This commit is contained in:
艾贤凌
2026-03-16 12:05:55 +08:00
parent af3a7c83e8
commit 6d4a72161f
33 changed files with 5671 additions and 178 deletions

View File

@@ -1,27 +1,38 @@
import jwt from "jsonwebtoken";
import * as log4js from "../log4js.js";
import jwt from 'jsonwebtoken'
import * as log4js from '../log4js.js'
const whiteList = [
'/',
'/api/login',
"/api/server/list"
'/api/register',
'/api/send_code',
'/api/reset_password',
'/api/check', // 旧版 token 验证,无需 JWT
'/api/server/list',
'/api/misc/agree',
'/api/config',
'/api/linuxdo/authorize',
'/api/linuxdo/callback',
'/api/linuxdo/bind',
'/api/bind_account', // 游戏服务端内部:绑定第三方账号
'/api/link', // 游戏服务端内部:按 connect_id 反查账号
]
async function auth(ctx, next) {
try {
log4js.koa.debug("接口请求:", ctx.path)
log4js.koa.debug(`鉴权: ${ctx.method} ${ctx.path}`)
if (whiteList.includes(ctx.path)) {
await next();
return; // 终止后续验证逻辑
await next()
return
}
const token = ctx.request.headers.authorization?.split(' ')[1];
if (!token) throw new Error('无token');
ctx.user = jwt.verify(token, process.env.SECRET_KEY);
await next();
const token = ctx.request.headers.authorization?.split(' ')[1]
if (!token) throw new Error('无token')
ctx.user = jwt.verify(token, process.env.SECRET_KEY || 'chuanqi_secret')
await next()
} catch (err) {
ctx.status = 401;
ctx.body = {msg: 'token无效或过期', code: 401};
ctx.status = 401
ctx.body = { code: 401, message: 'token无效或过期请重新登录' }
}
}
export default auth;
export default auth

View File

@@ -1,34 +1,83 @@
import Koa from 'koa';
import Router from 'koa-router';
import config from "../config/index.js"
import koaStatic from 'koa-static';
import registry from "./registry.js";
import * as log4js from "../log4js.js";
import auth from "./auth.js";
import login from "./login.js";
import Koa from 'koa'
import Router from 'koa-router'
import bodyParser from 'koa-bodyparser'
import koaStatic from 'koa-static'
import config from '../config/index.js'
import * as log4js from '../log4js.js'
import auth from './auth.js'
import login from './login.js'
import registry from './registry.js'
import linuxdo from './linuxdo.js'
import errorHandler from './middleware/errorHandler.js'
import ipFilter from './middleware/ipFilter.js'
import rateLimiter from './middleware/rateLimiter.js'
const app = new Koa();
const router = new Router();
const app = new Koa()
const router = new Router()
// 简单的路由示例
// ─── 基础路由 ────────────────────────────────────────────────────────────────
router.get('/', (ctx) => {
ctx.body = {message: 'Hello from Koa server!'};
});
ctx.body = { message: 'Chuanqi Server Running!' }
})
router.get('/api/config', (ctx) => {
ctx.body = {data: config}
ctx.body = {
data: {
gameName: config.game.name,
gameDescription: config.game.description,
codeOpen: config.code.open,
regCodeOpen: config.code.regCodeOpen,
regOpen: config.account.regOpen,
loginOpen: config.account.loginOpen,
linuxdoAuthorizeUrl: `/api/linuxdo/authorize`,
// 提现相关
withdrawRatio: config.withdraw.ratio,
withdrawMinOnce: config.withdraw.minOnce,
currencyName: config.currency.list[config.withdraw.type] || '货币',
}
}
})
app.proxy = true;
// ─── 中间件 ──────────────────────────────────────────────────────────────────
app.proxy = true
// 1. 统一错误处理(最外层,捕获所有异常)
app.use(errorHandler)
// 2. 请求日志
app.use(async (ctx, next) => {
log4js.koa.debug(`${ctx.method} ${ctx.path}`)
await next()
})
// 3. IP 黑名单过滤
app.use(ipFilter)
// 4. 请求限流(防暴力破解)
app.use(rateLimiter)
// 5. body 解析
app.use(bodyParser({
enableTypes: ['json', 'form'],
formLimit: '10mb',
jsonLimit: '10mb',
}))
// 6. JWT 鉴权
app.use(auth)
app.use(router.routes());
app.use(registry)
// 路由挂载
app.use(router.routes())
app.use(router.allowedMethods())
app.use(login)
app.use(router.allowedMethods());
app.use(registry)
app.use(linuxdo)
// 静态文件(部署时前端 dist 挂到 /www
app.use(koaStatic('/www'))
const PORT = process.env.PORT || 3001;
// ─── 启动 ────────────────────────────────────────────────────────────────────
const PORT = process.env.PORT || 3001
app.listen(PORT, () => {
log4js.koa.info(`Koa server is running on port ${PORT}`);
});
log4js.koa.info(`🚀 Koa server running on port ${PORT}`)
})

