WebGameChannelHandler.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723
  1. import {Context} from "koa";
  2. import * as crypto from 'crypto';
  3. import {ChannelHandler, LoginResult, PaymentResult} from "../interfaces/ChannelHandler";
  4. import {ChannelConfig} from "../../config/channelConfig";
  5. import {PaymentHelper} from "../../utils/PaymentHelper";
  6. import {setWebgameAuthInfo} from "../../utils/webgameAuthCache";
  7. import {formatDate, getClientIp} from "../../utils/common";
  8. import {WEBGAME_APP_ID, WEBGAME_REPORT_URL} from "../../config/thirdParams";
  9. import {getRoleInfoByUidAndServerId} from "../../mongo/mongodb";
  10. import {query} from "../../sql/query";
  11. const logger = require("../../utils/log");
  12. const User = require("../../model/UserModel");
  13. const axios = require("axios");
  14. const DEFAULT_GATE_DOMAIN = "https://mind.yishanyou.com/webgame/index.html";
  15. export class WebGameChannelHandler implements ChannelHandler {
  16. private toIntOrZero(v: any): number {
  17. if (v === undefined || v === null || v === '') return 0;
  18. const n = Number(v);
  19. return Number.isFinite(n) ? Math.trunc(n) : 0;
  20. }
  21. // 上报登录/支付事件(fire-and-forget,不阻塞主流程)
  22. // 上报地址由 WEBGAME_REPORT_URL 配置,格式:POST /api/v2/webgame/log (application/x-www-form-urlencoded)
  23. private reportEvent(logType: 'login' | 'payment', params: Record<string, any>, config: ChannelConfig): void {
  24. const reportUrl = WEBGAME_REPORT_URL;
  25. if (!reportUrl) return;
  26. const loginKey = config.loginConfig?.signKey ?? '';
  27. const timestamp = String(Math.floor(Date.now() / 1000));
  28. const userId = String(params.user_id ?? '');
  29. const gameId = String(WEBGAME_APP_ID);
  30. const sign = crypto.createHash('md5').update(userId + gameId + timestamp + loginKey).digest('hex').toLowerCase();
  31. const payload: Record<string, string> = {
  32. user_id: userId,
  33. game_id: gameId,
  34. sign,
  35. timestamp,
  36. server_id: String(params.server_id ?? ''),
  37. log_type: logType,
  38. };
  39. if (params.log_data) payload.log_data = params.log_data;
  40. if (params.ip_addr) payload.ip_addr = String(params.ip_addr);
  41. axios.post(reportUrl + '/api/v2/webgame/log', new URLSearchParams(payload).toString(), {
  42. headers: {'Content-Type': 'application/x-www-form-urlencoded'},
  43. timeout: 5000,
  44. }).then((res: any) => {
  45. logger.info('WebGame 事件上报成功', {logType, result: res.data});
  46. }).catch((err: any) => {
  47. logger.warn('WebGame 事件上报失败', {logType, error: err?.message});
  48. });
  49. }
  50. async handleLogin(ctx: Context,config:ChannelConfig): Promise<LoginResult> {
  51. const data = ctx.request.body as Record<string, unknown>;
  52. const uid = data.uid;
  53. // token 仅透传,不做校验
  54. if (uid === undefined || uid === null) {
  55. return {code: 0, msg: "缺少必要参数: uid"};
  56. }
  57. const uidStr = String(uid);
  58. let uidDecoded: string;
  59. try {
  60. uidDecoded = decodeURIComponent(uidStr.replace(/\+/g, "%20"));
  61. } catch {
  62. uidDecoded = uidStr;
  63. }
  64. const platformStr = data.platform == null ? "" : String(data.platform);
  65. // 登录成功后写入 accounts / account_login_logs(参考 checkUserToken)
  66. try {
  67. const ip = getClientIp(ctx);
  68. const create_time = formatDate(new Date());
  69. const channel_id = config.channelId;
  70. const device_no = (data as any).device_no ?? "";
  71. const reg_device = (data as any).reg_device ?? "";
  72. const device_type = this.toIntOrZero((data as any).device_type);
  73. const device_model = (data as any).device_model ?? "";
  74. const device_version = (data as any).device_version ?? "";
  75. const system_version = (data as any).system_version ?? "";
  76. const accountInfo = (await User.checkAccountIsExist(uidDecoded, channel_id))[0];
  77. if (!accountInfo) {
  78. const accountRes = await User.createAccount(
  79. uidDecoded,
  80. channel_id,
  81. ip,
  82. device_no,
  83. reg_device,
  84. create_time,
  85. platformStr
  86. );
  87. if (!accountRes || accountRes.affectedRows <= 0) {
  88. logger.error("WebGame 登录落库失败: 添加账户失败", {uid: uidDecoded, channel_id});
  89. return {code: 0, msg: "添加账户失败"};
  90. }
  91. }
  92. const logRes = await User.logAccountLogin(
  93. uidDecoded,
  94. ip,
  95. device_type,
  96. device_no,
  97. device_model,
  98. device_version,
  99. system_version,
  100. create_time,
  101. channel_id,
  102. platformStr
  103. );
  104. if (!logRes || logRes.affectedRows <= 0) {
  105. logger.error("WebGame 登录落库失败: 添加登录日志失败", {uid: uidDecoded, channel_id});
  106. return {code: 0, msg: "添加日志失败"};
  107. }
  108. } catch (e: any) {
  109. logger.error("WebGame 登录落库异常", {msg: e?.message, stack: e?.stack});
  110. return {code: 0, msg: "登录落库异常"};
  111. }
  112. // 登录成功后打点上报
  113. this.reportEvent('login', {
  114. user_id: uidDecoded,
  115. game_id: WEBGAME_APP_ID,
  116. server_id: (data as any).server_id ?? '',
  117. ip_addr: getClientIp(ctx),
  118. }, config);
  119. return {
  120. code: 1,
  121. msg: "success",
  122. data: {
  123. uid: uidDecoded,
  124. },
  125. };
  126. }
  127. /**
  128. * 联运 Web 游戏登录:生成带签名的游戏入口 URL。
  129. * 签名 md5(uid+platform+gkey+skey+time+is_adult[+exts]+'#'+lkey),缺省字段在签名中按空串拼接;uid 为 urldecode 后的值参与签名。
  130. * 必填:uid、platform、time、back_url、type;gkey、skey、is_adult、exts 可选(不传则不出现在 URL 查询串中)。
  131. */
  132. async getGameUrl(ctx: Context, config: ChannelConfig): Promise<LoginResult> {
  133. const data = ctx.request.body as Record<string, unknown>;
  134. const lkey = config.loginConfig?.signKey;
  135. if (!lkey) {
  136. logger.error("WebGame 获取游戏 URL 失败: 未配置登录密钥 loginConfig.signKey");
  137. return {code: 0, msg: "服务器未配置登录密钥"};
  138. }
  139. const gateDomain = DEFAULT_GATE_DOMAIN;
  140. const uid = data.uid;
  141. const platform = data.platform;
  142. const gkey = data.gkey;
  143. const skey = data.skey;
  144. const time = data.time;
  145. const is_adult = data.is_adult;
  146. const back_url = data.back_url;
  147. const type = data.type;
  148. if (
  149. uid === undefined ||
  150. uid === null ||
  151. platform === undefined ||
  152. platform === null ||
  153. time === undefined ||
  154. time === null ||
  155. back_url === undefined ||
  156. back_url === null ||
  157. type === undefined ||
  158. type === null
  159. ) {
  160. return {
  161. code: 0,
  162. msg: "缺少必要参数: uid, platform, time, back_url, type",
  163. };
  164. }
  165. const uidStr = String(uid);
  166. let uidDecoded: string;
  167. try {
  168. uidDecoded = decodeURIComponent(uidStr.replace(/\+/g, "%20"));
  169. } catch {
  170. uidDecoded = uidStr;
  171. }
  172. const platformStr = String(platform);
  173. const gkeyStr = gkey === undefined || gkey === null ? "" : String(gkey);
  174. const skeyStr = skey === undefined || skey === null ? "" : String(skey);
  175. const timeStr = String(time);
  176. const isAdultStr =
  177. is_adult === undefined || is_adult === null ? "" : String(is_adult);
  178. const extsVal = data.exts;
  179. const hasExts =
  180. extsVal !== undefined &&
  181. extsVal !== null &&
  182. String(extsVal) !== "";
  183. let signRaw = `${uidDecoded}${platformStr}${gkeyStr}${skeyStr}${timeStr}${isAdultStr}`;
  184. if (hasExts) {
  185. signRaw += String(extsVal);
  186. }
  187. signRaw += `#${lkey}`;
  188. const sign = crypto.createHash("md5").update(signRaw).digest("hex").toLowerCase();
  189. const base = gateDomain.replace(/\/+$/, "");
  190. const params = new URLSearchParams();
  191. params.set("uid", uidDecoded);
  192. params.set("platform", platformStr);
  193. if (gkeyStr !== "") {
  194. params.set("gkey", gkeyStr);
  195. }
  196. if (skeyStr !== "") {
  197. params.set("skey", skeyStr);
  198. }
  199. params.set("time", timeStr);
  200. if (isAdultStr !== "") {
  201. params.set("is_adult", isAdultStr);
  202. }
  203. params.set("back_url", String(back_url));
  204. params.set("type", String(type));
  205. if (hasExts) {
  206. params.set("exts", String(extsVal));
  207. }
  208. params.set("sign", sign);
  209. const gameUrl = `${base}/login.html?${params.toString()}`;
  210. // 供客户端只用 sign 调用 /webgame/auth 做权限判断
  211. setWebgameAuthInfo(sign, {
  212. uid: uidDecoded,
  213. platform: platformStr,
  214. time: timeStr,
  215. gkey: gkeyStr || undefined,
  216. skey: skeyStr || undefined,
  217. is_adult: isAdultStr || undefined,
  218. exts: hasExts ? String(extsVal) : undefined,
  219. });
  220. // 登录成功后写入 accounts / account_login_logs(参考 checkUserToken)
  221. try {
  222. const ip = getClientIp(ctx);
  223. const create_time = formatDate(new Date());
  224. const channel_id = config.channelId;
  225. const device_no = (data as any).device_no ?? "";
  226. const reg_device = (data as any).reg_device ?? "";
  227. const device_type = this.toIntOrZero((data as any).device_type);
  228. const device_model = (data as any).device_model ?? "";
  229. const device_version = (data as any).device_version ?? "";
  230. const system_version = (data as any).system_version ?? "";
  231. const accountInfo = (await User.checkAccountIsExist(uidDecoded, channel_id))[0];
  232. if (!accountInfo) {
  233. const accountRes = await User.createAccount(
  234. uidDecoded,
  235. channel_id,
  236. ip,
  237. device_no,
  238. reg_device,
  239. create_time,
  240. platformStr
  241. );
  242. if (!accountRes || accountRes.affectedRows <= 0) {
  243. logger.error("WebGame 登录落库失败: 添加账户失败", {uid: uidDecoded, channel_id});
  244. return {code: 0, msg: "添加账户失败"};
  245. }
  246. }
  247. const logRes = await User.logAccountLogin(
  248. uidDecoded,
  249. ip,
  250. device_type,
  251. device_no,
  252. device_model,
  253. device_version,
  254. system_version,
  255. create_time,
  256. channel_id,
  257. platformStr
  258. );
  259. if (!logRes || logRes.affectedRows <= 0) {
  260. logger.error("WebGame 登录落库失败: 添加登录日志失败", {uid: uidDecoded, channel_id});
  261. return {code: 0, msg: "添加日志失败"};
  262. }
  263. } catch (e: any) {
  264. logger.error("WebGame 登录落库异常", {msg: e?.message, stack: e?.stack});
  265. return {code: 0, msg: "登录落库异常"};
  266. }
  267. logger.info("WebGame 生成游戏登录 URL", {gateDomain: base, signLen: signRaw.length});
  268. return {
  269. code: 1,
  270. msg: "success",
  271. data: {
  272. gameUrl,
  273. },
  274. };
  275. }
  276. async handlePayment(ctx: Context, config: ChannelConfig): Promise<PaymentResult> {
  277. const data = ctx.request.body as any;
  278. logger.info("WebGame渠道支付回调参数", {url: ctx.href, params: data});
  279. // 新回调字段(不验 sign)
  280. const status = data?.status;
  281. const amount = data?.amount;
  282. const channelOrderId = data?.channel_order_id ?? data?.game_order ?? data?.id;
  283. const cpOrderId = data?.channel_exts ?? data?.extras_params ?? data?.game_order;
  284. // 兼容旧字段(历史上有 id/user_id/sign 等)
  285. const user_id = data?.user_id ?? data?.userId ?? data?.channel_uid;
  286. if (!cpOrderId || !status || amount === undefined || amount === null) {
  287. logger.warn("WebGame渠道支付回调失败: 缺少必要参数", {cpOrderId, status, amount});
  288. return {code: 0, msg: "缺少必要参数"};
  289. }
  290. if (status !== "completed") {
  291. logger.warn("WebGame渠道支付状态非completed", {channelOrderId, status});
  292. return {code: 0, msg: `支付状态非completed: ${status}`};
  293. }
  294. try {
  295. const validation = await PaymentHelper.validateOrder(String(cpOrderId));
  296. if (!validation.valid) {
  297. return {
  298. code: validation.message?.includes("重复发货") ? 1 : 0,
  299. msg: validation.message || "订单验证失败"
  300. };
  301. }
  302. const orderInfo = validation.orderInfo;
  303. const payAmount = parseFloat(String(amount));
  304. if (!Number.isFinite(payAmount)) {
  305. return {code: 0, msg: "支付金额格式错误"};
  306. }
  307. if (Math.abs(Number(orderInfo.amount) - payAmount) > 0.01) {
  308. logger.warn("WebGame渠道支付金额不匹配", {
  309. channelOrderId,
  310. requestAmount: payAmount,
  311. orderAmount: orderInfo.amount
  312. });
  313. return {code: 0, msg: `订单金额不一致: 订单金额${orderInfo.amount}元,支付金额${payAmount}元`};
  314. }
  315. logger.info(`WebGame渠道支付订单开始发货`, {
  316. channelOrderId,
  317. cpOrderId,
  318. user_id,
  319. amount: payAmount
  320. });
  321. const result = await PaymentHelper.deliverOrder(
  322. orderInfo,
  323. ctx.request.ip,
  324. validation.url,
  325. String(channelOrderId ?? ''),
  326. );
  327. logger.info(`WebGame渠道支付订单发货完成`, {channelOrderId, cpOrderId, result});
  328. // 支付成功后打点上报
  329. if (result.code === 1) {
  330. this.reportEvent('payment', {
  331. user_id: user_id ?? orderInfo.uid,
  332. game_id: WEBGAME_APP_ID,
  333. server_id: String(orderInfo.server_id ?? ''),
  334. ip_addr: ctx.request.ip,
  335. log_data: JSON.stringify({
  336. order_id: cpOrderId,
  337. channel_order_id: channelOrderId,
  338. amount: payAmount,
  339. }),
  340. }, config);
  341. }
  342. return result;
  343. } catch (error: any) {
  344. logger.error("WebGame渠道支付回调处理异常", {error: error.message, stack: error.stack});
  345. return {code: 0, msg: "回调处理异常"};
  346. }
  347. }
  348. // 角色查询接口(GET),平台查询指定区服下指定账号的角色信息
  349. // sign = md5(user_id + server_id + time + login_key)
  350. async handleRoleList(ctx: Context, config: ChannelConfig): Promise<any> {
  351. const q = {...ctx.query, ...ctx.request.body} as any;
  352. logger.info('WebGame 角色查询请求', {q});
  353. const {user_id, pid, server_id, time, sign} = q;
  354. if (!user_id || !pid || !server_id || !time || !sign) {
  355. return {code: 2, message: '参数不全', data: []};
  356. }
  357. const loginKey = config.loginConfig?.signKey ?? '';
  358. const expectedSign = crypto.createHash('md5')
  359. .update(String(user_id) + String(server_id) + String(time) + loginKey)
  360. .digest('hex').toLowerCase();
  361. if (expectedSign !== String(sign).toLowerCase()) {
  362. logger.warn('WebGame 角色查询签名错误', {user_id, server_id, expectedSign, sign});
  363. return {code: 3, message: '签名错误', data: []};
  364. }
  365. try {
  366. // 查 db_name
  367. const rows = await query(
  368. `SELECT db_name FROM game_server WHERE tag = ? AND sid = ? LIMIT 1`,
  369. [config.channelId, String(server_id)]
  370. ) as any[];
  371. if (!rows || rows.length === 0 || !rows[0].db_name) {
  372. logger.warn('WebGame 角色查询找不到区服', {server_id, channelId: config.channelId});
  373. return {code: 5, message: '服务器错误', data: []};
  374. }
  375. const dbName = rows[0].db_name;
  376. const newUniqueTag = `${config.channelId}|${server_id}|${user_id}`;
  377. // 查 MongoDB 角色信息
  378. const {getDb} = require("../../mongo/mongodb");
  379. const db = getDb(dbName);
  380. const collection = db.collection('char');
  381. const filter: any = {newUniqueTag};
  382. if (q.role_id) filter.roleId = String(q.role_id);
  383. const docs = await collection.find(filter).toArray();
  384. if (!docs || docs.length === 0) {
  385. return {code: 4, message: '未创建角色', data: []};
  386. }
  387. const data = docs.map((doc: any) => ({
  388. role_id: doc.roleId ?? doc._id.toString(),
  389. name: encodeURIComponent(doc.name ?? ''),
  390. lv: doc.lv ?? 0,
  391. sex: doc.sex ?? 'm',
  392. vocation: doc.vocation ?? 0,
  393. createTime: doc.createTime
  394. ? new Date(doc.createTime).toISOString().replace('T', ' ').slice(0, 19)
  395. : '',
  396. power: doc.zhandouli ?? doc.power ?? 0,
  397. }));
  398. return {code: 1, message: 'ok', data};
  399. } catch (e: any) {
  400. logger.error('WebGame 角色查询异常', {msg: e?.message, stack: e?.stack});
  401. return {code: 5, message: '服务器错误', data: []};
  402. }
  403. }
  404. // 登录跳转接口:验签 + 落库,返回拼接了平台参数的游戏 URL
  405. // sign = md5(user_id + server_id + pid + time + login_key)
  406. // 时间戳有效期 10 分钟
  407. async handleLoginUrl(ctx: Context, config: ChannelConfig): Promise<any> {
  408. const q = {...ctx.query, ...ctx.request.body} as any;
  409. logger.info('WebGame 登录跳转请求', {q});
  410. const {user_id, server_id, pid, time, sign} = q;
  411. if (!user_id || !server_id || !pid || !time || !sign) {
  412. return {code: 0, msg: '缺少必要参数'};
  413. }
  414. const loginKey = config.loginConfig?.signKey ?? '';
  415. const expectedSign = crypto.createHash('md5')
  416. .update(String(user_id) + String(server_id) + String(pid) + String(time) + loginKey)
  417. .digest('hex').toLowerCase();
  418. if (expectedSign !== String(sign).toLowerCase()) {
  419. logger.warn('WebGame 登录跳转签名错误', {user_id, expectedSign, sign});
  420. return {code: 0, msg: '签名错误'};
  421. }
  422. const ts = parseInt(String(time));
  423. if (Math.abs(Math.floor(Date.now() / 1000) - ts) > 600) {
  424. return {code: 0, msg: '登录链接已过期'};
  425. }
  426. // 落库
  427. try {
  428. const ip = getClientIp(ctx);
  429. const create_time = formatDate(new Date());
  430. const channel_id = config.channelId;
  431. const uidStr = String(user_id);
  432. const accountInfo = (await User.checkAccountIsExist(uidStr, channel_id))[0];
  433. if (!accountInfo) {
  434. await User.createAccount(uidStr, channel_id, ip, '', '', create_time, String(pid));
  435. }
  436. await User.logAccountLogin(uidStr, ip, 0, '', '', '', '', create_time, channel_id, String(pid));
  437. } catch (e: any) {
  438. logger.error('WebGame 登录跳转落库异常', {msg: e?.message});
  439. }
  440. // 拼接游戏 URL
  441. const base = DEFAULT_GATE_DOMAIN.replace(/\/+$/, '');
  442. const params = new URLSearchParams();
  443. params.set('user_id', String(user_id));
  444. params.set('server_id', String(server_id));
  445. params.set('pid', String(pid));
  446. params.set('time', String(time));
  447. params.set('sign', String(sign));
  448. if (q.ext != null) params.set('ext', String(q.ext));
  449. if (q.client != null) params.set('client', String(q.client));
  450. if (q.isAdult != null) params.set('isAdult', String(q.isAdult));
  451. const gameUrl = `${base}?${params.toString()}`;
  452. logger.info('WebGame 登录跳转生成游戏URL', {user_id, server_id, gameUrl});
  453. // 登录打点
  454. this.reportEvent('login', {
  455. user_id,
  456. game_id: WEBGAME_APP_ID,
  457. server_id,
  458. ip_addr: getClientIp(ctx),
  459. }, config);
  460. return {code: 1, msg: 'success', data: {gameUrl}};
  461. }
  462. // 充值下单接口(GET),平台调用我们,验证签名后返回确认
  463. // sign = md5(user_id + server_id + role_id + time + pay_key)
  464. // extra_info 由客户端传入我们系统的 orderId,透传给回调
  465. async handleCreateOrder(ctx: Context, config: ChannelConfig): Promise<any> {
  466. const q = {...ctx.query, ...ctx.request.body} as any;
  467. logger.info('WebGame 充值下单请求', {q});
  468. const {user_id, server_id, role_id, goods_id, goods_name, money, extra_info, sign, time} = q;
  469. if (!user_id || !server_id || !role_id || !goods_id || !money || !sign || !time) {
  470. return {code: 0, message: '缺少必要参数'};
  471. }
  472. const payKey = config.paymentConfig?.callbackKey ?? '';
  473. const expectedSign = crypto.createHash('md5')
  474. .update(String(user_id) + String(server_id) + String(role_id) + String(time) + payKey)
  475. .digest('hex').toLowerCase();
  476. if (expectedSign !== String(sign).toLowerCase()) {
  477. logger.warn('WebGame 充值下单签名错误', {user_id, expectedSign, sign});
  478. return {code: 2, message: '签名错误'};
  479. }
  480. logger.info('WebGame 充值下单验证通过', {user_id, server_id, role_id, goods_id, money, extra_info});
  481. return {code: 1, message: 'ok'};
  482. }
  483. // 充值回调接口(GET),平台支付成功后调用,发货
  484. // sign = md5(user_id + order_id + money + time + server_id + role_id + extra_info + pay_key)
  485. // extra_info 为客户端传入的内部 orderId,用于发货查询
  486. async handlePayCallback(ctx: Context, config: ChannelConfig): Promise<any> {
  487. const q = {...ctx.query, ...ctx.request.body} as any;
  488. logger.info('WebGame 充值回调请求', {q});
  489. const {user_id, pid, order_id, money, time, server_id, role_id, extra_info, sign} = q;
  490. if (!user_id || !order_id || !money || !time || !server_id || !role_id || !sign) {
  491. return {code: 4, message: '缺少必要参数'};
  492. }
  493. const payKey = config.paymentConfig?.callbackKey ?? '';
  494. const extraStr = extra_info == null ? '' : String(extra_info);
  495. const expectedSign = crypto.createHash('md5')
  496. .update(String(user_id) + String(order_id) + String(money) + String(time) + String(server_id) + String(role_id) + extraStr + payKey)
  497. .digest('hex').toLowerCase();
  498. if (expectedSign !== String(sign).toLowerCase()) {
  499. logger.warn('WebGame 充值回调签名错误', {user_id, order_id, expectedSign, sign});
  500. return {code: 2, message: '签名错误'};
  501. }
  502. // extra_info 为内部 orderId
  503. if (!extraStr) {
  504. logger.warn('WebGame 充值回调 extra_info 为空,无法定位内部订单');
  505. return {code: 4, message: '充值失败,请联系客服'};
  506. }
  507. const validation = await PaymentHelper.validateOrder(extraStr);
  508. if (!validation.valid) {
  509. if (validation.message?.includes('重复发货')) {
  510. return {code: 3, message: '订单号重复'};
  511. }
  512. logger.warn('WebGame 充值回调订单验证失败', {extra_info: extraStr, msg: validation.message});
  513. return {code: 4, message: '充值失败,请联系客服'};
  514. }
  515. const orderInfo = validation.orderInfo;
  516. const payAmount = parseFloat(String(money));
  517. if (!Number.isFinite(payAmount) || Math.abs(Number(orderInfo.amount) - payAmount) > 0.01) {
  518. logger.warn('WebGame 充值回调金额不匹配', {order_id, money, orderAmount: orderInfo.amount});
  519. return {code: 4, message: '充值失败,请联系客服'};
  520. }
  521. logger.info('WebGame 充值回调开始发货', {order_id, extra_info: extraStr, user_id, money: payAmount});
  522. const result = await PaymentHelper.deliverOrder(orderInfo, ctx.request.ip, validation.url, String(order_id));
  523. logger.info('WebGame 充值回调发货完成', {order_id, result});
  524. if (result.code === 1) {
  525. // 支付成功打点上报
  526. this.reportEvent('payment', {
  527. user_id,
  528. game_id: WEBGAME_APP_ID,
  529. server_id,
  530. ip_addr: ctx.request.ip,
  531. log_data: JSON.stringify({order_id, platform_order_id: order_id, amount: payAmount}),
  532. }, config);
  533. return {code: 1, message: 'ok'};
  534. }
  535. return {code: 4, message: '充值失败,请联系客服'};
  536. }
  537. // 聊天监控接口:客户端传业务参数,服务端生成 game_id/timestamp/sign 后转发 SDK
  538. async handleChatMonitor(ctx: Context, config: ChannelConfig): Promise<any> {
  539. const q = ctx.request.body as any;
  540. logger.info('WebGame 聊天监控请求', {q});
  541. const {user_id, chat_content, channel, role_id, role_name, server_id, account_name} = q;
  542. if (!user_id || !chat_content || !channel || !role_id || !role_name || !server_id || !account_name) {
  543. return {code: 0, data: null, msg: '缺少必要参数'};
  544. }
  545. const reportUrl = WEBGAME_REPORT_URL;
  546. if (!reportUrl) {
  547. logger.error('WebGame 聊天监控未配置 WEBGAME_REPORT_URL');
  548. return {code: 0, data: null, msg: '服务器配置错误'};
  549. }
  550. const loginKey = config.loginConfig?.signKey ?? '';
  551. const gameId = String(WEBGAME_APP_ID);
  552. const timestamp = String(Math.floor(Date.now() / 1000));
  553. const sign = crypto.createHash('md5')
  554. .update(String(user_id) + gameId + timestamp + loginKey)
  555. .digest('hex').toLowerCase();
  556. const payload: Record<string, string> = {
  557. user_id: String(user_id),
  558. game_id: gameId,
  559. sign,
  560. timestamp,
  561. chat_content: String(chat_content),
  562. channel: "世界",
  563. role_id: String(role_id),
  564. role_name: String(role_name),
  565. server_id: String(server_id),
  566. account_name: String(account_name),
  567. };
  568. // 可选字段透传
  569. const optionalFields = ['level', 'gold', 'chat_time', 'ip_addr',
  570. 'sec_chat_user_id', 'sec_chat_role_id', 'sec_chat_nickname', 'sec_chat_role_level'];
  571. for (const field of optionalFields) {
  572. if (q[field] !== undefined && q[field] !== null && q[field] !== '') {
  573. payload[field] = String(q[field]);
  574. }
  575. }
  576. try {
  577. const res = await axios.post(
  578. reportUrl + '/api/v2/webgame/chat-monitor',
  579. payload,
  580. {headers: {'Content-Type': 'application/json'}, timeout: 5000}
  581. );
  582. console.log("请求地址,",reportUrl + '/api/v2/webgame/chat-monitor',"参数",payload);
  583. logger.info('WebGame 聊天监控 SDK 响应', {result: res.data});
  584. return res.data;
  585. } catch (err: any) {
  586. logger.error('WebGame 聊天监控请求 SDK 失败', {error: err?.message});
  587. return {code: 0, data: null, msg: '聊天监控请求失败'};
  588. }
  589. }
  590. // 签名规则:所有参数(除sign)按字典序排序,拼接key=value&...&key={game_secret},MD5
  591. private verifySign(data: any, gameSecret: string): boolean {
  592. try {
  593. const receivedSign = data.sign;
  594. if (!receivedSign) return false;
  595. const params = {...data};
  596. delete params.sign;
  597. const sortedKeys = Object.keys(params).sort();
  598. const signStr = sortedKeys
  599. .map(key => {
  600. const v = params[key];
  601. if (v === null || v === undefined) return `${key}=`;
  602. return `${key}=${v}`;
  603. })
  604. .join('&');
  605. const stringToSign = `${signStr}&key=${gameSecret}`;
  606. const calculatedSign = crypto.createHash('md5').update(stringToSign).digest('hex').toLowerCase();
  607. logger.info("WebGame渠道签名验证", {calculatedSign, receivedSign: receivedSign.toLowerCase()});
  608. return calculatedSign === receivedSign.toLowerCase();
  609. } catch (error: any) {
  610. logger.error("WebGame渠道签名验证出错", {error: error.message});
  611. return false;
  612. }
  613. }
  614. }