|
@@ -0,0 +1,723 @@
|
|
|
|
|
+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<string, any>, 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<string, string> = {
|
|
|
|
|
+ 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<LoginResult> {
|
|
|
|
|
+ const data = ctx.request.body as Record<string, unknown>;
|
|
|
|
|
+ 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<LoginResult> {
|
|
|
|
|
+ const data = ctx.request.body as Record<string, unknown>;
|
|
|
|
|
+ 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<PaymentResult> {
|
|
|
|
|
+ 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<any> {
|
|
|
|
|
+ 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<any> {
|
|
|
|
|
+ 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<any> {
|
|
|
|
|
+ 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<any> {
|
|
|
|
|
+ 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<any> {
|
|
|
|
|
+ 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<string, string> = {
|
|
|
|
|
+ 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;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|