View File

@@ -0,0 +1,221 @@
import Router from 'koa-router'
import mysql from '../mysql/index.js'
import * as log4js from '../log4js.js'
import config from '../config/index.js'
import { encryptPassword } from '../utils.js'
const router = new Router()
function ok(ctx, data = {}, message = '操作成功') {
ctx.body = { code: 0, message, ...data }
}
function fail(ctx, message = '操作失败', code = 1) {
ctx.body = { code, message }
}
/**
* 发起 HTTP 请求(替代 PHP curl
*/
async function fetchJson(url, options = {}) {
const res = await fetch(url, options)
return res.json()
}
// ─── GET /api/linuxdo/authorize 跳转 LinuxDo 授权 ────────────────────────────
router.get('/api/linuxdo/authorize', (ctx) => {
const { clientId, redirectUri, authorizeUrl } = config.linuxdo
const url = `${authorizeUrl}?response_type=code&client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}`
ctx.redirect(url)
})
// ─── GET /api/linuxdo/callback LinuxDo OAuth 回调 ────────────────────────────
router.get('/api/linuxdo/callback', async (ctx) => {
const { code } = ctx.query
if (!code) {
ctx.status = 400
return fail(ctx, '缺少 code 参数')
}
const { clientId, clientSecret, redirectUri, tokenUrl, userUrl } = config.linuxdo
// 1. 换取 access_token
const tokenKey = Buffer.from(`${clientId}:${clientSecret}`).toString('base64')
let tokenData
try {
tokenData = await fetchJson(tokenUrl, {
method: 'POST',
headers: {
'Authorization': `Basic ${tokenKey}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
}).toString(),
})
} catch (e) {
log4js.koa.error('LinuxDo 获取 token 失败', e.message)
return ctx.redirect('/?error=token_failed')
}
if (!tokenData?.access_token) {
log4js.koa.warn('LinuxDo token 异常', JSON.stringify(tokenData))
return ctx.redirect('/?error=token_invalid')
}
// 2. 获取用户信息
let userInfo
try {
userInfo = await fetchJson(userUrl, {
headers: { 'Authorization': `Bearer ${tokenData.access_token}` },
})
} catch (e) {
return ctx.redirect('/?error=user_failed')
}
const connectId = userInfo?.username
if (!connectId) return ctx.redirect('/?error=user_invalid')
// 3. 查找本地绑定关系
const [bindRows] = await mysql.query(
'SELECT username FROM player_connect_threeparty WHERE type = ? AND connect_id = ? LIMIT 1',
['linuxdo', connectId]
)
if (bindRows.length > 0) {
// 已绑定:直接查账号密码并跳转游戏
const [playerRows] = await mysql.query(
'SELECT username, password FROM player WHERE username = ? LIMIT 1',
[bindRows[0].username]
)
if (playerRows.length > 0) {
const p = playerRows[0]
return ctx.redirect(`/play?account=${p.username}&token=${p.password}`)
}
}
// 4. 未绑定:跳转到前端绑定页面,携带 connect_id
return ctx.redirect(`/linuxdo-bind?connect_id=${encodeURIComponent(connectId)}`)
})
// ─── POST /api/linuxdo/bind 绑定 LinuxDo 账号 ────────────────────────────────
router.post('/api/linuxdo/bind', async (ctx) => {
const { account, password, connect_id, action } = ctx.request.body
if (!connect_id) return fail(ctx, '缺少 connect_id')
if (action === 'register') {
// 自动用 LinuxDo 账号名注册
const username = connect_id
const pwd = encryptPassword('linuxdo_' + connect_id)
// 尝试注册,如果账号已存在则直接绑定
const [existRows] = await mysql.query('SELECT id FROM player WHERE username = ?', [username])
if (!existRows.length) {
await mysql.query(
'INSERT INTO player (username, password, server_id, reg_time, reg_ip) VALUES (?, ?, 1, NOW(), "linuxdo")',
[username, pwd]
)
}
await _bindConnect(username, 'linuxdo', connect_id)
const [player] = await mysql.query('SELECT password FROM player WHERE username = ?', [username])
return ok(ctx, { account: username, token: player[0]?.password }, '绑定成功')
}
// 绑定已有账号
if (!account || !password) return fail(ctx, '请输入账号和密码')
const encPwd = encryptPassword(password)
const [playerRows] = await mysql.query(
'SELECT username, password FROM player WHERE username = ? AND password = ?',
[account, encPwd]
)
if (!playerRows.length) return fail(ctx, '账号或密码不正确!')
await _bindConnect(account, 'linuxdo', connect_id)
log4js.koa.info('LinuxDo 绑定成功', account, connect_id)
return ok(ctx, { account, token: playerRows[0].password }, '绑定成功')
})
// ─── GET /api/bind 查询当前用户的第三方绑定关系 ──────────────────────────────
// 需要 JWT 鉴权(已登录状态)
router.get('/api/bind', async (ctx) => {
const account = ctx.user?.username
if (!account) return fail(ctx, '未登录', 401)
const [rows] = await mysql.query(
'SELECT type, connect_id, created_at FROM player_connect_threeparty WHERE username = ?',
[account]
)
const bindings = {}
for (const row of rows) {
bindings[row.type] = {
connectId: row.connect_id,
createdAt: row.created_at,
}
}
return ok(ctx, { bindings }, '查询成功')
})
// ─── POST /api/bind_account 绑定第三方账号(游戏服务端内部回调)──────────────
// 对应 PHP api.php case 'bind':写入 player_connect_threeparty返回账号密码哈希
// 注意:此接口供游戏服务端内部使用,不需要 JWT在 auth.js 白名单中配置)
router.post('/api/bind_account', async (ctx) => {
const { account, connect_id, tp_type } = ctx.request.body
const tpType = tp_type || 'linuxdo'
if (!account || !connect_id) return fail(ctx, '参数错误')
// 检查账号是否存在
const [playerRows] = await mysql.query(
'SELECT password FROM player WHERE username = ? LIMIT 1',
[account]
)
if (!playerRows.length) return fail(ctx, '账号不存在')
// 写入绑定关系IGNORE 防止重复)
await mysql.query(
'INSERT IGNORE INTO player_connect_threeparty (username, type, connect_id) VALUES (?, ?, ?)',
[account, tpType, connect_id]
)
log4js.koa.info('bind_account', account, tpType, connect_id)
// 兼容旧版 PHP 返回:{ password: md5哈希 }
return ok(ctx, { password: playerRows[0].password }, '绑定成功')
})
// ─── GET /api/link 按第三方 connect_id 查询绑定的本地账号 ──────────────────────
// 对应 PHP api.php case 'link':通过 linuxdo connect_id 反查本地账号
// 供游戏服务端内部使用,不需要 JWT
router.get('/api/link', async (ctx) => {
const { connect_id, tp_type } = ctx.query
const tpType = tp_type || 'linuxdo'
if (!connect_id) return fail(ctx, '参数错误')
const [bindRows] = await mysql.query(
'SELECT username FROM player_connect_threeparty WHERE type = ? AND connect_id = ? LIMIT 1',
[tpType, connect_id]
)
if (!bindRows.length) return fail(ctx, '未绑定', 1)
const [playerRows] = await mysql.query(
'SELECT username, password FROM player WHERE username = ? LIMIT 1',
[bindRows[0].username]
)
if (!playerRows.length) return fail(ctx, '账号不存在')
return ok(ctx, { data: { username: playerRows[0].username, password: playerRows[0].password } }, '查询成功')
})
async function _bindConnect(username, type, connectId) {
// upsert存在则忽略
await mysql.query(
'INSERT IGNORE INTO player_connect_threeparty (username, type, connect_id) VALUES (?, ?, ?)',
[username, type, connectId]
)
}
export default router.routes()

View File

@@ -1,34 +1,267 @@
import Router from 'koa-router';
import mysql from "../mysql/index.js";
import jwt from "jsonwebtoken";
import * as log4js from "../log4js.js";
import {time} from "../utils.js";
import Router from 'koa-router'
import mysql from '../mysql/index.js'
import jwt from 'jsonwebtoken'
import * as log4js from '../log4js.js'
import config from '../config/index.js'
import { time, unixTime, encryptPassword, generateCode, getClientIp, isValidAccount, isValidEmail, getDeviceInfo } from '../utils.js'
import { sendCodeMail } from '../mail.js'
const router = new Router()
router.post("/api/login", async (ctx) => {
const {username, password} = ctx.request.body
if (['admin'].includes(username)) return ctx.body = {code: 1, message: "该账户不对外开放"}
const [rows] = await mysql.query("SELECT * FROM mir_web.player WHERE username = ? AND password = ?", [username, password])
if (rows?.length == 1) {
const token = jwt.sign(rows[0], process.env.SECRET_KEY, {expiresIn: '24h'});
return ctx.body = {code: 0, message: "登录成功", token}
// ─── 工具函数 ────────────────────────────────────────────────────────────────
function ok(ctx, data = {}, message = '操作成功') {
ctx.body = { code: 0, message, ...data }
}
function fail(ctx, message = '操作失败', code = 1) {
ctx.body = { code, message }
}
// ─── POST /api/login 登录 ────────────────────────────────────────────────────
router.post('/api/login', async (ctx) => {
const { username, password } = ctx.request.body
if (!username || !password) return fail(ctx, '请输入账号和密码')
if (config.account.adminAccount === username) return fail(ctx, '该账户不对外开放')
if (!config.account.loginOpen) return fail(ctx, '内部测试中,未开放登录,如需体验请联系客服。')
const ip = getClientIp(ctx)
const encPwd = encryptPassword(password)
const [rows] = await mysql.query(
'SELECT * FROM player WHERE username = ? AND password = ?',
[username, encPwd]
)
if (rows?.length !== 1) {
log4js.koa.warn('登录失败', username, ip)
return fail(ctx, '传送员无法匹配此账号,请检查!')
}
log4js.koa.error("用户登录失败", username)
return ctx.body = {code: 1, message: "用户名或密码错误"}
const token = jwt.sign({ ...rows[0] }, process.env.SECRET_KEY || 'chuanqi_secret', { expiresIn: '24h' })
log4js.koa.info('用户登录成功', username, ip)
return ok(ctx, { token }, '欢迎来到清渊传奇,正在传送…')
})
router.post("/api/enter_game", async (ctx) => {
const {srvId, account} = ctx.request.body
if (!srvId || !account) return ctx.body = {code: 1, message: "参数错误"}
log4js.koa.info("用户进入游戏", account, ctx.ip)
await mysql.query("UPDATE mir_web.player_game SET login_time = ?,login_ip = ? WHERE username = ?", [time(), ctx.ip, account])
return ctx.body = {code: 0, message: "进入游戏成功"}
// ─── POST /api/register 注册 ─────────────────────────────────────────────────
router.post('/api/register', async (ctx) => {
if (!config.account.regOpen) return fail(ctx, '内部测试中,未开放注册,如需体验请联系客服。')
const { username, password, password2, serverId, email, code } = ctx.request.body
const ip = getClientIp(ctx)
// 校验账号
if (!username) return fail(ctx, `请输入${config.account.name}${config.account.nameSuffix}`)
if (!isValidAccount(username)) return fail(ctx, `${config.account.name}${config.account.nameSuffix}为6-16位字母/数字/下划线`)
if (config.account.retainAccounts.includes(username.toLowerCase())) return fail(ctx, `抱歉!此${config.account.name}已被占用,请更换。`)
// 校验密码
if (!password) return fail(ctx, `请输入${config.account.name}${config.account.passwordSuffix}`)
if (password.length < 6 || password.length > 16) return fail(ctx, `${config.account.passwordSuffix}长度为6-16个字符`)
if (password !== password2) return fail(ctx, `两次输入的${config.account.passwordSuffix}不一致!`)
// 校验区服
if (!serverId) return fail(ctx, '请选择区服!')
// 邮箱校验(选填时只校验格式)
if (email && !isValidEmail(email)) return fail(ctx, '邮箱地址格式错误!')
// 验证码校验
if (config.code.open && config.code.regCodeOpen) {
if (!email) return fail(ctx, '请输入邮箱地址!')
if (!code || code.length !== config.code.length) return fail(ctx, `验证码长度为${config.code.length}位!`)
const [verifyRows] = await mysql.query(
'SELECT id, code FROM verify WHERE account = ? AND email = ? AND type = 1',
[username, email]
)
if (!verifyRows.length || verifyRows[0].code !== code) return fail(ctx, '验证码无效!')
}
// 每日注册限制
if (config.account.dayMaxReg) {
const [regRows] = await mysql.query(
"SELECT id FROM player WHERE reg_ip = ? AND DATE(FROM_UNIXTIME(reg_time)) = CURDATE()",
[ip]
)
if (regRows.length >= config.account.dayMaxReg) return fail(ctx, '您今日注册量已达上限,请明日再试~', 10)
}
// 检查账号是否已存在
const [existRows] = await mysql.query('SELECT id FROM player WHERE username = ?', [username])
if (existRows.length > 0) return fail(ctx, `${config.account.name}已被其他勇士占用!请更换。`)
// 检查邮箱是否已被占用
if (email) {
const [emailRows] = await mysql.query('SELECT id FROM player WHERE email = ?', [email])
if (emailRows.length > 0) return fail(ctx, '此邮箱地址已被其他勇士占用!请更换。')
}
const encPwd = encryptPassword(password)
const nowTime = unixTime()
// 获取设备信息
const ua = ctx.request.headers['user-agent'] || ''
const deviceInfo = getDeviceInfo(ua)
// 读取代理人 ID来自 query 参数 agent 或请求体)
const agentId = parseInt(ctx.request.body.agent_id || ctx.query.agent_id) || 0
const [result] = await mysql.query(
'INSERT INTO player (username, password, server_id, email, agent_id, reg_time, reg_ip, device, os, browse) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[username, encPwd, parseInt(serverId), email || '', agentId, nowTime, ip, deviceInfo.device, deviceInfo.os, deviceInfo.browse]
)
if (result.affectedRows < 1) return fail(ctx, `${config.account.name}获取失败,请重试~`)
// 删除验证码
if (config.code.open && config.code.regCodeOpen && email) {
await mysql.query('DELETE FROM verify WHERE account = ? AND email = ? AND type = 1', [username, email])
}
log4js.koa.info('用户注册成功', username, ip)
return ok(ctx, { token: encPwd }, `恭喜勇士!获得${config.account.name},请牢记${config.account.passwordSuffix}!准备开启传奇之旅..`)
})
router.get("/api/server/list", async (ctx) => {
const [rows] = await mysql.query("SELECT * FROM mir_web.server WHERE status >= 1 ORDER BY server_id ASC limit 1000")
return ctx.body = {code: 0, message: "获取服务器列表成功", data: rows}
// ─── POST /api/reset_password 找回/修改密码 ──────────────────────────────────
router.post('/api/reset_password', async (ctx) => {
if (!config.code.open) return fail(ctx, '验证码系统尚未开启!找回密码请联系客服。')
const { username, email, password, password2, code } = ctx.request.body
if (!username || !isValidAccount(username)) return fail(ctx, `请输入正确的${config.account.name}${config.account.nameSuffix}`)
if (!email || !isValidEmail(email)) return fail(ctx, '请输入正确的邮箱地址!')
if (!password || password.length < 6 || password.length > 16) return fail(ctx, `${config.account.passwordSuffix}长度为6-16个字符`)
if (password !== password2) return fail(ctx, `两次输入的${config.account.passwordSuffix}不一致!`)
if (!code || code.length !== config.code.length) return fail(ctx, `请输入${config.code.length}位验证码!`)
// 检查账号+邮箱是否匹配
const [playerRows] = await mysql.query(
'SELECT id FROM player WHERE username = ? AND email = ?',
[username, email]
)
if (!playerRows.length) return fail(ctx, '传送员无法匹配此账号,请检查!')
// 检查验证码
const [verifyRows] = await mysql.query(
'SELECT id, code FROM verify WHERE email = ? AND type = 2',
[email]
)
if (!verifyRows.length || verifyRows[0].code !== code) return fail(ctx, '验证码不正确!')
const encPwd = encryptPassword(password)
await mysql.query('UPDATE player SET password = ? WHERE username = ? AND email = ?', [encPwd, username, email])
await mysql.query('DELETE FROM verify WHERE id = ? AND type = 2', [verifyRows[0].id])
log4js.koa.info('用户重置密码成功', username)
return ok(ctx, {}, `${config.account.passwordSuffix}修改成功!`)
})
// ─── POST /api/send_code 发送邮箱验证码 ──────────────────────────────────────
router.post('/api/send_code', async (ctx) => {
if (!config.code.open) return fail(ctx, '验证码系统尚未开启!')
const { username, email, type } = ctx.request.body // type: 1=注册 2=找回密码
const typeInt = parseInt(type)
const ip = getClientIp(ctx)
if (![1, 2].includes(typeInt)) return fail(ctx, '参数错误!')
if (!username || !isValidAccount(username)) return fail(ctx, `请输入${config.account.name}${config.account.nameSuffix}`)
if (!email || !isValidEmail(email)) return fail(ctx, '请输入正确的邮箱地址!')
if (1 === typeInt) {
if (!config.account.regOpen) return fail(ctx, '内部测试中,未开放注册,如需体验请联系客服。')
if (config.account.retainAccounts.includes(username.toLowerCase())) return fail(ctx, `${config.account.name}已被占用,请更换。`)
// 每日注册限制
if (config.account.dayMaxReg) {
const [regRows] = await mysql.query(
"SELECT id FROM player WHERE reg_ip = ? AND DATE(FROM_UNIXTIME(reg_time)) = CURDATE()",
[ip]
)
if (regRows.length >= config.account.dayMaxReg) return fail(ctx, '您今日注册量已达上限,请明日再试~', 10)
}
// 检查账号是否已存在
const [existRows] = await mysql.query('SELECT id FROM player WHERE username = ?', [username])
if (existRows.length > 0) return fail(ctx, `${config.account.name}已被其他勇士占用!请更换。`)
// 检查邮箱是否已被占用
const [emailRows] = await mysql.query('SELECT id FROM player WHERE email = ?', [email])
if (emailRows.length > 0) return fail(ctx, '此邮箱地址已被其他勇士占用!请更换。')
} else {
// 找回密码:检查账号+邮箱是否匹配
const [playerRows] = await mysql.query(
'SELECT id FROM player WHERE username = ? AND email = ?',
[username, email]
)
if (!playerRows.length) return fail(ctx, '传送员无法匹配此账号,请检查!')
}
// 检查发送间隔
const nowTime = unixTime()
const [existVerify] = await mysql.query(
'SELECT id, time FROM verify WHERE account = ? AND email = ? AND type = ?',
[username, email, typeInt]
)
if (existVerify.length > 0) {
const leftTime = config.code.sendInterval - (nowTime - existVerify[0].time)
if (leftTime > 0) return fail(ctx, `操作频繁!请${leftTime}秒后再发送~`, 1)
}
const code = generateCode(config.code.length, 'NUMBER')
const sent = await sendCodeMail(email, username, code, typeInt)
if (!sent) return fail(ctx, '验证码发送失败!请重试~')
if (existVerify.length > 0) {
await mysql.query(
'UPDATE verify SET code = ?, time = ?, ip = ? WHERE id = ? AND type = ?',
[code, nowTime, ip, existVerify[0].id, typeInt]
)
} else {
await mysql.query(
'INSERT INTO verify (account, type, email, code, time, ip) VALUES (?, ?, ?, ?, ?, ?)',
[username, typeInt, email, code, nowTime, ip]
)
}
return ok(ctx, { time: config.code.sendInterval }, `验证码已发送到您的邮箱:${email},请查收!`)
})
// ─── POST /api/enter_game 进入游戏 ───────────────────────────────────────────
router.post('/api/enter_game', async (ctx) => {
const { srvId, account } = ctx.request.body
if (!srvId || !account) return fail(ctx, '参数错误')
const ip = getClientIp(ctx)
log4js.koa.info('用户进入游戏', account, `srvId=${srvId}`, ip)
await mysql.query(
'UPDATE player SET login_time = ?, login_ip = ? WHERE username = ?',
[time(), ip, account]
)
return ok(ctx, {}, '进入游戏成功')
})
// ─── POST /api/check Token 校验(兼容旧版游戏客户端,接受 md5 密码 token────
// 旧版游戏客户端传 account + tokenmd5密码哈希此接口验证并返回 JWT
router.post('/api/check', async (ctx) => {
const { account, token } = ctx.request.body
if (!account || !token) return fail(ctx, '参数错误')
const [rows] = await mysql.query(
'SELECT * FROM player WHERE username = ? AND password = ?',
[account, token]
)
if (!rows?.length) return fail(ctx, '账号验证失败')
// 签发 JWT 供后续接口使用
const jwtToken = jwt.sign({ ...rows[0] }, process.env.SECRET_KEY || 'chuanqi_secret', { expiresIn: '24h' })
return ok(ctx, { token: jwtToken, account }, '验证成功')
})
// ─── GET /api/check Token 验证GET 方式,部分游戏客户端使用 query 参数)────
router.get('/api/check', async (ctx) => {
const { account, token } = ctx.query
if (!account || !token) return fail(ctx, '参数错误')
const [rows] = await mysql.query(
'SELECT id, username FROM player WHERE username = ? AND password = ?',
[account, token]
)
if (!rows?.length) return fail(ctx, '账号验证失败')
return ok(ctx, { account }, '验证成功')
})
export default router.routes()

View File

@@ -0,0 +1,22 @@
import * as log4js from '../../log4js.js'
/**
* 统一错误处理中间件
* 捕获所有未处理异常,规范化错误响应格式,避免泄露内部错误信息
*/
export default async function errorHandler(ctx, next) {
try {
await next()
} catch (err) {
log4js.koa.error(`[${ctx.method}] ${ctx.path}${err.message}`, err.stack || '')
// 已知业务错误(主动 throw new Error直接返回消息
if (err.status) {
ctx.status = err.status
ctx.body = { code: err.status, message: err.message || '请求错误' }
} else {
ctx.status = 500
ctx.body = { code: 500, message: '服务器内部错误,请稍后再试!' }
}
}
}

View File

@@ -0,0 +1,19 @@
import config from '../../config/index.js'
import { getClientIp } from '../../utils.js'
/**
* IP 黑名单过滤中间件
* 对所有 /api/* 请求检查,命中封禁列表时返回 403
*/
export default async function ipFilter(ctx, next) {
if (!ctx.path.startsWith('/api/')) {
return next()
}
const ip = getClientIp(ctx)
if (config.account.denyIps && config.account.denyIps.includes(ip)) {
ctx.status = 403
ctx.body = { code: 403, message: '当前未开放访问!' }
return
}
return next()
}

View File

@@ -0,0 +1,61 @@
import * as log4js from '../../log4js.js'
import { getClientIp } from '../../utils.js'
/**
* 简单内存限流中间件(基于滑动窗口计数)
*
* 默认规则:
* - 注册/发验证码:每 IP 每分钟最多 5 次
* - 登录:每 IP 每分钟最多 20 次
* - 其余 /api/*:每 IP 每分钟最多 100 次
*
* 生产环境建议替换为 Redis 方案以支持多实例
*/
// Map<ip+path, [timestamps...]>
const requestMap = new Map()
// 配置:[path前缀/全路径, 时间窗口(ms), 最大请求数]
const RULES = [
['/api/register', 60_000, 5],
['/api/send_code', 60_000, 5],
['/api/login', 60_000, 20],
['/api/', 60_000, 200],
]
// 定时清理过期记录,避免内存泄漏
setInterval(() => {
const now = Date.now()
for (const [key, timestamps] of requestMap.entries()) {
const fresh = timestamps.filter(t => now - t < 60_000)
if (fresh.length === 0) requestMap.delete(key)
else requestMap.set(key, fresh)
}
}, 30_000)
export default async function rateLimiter(ctx, next) {
if (!ctx.path.startsWith('/api/')) return next()
const ip = getClientIp(ctx)
const now = Date.now()
// 匹配第一条符合的规则
const rule = RULES.find(([prefix]) => ctx.path.startsWith(prefix))
if (!rule) return next()
const [prefix, windowMs, maxReq] = rule
const key = `${ip}:${prefix}`
const timestamps = (requestMap.get(key) || []).filter(t => now - t < windowMs)
timestamps.push(now)
requestMap.set(key, timestamps)
if (timestamps.length > maxReq) {
log4js.koa.warn(`限流触发: ${ip} ${ctx.path} (${timestamps.length}/${maxReq})`)
ctx.status = 429
ctx.body = { code: 429, message: '请求过于频繁,请稍后再试!' }
return
}
return next()
}

View File

@@ -1,5 +1,194 @@
import Router from 'koa-router';
import Router from 'koa-router'
import mysql from '../mysql/index.js'
import getGameDB from '../mysql/gameDB.js'
import * as log4js from '../log4js.js'
import config from '../config/index.js'
import { time, unixTime, getClientIp } from '../utils.js'
import { readFileSync, existsSync } from 'fs'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
const __dirname = dirname(fileURLToPath(import.meta.url))
const router = new Router()
function ok(ctx, data = {}, message = '操作成功') {
ctx.body = { code: 0, message, ...data }
}
function fail(ctx, message = '操作失败', code = 1) {
ctx.body = { code, message }
}
// ─── GET /api/server/list 区服列表 ──────────────────────────────────────────
router.get('/api/server/list', async (ctx) => {
const account = ctx.query.account || ''
const nowTime = unixTime()
const newSrvTime = 7 * 24 * 60 * 60
const [rows] = await mysql.query(
'SELECT id, server_id, name, host, port, status, UNIX_TIMESTAMP(time) as time, merge_id FROM server WHERE status >= 1 ORDER BY server_id ASC LIMIT 1000'
)
const serverlist = rows.map(row => {
const sid = row.merge_id || row.server_id
return {
id: row.id,
serverName: (row.name || config.game.firstName) + row.server_id + '区',
srvaddr: (row.host && row.host !== '127.0.0.1') ? row.host : config.game.host,
srvport: row.port || (config.game.port + sid),
srvid: sid,
type: row.status === 3 ? 3 : (nowTime - row.time <= newSrvTime ? 1 : 2), // 1:新 2:火爆 3:维护
opentime: time(row.time * 1000),
pf: config.game.pf,
serverAlias: 's' + sid,
originalSrvid: sid,
}
})
return ok(ctx, {
login: [999, 997, 990],
serverlist: [{ name: '全部区服', serverlist }]
}, '获取服务器列表成功')
})
// ─── GET /api/misc/agree 用户协议 ───────────────────────────────────────────
// 优先从 config/agreement.html 文件读取,不存在则回退到 config.agree 字符串配置
router.get('/api/misc/agree', async (ctx) => {
const agreePath = join(__dirname, '../config/agreement.html')
if (existsSync(agreePath)) {
ctx.type = 'html'
ctx.body = readFileSync(agreePath, 'utf-8')
} else {
ctx.body = config.agree
}
})
// ─── POST /api/report/chat 上报聊天 ─────────────────────────────────────────
router.post('/api/report/chat', async (ctx) => {
const { server_id, account, role_id, channel_id, content, cross } = ctx.request.body
const serverId = parseInt((server_id || '').toString().replace(/^s/, ''))
if (!serverId || !account || !role_id || !content) return fail(ctx, 'param error')
if (account.length > 26) return fail(ctx, 'param error')
if (parseInt(channel_id) > 10) return fail(ctx, 'param error')
if (content.length > 255) return fail(ctx, 'param error')
// 验证账号 token
const token = ctx.request.headers.authorization?.split(' ')[1]
if (!token) return fail(ctx, '未授权', 401)
const nowTime = time()
await mysql.query(
'INSERT INTO chat (account, server_id, role_id, channel_id, content, is_cross, time) VALUES (?, ?, ?, ?, ?, ?, ?)',
[account, serverId, parseInt(role_id), parseInt(channel_id) || 0, content, cross == 1 ? 1 : 0, nowTime]
)
return ok(ctx)
})
// ─── POST /api/game/withdraw 提现 ───────────────────────────────────────────
router.post('/api/game/withdraw', async (ctx) => {
const {
server_id, account, role_id, role_name, pay_type, pay_account, amount
} = ctx.request.body
const serverId = parseInt((server_id || '').toString().replace(/^s/, ''))
const roleId = parseInt(role_id)
const payType = parseInt(pay_type)
const amountInt = parseInt(amount)
if (!serverId || !account || !roleId || !role_name || !pay_account || !amountInt) return fail(ctx, '参数错误!')
if (account.length > 26) return fail(ctx, '参数错误!')
if (role_name.length > 24) return fail(ctx, '参数错误!')
if (![0, 1].includes(payType)) return fail(ctx, '收款账户类型不正确!')
if (pay_account.length > 30) return fail(ctx, '收款账户格式不正确!')
const withdrawCfg = config.withdraw
const currencyName = config.currency.list[withdrawCfg.type]
const currencyField = config.currency.field[withdrawCfg.type]
if (amountInt < withdrawCfg.ratio) return fail(ctx, `最低提现数量为${withdrawCfg.ratio}`)
const minAmount = withdrawCfg.ratio * withdrawCfg.minOnce
if (amountInt < minAmount) return fail(ctx, `单次提现数量不能低于${minAmount}`)
// 验证账号
const [playerRows] = await mysql.query(
'SELECT id FROM player WHERE username = ?', [account]
)
if (!playerRows.length) return fail(ctx, '账号不存在!')
// 提现间隔限制
const nowTime = unixTime()
const [lastWithdraw] = await mysql.query(
'SELECT UNIX_TIMESTAMP(time) as time FROM withdraw WHERE server_id = ? AND role_id = ? ORDER BY id DESC LIMIT 1',
[serverId, roleId]
)
if (lastWithdraw.length > 0 && nowTime - lastWithdraw[0].time < withdrawCfg.intervalSec) {
const leftSec = withdrawCfg.intervalSec - (nowTime - lastWithdraw[0].time)
return fail(ctx, `请等待 ${leftSec} 秒后再试~`)
}
// ── 连接游戏区服数据库,验证货币余额 ─────────────────────────────────────────
let gameDB
try {
gameDB = getGameDB(serverId)
} catch (e) {
log4js.koa.error('连接游戏DB失败', serverId, e.message)
return fail(ctx, '游戏服务器连接失败,请稍后再试!')
}
// 查询角色货币余额(表名为 characters字段由 currency.field 配置)
let currentBalance = 0
if (gameDB && currencyField) {
try {
const [charRows] = await gameDB.query(
`SELECT \`${currencyField}\` as balance FROM characters WHERE id = ? LIMIT 1`,
[roleId]
)
if (!charRows.length) return fail(ctx, '角色不存在,请确认区服和角色是否正确!')
currentBalance = parseInt(charRows[0].balance) || 0
} catch (e) {
log4js.koa.error('查询角色余额失败', serverId, roleId, e.message)
return fail(ctx, '查询角色数据失败,请稍后再试!')
}
if (currentBalance < amountInt) {
return fail(ctx, `您的${currencyName}余额不足(当前:${currentBalance},需要:${amountInt}`)
}
}
const money = Math.floor(amountInt / withdrawCfg.ratio)
// ── 调用游戏 GM 命令接口扣除货币 ─────────────────────────────────────────────
const gmHost = config.game.host
const gmPort = config.game.gmPort
// GM 接口格式operid=10030扣除货币
const gmUrl = `http://${gmHost}:${gmPort}/?operid=10030&serverid=${serverId}&roleid=${roleId}&type=${withdrawCfg.type}&num=${amountInt}`
let gmSuccess = false
try {
const gmRes = await fetch(gmUrl, { signal: AbortSignal.timeout(5000) })
const gmText = await gmRes.text()
// GM 接口返回 0 表示成功
gmSuccess = gmText.trim() === '0' || gmText.includes('"result":0') || gmText.includes('success')
log4js.koa.info(`GM 命令返回: ${gmText.trim()}`, gmUrl)
} catch (e) {
log4js.koa.error('GM 命令调用失败', e.message, gmUrl)
return fail(ctx, '扣除货币失败,请联系客服!')
}
if (!gmSuccess) {
log4js.koa.warn('GM 命令返回失败', gmUrl)
return fail(ctx, '货币扣除失败,请联系客服处理!')
}
// ── 写入提现记录 ──────────────────────────────────────────────────────────────
await mysql.query(
'INSERT INTO withdraw (account, server_id, role_id, pay_type, pay_account, amount, money, time) VALUES (?, ?, ?, ?, ?, ?, ?, NOW())',
[account, serverId, roleId, payType, pay_account, amountInt, money]
)
log4js.koa.info(`提现成功: ${account} s${serverId} ${role_name} ${amountInt}${currencyName}=${money}`)
return ok(ctx, {}, `成功提现:${amountInt}${currencyName}\n收益人民币:${money}\n\n请留意您的收款账户余额。`)
})
export default router.routes()