|
|
@@ -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;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|