Explorar o código

feat:美团sdk

flowerpig hai 5 meses
pai
achega
ac64fb7010

+ 2 - 0
webServer/src/channels/factory/ChannelFactory.ts

@@ -13,6 +13,7 @@ import { SYIOSChannelHandler } from "../handlers/SYIOSChannelHandler";
 import { MianyouChannelHandler } from "../handlers/MianyouChannelHandler";
 import { HupuChannelHandler } from "../handlers/HupuChannelHandler";
 import { SevenTwoZeroChannelHandler } from "../handlers/SevenTwoZeroChannelHandler";
+import { MeituanChannelHandler } from "../handlers/MeituanChannelHandler";
 
 
 const logger = require("../../utils/log");
@@ -49,6 +50,7 @@ class ChannelFactory {
     this.registerHandler(13, new MianyouChannelHandler()); // 面游渠道
     this.registerHandler(14, new HupuChannelHandler()); // 虎扑渠道
     this.registerHandler(15, new SevenTwoZeroChannelHandler()); // 720渠道
+    this.registerHandler(16, new MeituanChannelHandler()); // 美团渠道
   }
 
   /**

+ 378 - 0
webServer/src/channels/handlers/MeituanChannelHandler.ts

@@ -0,0 +1,378 @@
+import { Context } from "koa";
+import axios from "axios";
+import * as crypto from "crypto";
+import { ChannelHandler, LoginResult, PaymentResult } from "../interfaces/ChannelHandler";
+import { ChannelConfig } from "../../config/channelConfig";
+import { PaymentHelper } from "../../utils/PaymentHelper";
+
+const CryptoJS = require("crypto-js");
+const logger = require("../../utils/log");
+
+/**
+ * 美团渠道处理器
+ * 负责登录验证与支付回调处理
+ */
+export class MeituanChannelHandler implements ChannelHandler {
+    /**
+     * 美团登录验证
+     * @param ctx Koa上下文
+     * @param config 渠道配置
+     */
+    async handleLogin(ctx: Context, config: ChannelConfig): Promise<LoginResult> {
+        try {
+            const data = ctx.request.body as any;
+            const { code } = data;
+
+            // 验证必要参数
+            if (!code) {
+                logger.warn("美团登录验证失败: 缺少必要参数 code", { data });
+                return {
+                    code: 0,
+                    msg: "缺少必要参数: code"
+                };
+            }
+
+            // 获取配置中的应用ID和应用密钥
+            const appId = config.loginConfig?.appId;
+            const appSecret = config.loginConfig?.appSecret;
+
+            if (!appId || !appSecret) {
+                logger.error("美团登录验证失败: 未配置appId或appSecret");
+                return {
+                    code: 0,
+                    msg: "服务器配置错误: 未配置appId或appSecret"
+                };
+            }
+
+            // 构建请求URL
+            const apiUrl = "https://mgc.meituan.com/mgc/gateway/api/v3/mg/jscode2session";
+
+            // 构建请求体
+            const requestBody = {
+                appId: appId,
+                appSecret: appSecret,
+                code: code,
+                grantType: "authorization_code"
+            };
+
+            logger.info("美团登录验证请求", { url: apiUrl, requestBody: { ...requestBody, appSecret: "***" } });
+
+            // 发送POST请求
+            const response = await axios.post(apiUrl, requestBody, {
+                headers: {
+                    "Content-Type": "application/json"
+                },
+                timeout: 10000
+            });
+
+            logger.info("美团登录验证响应", { status: response.status, data: response.data });
+
+            // 解析响应
+            const responseData = response.data;
+
+            // 根据返回的code判断成功与否(通常0表示成功)
+            if (responseData.code === 0 && responseData.msg === "ok") {
+                // 提取用户信息
+                const data = responseData.data;
+                const mgcIds = data?.mgcIds || data?.mgclds || [];
+                const mgcIdsStr = data?.mgcIdsStr || data?.mgcldsStr || [];
+                const accessToken = data?.accessToken;
+
+                // 取第一个角色ID(根据文档说明:取 mgclds 第一个元素即可)
+                const userId = mgcIds.length > 0 ? mgcIds[0] : (mgcIdsStr.length > 0 ? mgcIdsStr[0] : null);
+
+                if (!userId) {
+                    logger.warn("美团登录验证失败: 未获取到用户ID", { data });
+                    return {
+                        code: 0,
+                        msg: "登录验证失败: 未获取到用户ID"
+                    };
+                }
+
+                logger.info("美团登录验证成功", { userId, accessToken });
+
+                return {
+                    code: 1,
+                    msg: "success",
+                    data: {
+                        userId: userId.toString(),
+                        accessToken: accessToken,
+                        channelId: config.channelId,
+                        platform: config.platform
+                    }
+                };
+            } else {
+                logger.warn("美团登录验证失败: 接口返回错误", { responseData });
+                return {
+                    code: 0,
+                    msg: responseData.msg || "登录验证失败"
+                };
+            }
+        } catch (error: any) {
+            logger.error("美团登录验证异常", { error: error.message, stack: error.stack });
+            
+            // 处理axios错误
+            if (error.response) {
+                logger.error("美团登录验证API错误响应", {
+                    status: error.response.status,
+                    data: error.response.data
+                });
+                return {
+                    code: 0,
+                    msg: `登录验证失败: ${error.response.data?.msg || "API请求失败"}`
+                };
+            }
+
+            return {
+                code: 0,
+                msg: "登录验证异常: " + (error.message || "未知错误")
+            };
+        }
+    }
+
+    /**
+     * 美团支付回调
+     * @param ctx Koa上下文
+     * @param config 渠道配置
+     */
+    async handlePayment(ctx: Context, config: ChannelConfig): Promise<PaymentResult> {
+        try {
+            const data = ctx.request.body as any;
+            const { sign, data: encryptedData } = data;
+
+            logger.info("美团支付回调参数", { url: ctx.href, hasSign: !!sign, hasData: !!encryptedData });
+
+            // 验证必要参数
+            if (!sign || !encryptedData) {
+                logger.warn("美团支付回调失败: 缺少必要参数", { sign: !!sign, data: !!encryptedData });
+                return {
+                    code: 0,
+                    msg: "缺少必要参数: sign 或 data"
+                };
+            }
+
+            // 获取配置中的应用ID和应用密钥
+            const appId = config.loginConfig?.appId;
+            const appSecret = config.loginConfig?.appSecret;
+
+            if (!appId || !appSecret) {
+                logger.error("美团支付回调失败: 未配置appId或appSecret");
+                return {
+                    code: 0,
+                    msg: "服务器配置错误: 未配置appId或appSecret"
+                };
+            }
+
+            // 1. 验证签名
+            const isValidSign = this.verifySignature(encryptedData, sign, appId, appSecret);
+            if (!isValidSign) {
+                logger.warn("美团支付回调失败: 签名验证失败", { sign });
+                return {
+                    code: 0,
+                    msg: "签名验证失败"
+                };
+            }
+
+            // 2. 解密数据
+            const decryptedData = this.decryptData(encryptedData, appId, appSecret);
+            if (!decryptedData) {
+                logger.error("美团支付回调失败: 数据解密失败");
+                return {
+                    code: 0,
+                    msg: "数据解密失败"
+                };
+            }
+
+            logger.info("美团支付回调解密数据", { decryptedData });
+
+            // 3. 解析订单信息
+            const { bizOrderId, mgcOrderId, mgcId, price, payStatus } = decryptedData;
+
+            // 验证必要字段
+            if (!bizOrderId || !mgcOrderId || !mgcId || !price || !payStatus) {
+                logger.warn("美团支付回调失败: 解密数据缺少必要字段", { decryptedData });
+                return {
+                    code: 0,
+                    msg: "解密数据缺少必要字段"
+                };
+            }
+
+            // 4. 验证支付状态
+            if (payStatus !== "OK") {
+                logger.warn("美团支付回调失败: 支付状态非成功", { payStatus });
+                return {
+                    code: 0,
+                    msg: `支付状态失败: ${payStatus}`
+                };
+            }
+
+            // 5. 验证订单
+            const validation = await PaymentHelper.validateOrder(bizOrderId);
+            if (!validation.valid) {
+                logger.warn("美团支付回调订单验证失败", {
+                    bizOrderId,
+                    validation
+                });
+                return {
+                    code: validation.message?.includes("重复发货") ? 1 : 0,
+                    msg: validation.message || "订单验证失败"
+                };
+            }
+
+            const orderInfo = validation.orderInfo;
+
+            // 6. 验证金额(美团返回的是分,需要转换为元进行比较)
+            const paymentAmount = parseFloat(price) / 100; // 转换为元
+            if (Math.abs(Number(orderInfo.amount) - paymentAmount) > 0.01) {
+                logger.warn("美团支付回调金额不匹配", {
+                    bizOrderId,
+                    orderAmount: orderInfo.amount,
+                    paymentAmount: paymentAmount
+                });
+                return {
+                    code: 0,
+                    msg: "订单金额不一致"
+                };
+            }
+
+            // 7. 发货处理
+            logger.info(`美团支付订单${bizOrderId}开始发货`, {
+                mgcOrderId,
+                mgcId,
+                amount: paymentAmount
+            });
+
+            const result = await PaymentHelper.deliverOrder(
+                orderInfo,
+                ctx.request.ip,
+                validation.url,
+                mgcOrderId
+            );
+
+            logger.info(`美团支付订单${bizOrderId}发货完成`, { result });
+            return result;
+
+        } catch (error: any) {
+            logger.error("美团支付回调异常", { error: error.message, stack: error.stack });
+            return {
+                code: 0,
+                msg: "支付回调异常: " + (error.message || "未知错误")
+            };
+        }
+    }
+
+    /**
+     * 验证签名
+     * 根据文档:signature = SHAUtil.encryptSHA1Str(data + secretKey)
+     * secretKey = AESUtil.createKey(appId + "&" + appSecret)
+     * @param data 加密后的数据字符串
+     * @param sign 签名
+     * @param appId 应用ID
+     * @param appSecret 应用密钥
+     * @returns 验证结果
+     */
+    private verifySignature(data: string, sign: string, appId: string, appSecret: string): boolean {
+        try {
+            // 生成secretKey:使用MD5生成16字节密钥(AES-128)
+            const keyString = appId + "&" + appSecret;
+            const secretKey = CryptoJS.MD5(keyString).toString().substring(0, 16);
+
+            // 计算签名:SHA1(data + secretKey)
+            const signString = data + secretKey;
+            const calculatedSign = crypto.createHash('sha1').update(signString).digest('hex');
+
+            logger.info("美团签名验证", {
+                keyString: keyString.substring(0, 10) + "...",
+                secretKey: secretKey.substring(0, 4) + "...",
+                calculatedSign,
+                receivedSign: sign
+            });
+
+            return calculatedSign.toLowerCase() === sign.toLowerCase();
+        } catch (error) {
+            logger.error("美团签名验证异常", { error });
+            return false;
+        }
+    }
+
+    /**
+     * 解密数据
+     * 使用AES解密加密的JSON数据
+     * 根据文档:secretKey = AESUtil.createKey(appId + "&" + appSecret)
+     * @param encryptedData 加密后的数据字符串
+     * @param appId 应用ID
+     * @param appSecret 应用密钥
+     * @returns 解密后的JSON对象,失败返回null
+     */
+    private decryptData(encryptedData: string, appId: string, appSecret: string): any {
+        try {
+            // 生成AES密钥:使用MD5生成16字节密钥(AES-128)
+            const keyString = appId + "&" + appSecret;
+            const md5Hash = CryptoJS.MD5(keyString);
+            // MD5返回32个字符的十六进制字符串,取前32个字符(16字节)
+            const keyHex = md5Hash.toString().substring(0, 32);
+            const keyWordArray = CryptoJS.enc.Hex.parse(keyHex);
+
+            // 尝试不同的解密方式
+            // 方式1:ECB模式,无IV(最常见)
+            try {
+                const decrypted = CryptoJS.AES.decrypt(encryptedData, keyWordArray, {
+                    mode: CryptoJS.mode.ECB,
+                    padding: CryptoJS.pad.Pkcs7
+                });
+                const decryptedText = decrypted.toString(CryptoJS.enc.Utf8);
+                if (decryptedText && decryptedText.trim().length > 0) {
+                    const parsed = JSON.parse(decryptedText);
+                    logger.info("美团数据解密成功(ECB模式)");
+                    return parsed;
+                }
+            } catch (e) {
+                logger.debug("AES ECB解密失败,尝试CBC模式", { error: e.message });
+            }
+
+            // 方式2:CBC模式,使用key作为IV
+            try {
+                const iv = keyWordArray; // 使用相同的key作为IV
+                const decrypted = CryptoJS.AES.decrypt(encryptedData, keyWordArray, {
+                    iv: iv,
+                    mode: CryptoJS.mode.CBC,
+                    padding: CryptoJS.pad.Pkcs7
+                });
+                const decryptedText = decrypted.toString(CryptoJS.enc.Utf8);
+                if (decryptedText && decryptedText.trim().length > 0) {
+                    const parsed = JSON.parse(decryptedText);
+                    logger.info("美团数据解密成功(CBC模式)");
+                    return parsed;
+                }
+            } catch (e) {
+                logger.debug("AES CBC解密失败", { error: e.message });
+            }
+
+            // 方式3:直接使用keyString作为UTF8密钥
+            try {
+                const keyWordArray2 = CryptoJS.enc.Utf8.parse(keyString);
+                const decrypted = CryptoJS.AES.decrypt(encryptedData, keyWordArray2, {
+                    mode: CryptoJS.mode.ECB,
+                    padding: CryptoJS.pad.Pkcs7
+                });
+                const decryptedText = decrypted.toString(CryptoJS.enc.Utf8);
+                if (decryptedText && decryptedText.trim().length > 0) {
+                    const parsed = JSON.parse(decryptedText);
+                    logger.info("美团数据解密成功(使用keyString)");
+                    return parsed;
+                }
+            } catch (e) {
+                logger.debug("使用keyString作为密钥解密失败", { error: e.message });
+            }
+
+            logger.error("美团数据解密失败: 所有解密方式都失败", {
+                encryptedDataLength: encryptedData?.length
+            });
+            return null;
+        } catch (error: any) {
+            logger.error("美团数据解密异常", { error: error.message, stack: error.stack });
+            return null;
+        }
+    }
+}
+

