import {Context} from "koa"; import * as crypto from 'crypto'; import {ChannelHandler, LoginResult, PaymentResult} from "../interfaces/ChannelHandler"; import {ChannelConfig} from "../../config/channelConfig"; import {PaymentHelper} from "../../utils/PaymentHelper"; import {setWebgameAuthInfo} from "../../utils/webgameAuthCache"; import {formatDate, getClientIp} from "../../utils/common"; import {WEBGAME_APP_ID, WEBGAME_REPORT_URL} from "../../config/thirdParams"; import {getRoleInfoByUidAndServerId} from "../../mongo/mongodb"; import {query} from "../../sql/query"; const logger = require("../../utils/log"); const User = require("../../model/UserModel"); const axios = require("axios"); const DEFAULT_GATE_DOMAIN = "https://mind.yishanyou.com/webgame/index.html"; export class WebGameChannelHandler implements ChannelHandler { private toIntOrZero(v: any): number { if (v === undefined || v === null || v === '') return 0; const n = Number(v); return Number.isFinite(n) ? Math.trunc(n) : 0; } // 上报登录/支付事件(fire-and-forget,不阻塞主流程) // 上报地址由 WEBGAME_REPORT_URL 配置,格式:POST /api/v2/webgame/log (application/x-www-form-urlencoded) private reportEvent(logType: 'login' | 'payment', params: Record, config: ChannelConfig): void { const reportUrl = WEBGAME_REPORT_URL; if (!reportUrl) return; const loginKey = config.loginConfig?.signKey ?? ''; const timestamp = String(Math.floor(Date.now() / 1000)); const userId = String(params.user_id ?? ''); const gameId = String(WEBGAME_APP_ID); const sign = crypto.createHash('md5').update(userId + gameId + timestamp + loginKey).digest('hex').toLowerCase(); const payload: Record = { user_id: userId, game_id: gameId, sign, timestamp, server_id: String(params.server_id ?? ''), log_type: logType, }; if (params.log_data) payload.log_data = params.log_data; if (params.ip_addr) payload.ip_addr = String(params.ip_addr); axios.post(reportUrl + '/api/v2/webgame/log', new URLSearchParams(payload).toString(), { headers: {'Content-Type': 'application/x-www-form-urlencoded'}, timeout: 5000, }).then((res: any) => { logger.info('WebGame 事件上报成功', {logType, result: res.data}); }).catch((err: any) => { logger.warn('WebGame 事件上报失败', {logType, error: err?.message}); }); } async handleLogin(ctx: Context,config:ChannelConfig): Promise { const data = ctx.request.body as Record; const uid = data.uid; // token 仅透传,不做校验 if (uid === undefined || uid === null) { return {code: 0, msg: "缺少必要参数: uid"}; } const uidStr = String(uid); let uidDecoded: string; try { uidDecoded = decodeURIComponent(uidStr.replace(/\+/g, "%20")); } catch { uidDecoded = uidStr; } const platformStr = data.platform == null ? "" : String(data.platform); // 登录成功后写入 accounts / account_login_logs(参考 checkUserToken) try { const ip = getClientIp(ctx); const create_time = formatDate(new Date()); const channel_id = config.channelId; const device_no = (data as any).device_no ?? ""; const reg_device = (data as any).reg_device ?? ""; const device_type = this.toIntOrZero((data as any).device_type); const device_model = (data as any).device_model ?? ""; const device_version = (data as any).device_version ?? ""; const system_version = (data as any).system_version ?? ""; const accountInfo = (await User.checkAccountIsExist(uidDecoded, channel_id))[0]; if (!accountInfo) { const accountRes = await User.createAccount( uidDecoded, channel_id, ip, device_no, reg_device, create_time, platformStr ); if (!accountRes || accountRes.affectedRows <= 0) { logger.error("WebGame 登录落库失败: 添加账户失败", {uid: uidDecoded, channel_id}); return {code: 0, msg: "添加账户失败"}; } } const logRes = await User.logAccountLogin( uidDecoded, ip, device_type, device_no, device_model, device_version, system_version, create_time, channel_id, platformStr ); if (!logRes || logRes.affectedRows <= 0) { logger.error("WebGame 登录落库失败: 添加登录日志失败", {uid: uidDecoded, channel_id}); return {code: 0, msg: "添加日志失败"}; } } catch (e: any) { logger.error("WebGame 登录落库异常", {msg: e?.message, stack: e?.stack}); return {code: 0, msg: "登录落库异常"}; } // 登录成功后打点上报 this.reportEvent('login', { user_id: uidDecoded, game_id: WEBGAME_APP_ID, server_id: (data as any).server_id ?? '', ip_addr: getClientIp(ctx), }, config); return { code: 1, msg: "success", data: { uid: uidDecoded, }, }; } /** * 联运 Web 游戏登录:生成带签名的游戏入口 URL。 * 签名 md5(uid+platform+gkey+skey+time+is_adult[+exts]+'#'+lkey),缺省字段在签名中按空串拼接;uid 为 urldecode 后的值参与签名。 * 必填:uid、platform、time、back_url、type;gkey、skey、is_adult、exts 可选(不传则不出现在 URL 查询串中)。 */ async getGameUrl(ctx: Context, config: ChannelConfig): Promise { const data = ctx.request.body as Record; const lkey = config.loginConfig?.signKey; if (!lkey) { logger.error("WebGame 获取游戏 URL 失败: 未配置登录密钥 loginConfig.signKey"); return {code: 0, msg: "服务器未配置登录密钥"}; } const gateDomain = DEFAULT_GATE_DOMAIN; const uid = data.uid; const platform = data.platform; const gkey = data.gkey; const skey = data.skey; const time = data.time; const is_adult = data.is_adult; const back_url = data.back_url; const type = data.type; if ( uid === undefined || uid === null || platform === undefined || platform === null || time === undefined || time === null || back_url === undefined || back_url === null || type === undefined || type === null ) { return { code: 0, msg: "缺少必要参数: uid, platform, time, back_url, type", }; } const uidStr = String(uid); let uidDecoded: string; try { uidDecoded = decodeURIComponent(uidStr.replace(/\+/g, "%20")); } catch { uidDecoded = uidStr; } const platformStr = String(platform); const gkeyStr = gkey === undefined || gkey === null ? "" : String(gkey); const skeyStr = skey === undefined || skey === null ? "" : String(skey); const timeStr = String(time); const isAdultStr = is_adult === undefined || is_adult === null ? "" : String(is_adult); const extsVal = data.exts; const hasExts = extsVal !== undefined && extsVal !== null && String(extsVal) !== ""; let signRaw = `${uidDecoded}${platformStr}${gkeyStr}${skeyStr}${timeStr}${isAdultStr}`; if (hasExts) { signRaw += String(extsVal); } signRaw += `#${lkey}`; const sign = crypto.createHash("md5").update(signRaw).digest("hex").toLowerCase(); const base = gateDomain.replace(/\/+$/, ""); const params = new URLSearchParams(); params.set("uid", uidDecoded); params.set("platform", platformStr); if (gkeyStr !== "") { params.set("gkey", gkeyStr); } if (skeyStr !== "") { params.set("skey", skeyStr); } params.set("time", timeStr); if (isAdultStr !== "") { params.set("is_adult", isAdultStr); } params.set("back_url", String(back_url)); params.set("type", String(type)); if (hasExts) { params.set("exts", String(extsVal)); } params.set("sign", sign); const gameUrl = `${base}/login.html?${params.toString()}`; // 供客户端只用 sign 调用 /webgame/auth 做权限判断 setWebgameAuthInfo(sign, { uid: uidDecoded, platform: platformStr, time: timeStr, gkey: gkeyStr || undefined, skey: skeyStr || undefined, is_adult: isAdultStr || undefined, exts: hasExts ? String(extsVal) : undefined, }); // 登录成功后写入 accounts / account_login_logs(参考 checkUserToken) try { const ip = getClientIp(ctx); const create_time = formatDate(new Date()); const channel_id = config.channelId; const device_no = (data as any).device_no ?? ""; const reg_device = (data as any).reg_device ?? ""; const device_type = this.toIntOrZero((data as any).device_type); const device_model = (data as any).device_model ?? ""; const device_version = (data as any).device_version ?? ""; const system_version = (data as any).system_version ?? ""; const accountInfo = (await User.checkAccountIsExist(uidDecoded, channel_id))[0]; if (!accountInfo) { const accountRes = await User.createAccount( uidDecoded, channel_id, ip, device_no, reg_device, create_time, platformStr ); if (!accountRes || accountRes.affectedRows <= 0) { logger.error("WebGame 登录落库失败: 添加账户失败", {uid: uidDecoded, channel_id}); return {code: 0, msg: "添加账户失败"}; } } const logRes = await User.logAccountLogin( uidDecoded, ip, device_type, device_no, device_model, device_version, system_version, create_time, channel_id, platformStr ); if (!logRes || logRes.affectedRows <= 0) { logger.error("WebGame 登录落库失败: 添加登录日志失败", {uid: uidDecoded, channel_id}); return {code: 0, msg: "添加日志失败"}; } } catch (e: any) { logger.error("WebGame 登录落库异常", {msg: e?.message, stack: e?.stack}); return {code: 0, msg: "登录落库异常"}; } logger.info("WebGame 生成游戏登录 URL", {gateDomain: base, signLen: signRaw.length}); return { code: 1, msg: "success", data: { gameUrl, }, }; } async handlePayment(ctx: Context, config: ChannelConfig): Promise { const data = ctx.request.body as any; logger.info("WebGame渠道支付回调参数", {url: ctx.href, params: data}); // 新回调字段(不验 sign) const status = data?.status; const amount = data?.amount; const channelOrderId = data?.channel_order_id ?? data?.game_order ?? data?.id; const cpOrderId = data?.channel_exts ?? data?.extras_params ?? data?.game_order; // 兼容旧字段(历史上有 id/user_id/sign 等) const user_id = data?.user_id ?? data?.userId ?? data?.channel_uid; if (!cpOrderId || !status || amount === undefined || amount === null) { logger.warn("WebGame渠道支付回调失败: 缺少必要参数", {cpOrderId, status, amount}); return {code: 0, msg: "缺少必要参数"}; } if (status !== "completed") { logger.warn("WebGame渠道支付状态非completed", {channelOrderId, status}); return {code: 0, msg: `支付状态非completed: ${status}`}; } try { const validation = await PaymentHelper.validateOrder(String(cpOrderId)); if (!validation.valid) { return { code: validation.message?.includes("重复发货") ? 1 : 0, msg: validation.message || "订单验证失败" }; } const orderInfo = validation.orderInfo; const payAmount = parseFloat(String(amount)); if (!Number.isFinite(payAmount)) { return {code: 0, msg: "支付金额格式错误"}; } if (Math.abs(Number(orderInfo.amount) - payAmount) > 0.01) { logger.warn("WebGame渠道支付金额不匹配", { channelOrderId, requestAmount: payAmount, orderAmount: orderInfo.amount }); return {code: 0, msg: `订单金额不一致: 订单金额${orderInfo.amount}元,支付金额${payAmount}元`}; } logger.info(`WebGame渠道支付订单开始发货`, { channelOrderId, cpOrderId, user_id, amount: payAmount }); const result = await PaymentHelper.deliverOrder( orderInfo, ctx.request.ip, validation.url, String(channelOrderId ?? ''), ); logger.info(`WebGame渠道支付订单发货完成`, {channelOrderId, cpOrderId, result}); // 支付成功后打点上报 if (result.code === 1) { this.reportEvent('payment', { user_id: user_id ?? orderInfo.uid, game_id: WEBGAME_APP_ID, server_id: String(orderInfo.server_id ?? ''), ip_addr: ctx.request.ip, log_data: JSON.stringify({ order_id: cpOrderId, channel_order_id: channelOrderId, amount: payAmount, }), }, config); } return result; } catch (error: any) { logger.error("WebGame渠道支付回调处理异常", {error: error.message, stack: error.stack}); return {code: 0, msg: "回调处理异常"}; } } // 角色查询接口(GET),平台查询指定区服下指定账号的角色信息 // sign = md5(user_id + server_id + time + login_key) async handleRoleList(ctx: Context, config: ChannelConfig): Promise { const q = {...ctx.query, ...ctx.request.body} as any; logger.info('WebGame 角色查询请求', {q}); const {user_id, pid, server_id, time, sign} = q; if (!user_id || !pid || !server_id || !time || !sign) { return {code: 2, message: '参数不全', data: []}; } const loginKey = config.loginConfig?.signKey ?? ''; const expectedSign = crypto.createHash('md5') .update(String(user_id) + String(server_id) + String(time) + loginKey) .digest('hex').toLowerCase(); if (expectedSign !== String(sign).toLowerCase()) { logger.warn('WebGame 角色查询签名错误', {user_id, server_id, expectedSign, sign}); return {code: 3, message: '签名错误', data: []}; } try { // 查 db_name const rows = await query( `SELECT db_name FROM game_server WHERE tag = ? AND sid = ? LIMIT 1`, [config.channelId, String(server_id)] ) as any[]; if (!rows || rows.length === 0 || !rows[0].db_name) { logger.warn('WebGame 角色查询找不到区服', {server_id, channelId: config.channelId}); return {code: 5, message: '服务器错误', data: []}; } const dbName = rows[0].db_name; const newUniqueTag = `${config.channelId}|${server_id}|${user_id}`; // 查 MongoDB 角色信息 const {getDb} = require("../../mongo/mongodb"); const db = getDb(dbName); const collection = db.collection('char'); const filter: any = {newUniqueTag}; if (q.role_id) filter.roleId = String(q.role_id); const docs = await collection.find(filter).toArray(); if (!docs || docs.length === 0) { return {code: 4, message: '未创建角色', data: []}; } const data = docs.map((doc: any) => ({ role_id: doc.roleId ?? doc._id.toString(), name: encodeURIComponent(doc.name ?? ''), lv: doc.lv ?? 0, sex: doc.sex ?? 'm', vocation: doc.vocation ?? 0, createTime: doc.createTime ? new Date(doc.createTime).toISOString().replace('T', ' ').slice(0, 19) : '', power: doc.zhandouli ?? doc.power ?? 0, })); return {code: 1, message: 'ok', data}; } catch (e: any) { logger.error('WebGame 角色查询异常', {msg: e?.message, stack: e?.stack}); return {code: 5, message: '服务器错误', data: []}; } } // 登录跳转接口:验签 + 落库,返回拼接了平台参数的游戏 URL // sign = md5(user_id + server_id + pid + time + login_key) // 时间戳有效期 10 分钟 async handleLoginUrl(ctx: Context, config: ChannelConfig): Promise { const q = {...ctx.query, ...ctx.request.body} as any; logger.info('WebGame 登录跳转请求', {q}); const {user_id, server_id, pid, time, sign} = q; if (!user_id || !server_id || !pid || !time || !sign) { return {code: 0, msg: '缺少必要参数'}; } const loginKey = config.loginConfig?.signKey ?? ''; const expectedSign = crypto.createHash('md5') .update(String(user_id) + String(server_id) + String(pid) + String(time) + loginKey) .digest('hex').toLowerCase(); if (expectedSign !== String(sign).toLowerCase()) { logger.warn('WebGame 登录跳转签名错误', {user_id, expectedSign, sign}); return {code: 0, msg: '签名错误'}; } const ts = parseInt(String(time)); if (Math.abs(Math.floor(Date.now() / 1000) - ts) > 600) { return {code: 0, msg: '登录链接已过期'}; } // 落库 try { const ip = getClientIp(ctx); const create_time = formatDate(new Date()); const channel_id = config.channelId; const uidStr = String(user_id); const accountInfo = (await User.checkAccountIsExist(uidStr, channel_id))[0]; if (!accountInfo) { await User.createAccount(uidStr, channel_id, ip, '', '', create_time, String(pid)); } await User.logAccountLogin(uidStr, ip, 0, '', '', '', '', create_time, channel_id, String(pid)); } catch (e: any) { logger.error('WebGame 登录跳转落库异常', {msg: e?.message}); } // 拼接游戏 URL const base = DEFAULT_GATE_DOMAIN.replace(/\/+$/, ''); const params = new URLSearchParams(); params.set('user_id', String(user_id)); params.set('server_id', String(server_id)); params.set('pid', String(pid)); params.set('time', String(time)); params.set('sign', String(sign)); if (q.ext != null) params.set('ext', String(q.ext)); if (q.client != null) params.set('client', String(q.client)); if (q.isAdult != null) params.set('isAdult', String(q.isAdult)); const gameUrl = `${base}?${params.toString()}`; logger.info('WebGame 登录跳转生成游戏URL', {user_id, server_id, gameUrl}); // 登录打点 this.reportEvent('login', { user_id, game_id: WEBGAME_APP_ID, server_id, ip_addr: getClientIp(ctx), }, config); return {code: 1, msg: 'success', data: {gameUrl}}; } // 充值下单接口(GET),平台调用我们,验证签名后返回确认 // sign = md5(user_id + server_id + role_id + time + pay_key) // extra_info 由客户端传入我们系统的 orderId,透传给回调 async handleCreateOrder(ctx: Context, config: ChannelConfig): Promise { const q = {...ctx.query, ...ctx.request.body} as any; logger.info('WebGame 充值下单请求', {q}); const {user_id, server_id, role_id, goods_id, goods_name, money, extra_info, sign, time} = q; if (!user_id || !server_id || !role_id || !goods_id || !money || !sign || !time) { return {code: 0, message: '缺少必要参数'}; } const payKey = config.paymentConfig?.callbackKey ?? ''; const expectedSign = crypto.createHash('md5') .update(String(user_id) + String(server_id) + String(role_id) + String(time) + payKey) .digest('hex').toLowerCase(); if (expectedSign !== String(sign).toLowerCase()) { logger.warn('WebGame 充值下单签名错误', {user_id, expectedSign, sign}); return {code: 2, message: '签名错误'}; } logger.info('WebGame 充值下单验证通过', {user_id, server_id, role_id, goods_id, money, extra_info}); return {code: 1, message: 'ok'}; } // 充值回调接口(GET),平台支付成功后调用,发货 // sign = md5(user_id + order_id + money + time + server_id + role_id + extra_info + pay_key) // extra_info 为客户端传入的内部 orderId,用于发货查询 async handlePayCallback(ctx: Context, config: ChannelConfig): Promise { const q = {...ctx.query, ...ctx.request.body} as any; logger.info('WebGame 充值回调请求', {q}); const {user_id, pid, order_id, money, time, server_id, role_id, extra_info, sign} = q; if (!user_id || !order_id || !money || !time || !server_id || !role_id || !sign) { return {code: 4, message: '缺少必要参数'}; } const payKey = config.paymentConfig?.callbackKey ?? ''; const extraStr = extra_info == null ? '' : String(extra_info); const expectedSign = crypto.createHash('md5') .update(String(user_id) + String(order_id) + String(money) + String(time) + String(server_id) + String(role_id) + extraStr + payKey) .digest('hex').toLowerCase(); if (expectedSign !== String(sign).toLowerCase()) { logger.warn('WebGame 充值回调签名错误', {user_id, order_id, expectedSign, sign}); return {code: 2, message: '签名错误'}; } // extra_info 为内部 orderId if (!extraStr) { logger.warn('WebGame 充值回调 extra_info 为空,无法定位内部订单'); return {code: 4, message: '充值失败,请联系客服'}; } const validation = await PaymentHelper.validateOrder(extraStr); if (!validation.valid) { if (validation.message?.includes('重复发货')) { return {code: 3, message: '订单号重复'}; } logger.warn('WebGame 充值回调订单验证失败', {extra_info: extraStr, msg: validation.message}); return {code: 4, message: '充值失败,请联系客服'}; } const orderInfo = validation.orderInfo; const payAmount = parseFloat(String(money)); if (!Number.isFinite(payAmount) || Math.abs(Number(orderInfo.amount) - payAmount) > 0.01) { logger.warn('WebGame 充值回调金额不匹配', {order_id, money, orderAmount: orderInfo.amount}); return {code: 4, message: '充值失败,请联系客服'}; } logger.info('WebGame 充值回调开始发货', {order_id, extra_info: extraStr, user_id, money: payAmount}); const result = await PaymentHelper.deliverOrder(orderInfo, ctx.request.ip, validation.url, String(order_id)); logger.info('WebGame 充值回调发货完成', {order_id, result}); if (result.code === 1) { // 支付成功打点上报 this.reportEvent('payment', { user_id, game_id: WEBGAME_APP_ID, server_id, ip_addr: ctx.request.ip, log_data: JSON.stringify({order_id, platform_order_id: order_id, amount: payAmount}), }, config); return {code: 1, message: 'ok'}; } return {code: 4, message: '充值失败,请联系客服'}; } // 聊天监控接口:客户端传业务参数,服务端生成 game_id/timestamp/sign 后转发 SDK async handleChatMonitor(ctx: Context, config: ChannelConfig): Promise { const q = ctx.request.body as any; logger.info('WebGame 聊天监控请求', {q}); const {user_id, chat_content, channel, role_id, role_name, server_id, account_name} = q; if (!user_id || !chat_content || !channel || !role_id || !role_name || !server_id || !account_name) { return {code: 0, data: null, msg: '缺少必要参数'}; } const reportUrl = WEBGAME_REPORT_URL; if (!reportUrl) { logger.error('WebGame 聊天监控未配置 WEBGAME_REPORT_URL'); return {code: 0, data: null, msg: '服务器配置错误'}; } const loginKey = config.loginConfig?.signKey ?? ''; const gameId = String(WEBGAME_APP_ID); const timestamp = String(Math.floor(Date.now() / 1000)); const sign = crypto.createHash('md5') .update(String(user_id) + gameId + timestamp + loginKey) .digest('hex').toLowerCase(); const payload: Record = { user_id: String(user_id), game_id: gameId, sign, timestamp, chat_content: String(chat_content), channel: "世界", role_id: String(role_id), role_name: String(role_name), server_id: String(server_id), account_name: String(account_name), }; // 可选字段透传 const optionalFields = ['level', 'gold', 'chat_time', 'ip_addr', 'sec_chat_user_id', 'sec_chat_role_id', 'sec_chat_nickname', 'sec_chat_role_level']; for (const field of optionalFields) { if (q[field] !== undefined && q[field] !== null && q[field] !== '') { payload[field] = String(q[field]); } } try { const res = await axios.post( reportUrl + '/api/v2/webgame/chat-monitor', payload, {headers: {'Content-Type': 'application/json'}, timeout: 5000} ); console.log("请求地址,",reportUrl + '/api/v2/webgame/chat-monitor',"参数",payload); logger.info('WebGame 聊天监控 SDK 响应', {result: res.data}); return res.data; } catch (err: any) { logger.error('WebGame 聊天监控请求 SDK 失败', {error: err?.message}); return {code: 0, data: null, msg: '聊天监控请求失败'}; } } // 签名规则:所有参数(除sign)按字典序排序,拼接key=value&...&key={game_secret},MD5 private verifySign(data: any, gameSecret: string): boolean { try { const receivedSign = data.sign; if (!receivedSign) return false; const params = {...data}; delete params.sign; const sortedKeys = Object.keys(params).sort(); const signStr = sortedKeys .map(key => { const v = params[key]; if (v === null || v === undefined) return `${key}=`; return `${key}=${v}`; }) .join('&'); const stringToSign = `${signStr}&key=${gameSecret}`; const calculatedSign = crypto.createHash('md5').update(stringToSign).digest('hex').toLowerCase(); logger.info("WebGame渠道签名验证", {calculatedSign, receivedSign: receivedSign.toLowerCase()}); return calculatedSign === receivedSign.toLowerCase(); } catch (error: any) { logger.error("WebGame渠道签名验证出错", {error: error.message}); return false; } } }