inint
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}`)
|
||||
})
|
||||
|
||||
221
module/server/koa/linuxdo.js
Normal file
221
module/server/koa/linuxdo.js
Normal 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()
|
||||
@@ -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 + token(md5密码哈希),此接口验证并返回 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()
|
||||
|
||||
22
module/server/koa/middleware/errorHandler.js
Normal file
22
module/server/koa/middleware/errorHandler.js
Normal 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: '服务器内部错误,请稍后再试!' }
|
||||
}
|
||||
}
|
||||
}
|
||||
19
module/server/koa/middleware/ipFilter.js
Normal file
19
module/server/koa/middleware/ipFilter.js
Normal 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()
|
||||
}
|
||||
61
module/server/koa/middleware/rateLimiter.js
Normal file
61
module/server/koa/middleware/rateLimiter.js
Normal 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()
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user