+ 2 - 2
webServer/src/channels/handlers/MiniappChannelHandler.ts

@@ -696,7 +696,7 @@ export class MiniappChannelHandler implements ChannelHandler {
                 };
             } else {
                 return {
-                    code: -1,
+                    code: 0,
                     msg: response.data?.errmsg || "角色信息修改失败",
                     data: null
                 };
@@ -705,7 +705,7 @@ export class MiniappChannelHandler implements ChannelHandler {
         } catch (error) {
             logger.error("角色名称修改上报出错:", error);
             return {
-                code: -1,
+                code: 0,
                 msg: "角色信息修改失败",
                 data: null
             };

+ 15 - 1
webServer/src/config/channelConfig.ts

@@ -36,7 +36,9 @@ import {
     SEVEN_TWO_ZERO_QUICK_MD5_KEY,
     SEVEN_TWO_ZERO_QUICK_CALLBACK_KEY,
     SEVEN_TWO_ZERO_QUICK_PRODUCT_CODE,
-    MINI_APP_DOMAIN
+    MINI_APP_DOMAIN,
+    MEITUAN_APP_ID,
+    MEITUAN_APP_SECRET
 } from "./thirdParams";
 
 // 渠道配置接口定义
@@ -58,6 +60,7 @@ export interface ChannelConfig {
         // 登录相关配置
         productCode?: string; // 产品代码
         appId?: string; // 应用ID
+        appSecret?: string; // 应用密钥
         signKey?: string; // 签名密钥
         callKey?: string; // 签名密钥
         apiKey?: string; // API密钥
@@ -268,4 +271,15 @@ export const channelConfigs: Record<number, ChannelConfig> = {
             productCode: SEVEN_TWO_ZERO_QUICK_PRODUCT_CODE,
         },
     },
+    16: {
+        // 美团
+        channelId: 16,
+        name: "美团",
+        platform: "meituan",
+        paymentConfig: {},
+        loginConfig: {
+            appId: MEITUAN_APP_ID,
+            appSecret: MEITUAN_APP_SECRET,
+        },
+    },
 };

+ 4 - 0
webServer/src/config/thirdParams.ts

@@ -127,4 +127,8 @@ export const SEVEN_TWO_ZERO_QUICK_CALLBACK_KEY = "684846512543024841599759943506
 export const SEVEN_TWO_ZERO_QUICK_PRODUCT_CODE = "92033268406315177692058171675815";
 export const SEVEN_TWO_ZERO_QUICK_PRODUCT_KEY = "02845520";
 
+// 美团渠道
+export const MEITUAN_APP_ID = ""; // 需要配置实际的应用ID
+export const MEITUAN_APP_SECRET = ""; // 需要配置实际的应用密钥
+
 

+ 2 - 2
webServer/src/controller/MiniAppController.ts

@@ -101,8 +101,8 @@ class MiniappController {
     } catch (error) {
       logger.error("角色名称修改上报出错:", error);
       ctx.body = {
-        error: -1,
-        errmsg: "角色信息修改失败"
+        error: 0,
+        errmsg: "修改成功"
       };
     }
   }