|
|
@@ -0,0 +1,439 @@
|
|
|
+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 logger = require("../../utils/log");
|
|
|
+
|
|
|
+/**
|
|
|
+ * 华为渠道处理器
|
|
|
+ * 负责登录验证与支付回调处理
|
|
|
+ */
|
|
|
+export class HuaweiChannelHandler implements ChannelHandler {
|
|
|
+ /**
|
|
|
+ * 获取HTTPS代理配置
|
|
|
+ * @returns HTTPS代理配置
|
|
|
+ */
|
|
|
+ private getHttpsAgent() {
|
|
|
+ const https = require('https');
|
|
|
+ return new https.Agent({
|
|
|
+ rejectUnauthorized: false,
|
|
|
+ secureProtocol: 'TLSv1_2_method',
|
|
|
+ timeout: 10000
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 生成签名
|
|
|
+ * 根据文档:移除空值参数 -> 按字母顺序排序 -> 拼接参数 -> 拼接secret -> MD5
|
|
|
+ * @param params 请求参数对象
|
|
|
+ * @param secret 应用密钥
|
|
|
+ * @returns MD5签名字符串(32位小写)
|
|
|
+ */
|
|
|
+ private generateSign(params: any, secret: string): string {
|
|
|
+ try {
|
|
|
+ // 1. 移除空值参数
|
|
|
+ const filteredParams: any = {};
|
|
|
+ Object.keys(params).forEach(key => {
|
|
|
+ if (params[key] !== null && params[key] !== undefined && params[key] !== '') {
|
|
|
+ filteredParams[key] = params[key].toString();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 2. 按字母顺序排序
|
|
|
+ const sortedKeys = Object.keys(filteredParams).sort();
|
|
|
+
|
|
|
+ // 3. 构建签名字符串:key=value&key=value
|
|
|
+ const queryString = sortedKeys.map(key => {
|
|
|
+ return `${key}=${filteredParams[key]}`;
|
|
|
+ }).join('&');
|
|
|
+
|
|
|
+ // 4. 拼接secret key
|
|
|
+ const signStr = `${queryString}&key=${secret}`;
|
|
|
+
|
|
|
+ logger.info("华为渠道签名生成详情:", {
|
|
|
+ filteredParams,
|
|
|
+ queryString,
|
|
|
+ signStr
|
|
|
+ });
|
|
|
+
|
|
|
+ // 5. MD5哈希(32位小写)
|
|
|
+ const signature = crypto.createHash('md5').update(signStr).digest('hex').toLowerCase();
|
|
|
+
|
|
|
+ return signature;
|
|
|
+ } catch (error) {
|
|
|
+ logger.error("华为渠道签名生成出错:", error);
|
|
|
+ return '';
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 华为渠道登录鉴权
|
|
|
+ * @param ctx Koa上下文
|
|
|
+ * @param config 渠道配置
|
|
|
+ * @returns 登录结果
|
|
|
+ */
|
|
|
+ async handleLogin(ctx: Context, config: ChannelConfig): Promise<LoginResult> {
|
|
|
+ try {
|
|
|
+ const data = ctx.request.body as any;
|
|
|
+ const { token } = data;
|
|
|
+
|
|
|
+ // 验证必要参数
|
|
|
+ if (!token) {
|
|
|
+ logger.error("华为渠道登录鉴权失败 - 缺少token参数");
|
|
|
+ return {
|
|
|
+ code: -1,
|
|
|
+ msg: "缺少必要参数: token",
|
|
|
+ data: null
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取配置
|
|
|
+ const appId = config.loginConfig?.appId;
|
|
|
+ const appSecret = config.loginConfig?.appSecret;
|
|
|
+
|
|
|
+ if (!appId || !appSecret) {
|
|
|
+ logger.error("华为渠道登录鉴权失败 - 缺少配置参数", { appId, appSecret: !!appSecret });
|
|
|
+ return {
|
|
|
+ code: -1,
|
|
|
+ msg: "服务器配置错误: 缺少appId或appSecret",
|
|
|
+ data: null
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // 构建请求参数
|
|
|
+ const timestamp = Math.floor(Date.now() / 1000).toString();
|
|
|
+ const requestBody = {
|
|
|
+ appId: appId,
|
|
|
+ timestamp: timestamp
|
|
|
+ };
|
|
|
+
|
|
|
+ // 生成签名
|
|
|
+ const sign = this.generateSign(requestBody, appSecret);
|
|
|
+
|
|
|
+ if (!sign) {
|
|
|
+ logger.error("华为渠道登录鉴权失败 - 签名生成失败");
|
|
|
+ return {
|
|
|
+ code: -1,
|
|
|
+ msg: "签名生成失败",
|
|
|
+ data: null
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // 构建请求头
|
|
|
+ const headers = {
|
|
|
+ 'Accept': 'application/json;',
|
|
|
+ 'Content-Type': 'application/json;charset=utf-8;',
|
|
|
+ 'Authorization': token,
|
|
|
+ 'sign': sign,
|
|
|
+ 'Server-Plat-From': 'minigame_server' // 可选,但建议传递
|
|
|
+ };
|
|
|
+
|
|
|
+ // API地址
|
|
|
+ const apiUrl = 'https://sdkapi.haotianhuyu.com/api/user/getLoginStatus';
|
|
|
+
|
|
|
+ logger.info("华为渠道登录鉴权请求", {
|
|
|
+ url: apiUrl,
|
|
|
+ headers: { ...headers, Authorization: token ? '***' : undefined },
|
|
|
+ body: requestBody
|
|
|
+ });
|
|
|
+
|
|
|
+ // 发送请求
|
|
|
+ const response = await axios.post(apiUrl, requestBody, {
|
|
|
+ headers: headers,
|
|
|
+ timeout: 10000,
|
|
|
+ httpsAgent: this.getHttpsAgent(),
|
|
|
+ validateStatus: (status) => {
|
|
|
+ // 根据文档,失败时HTTP状态码是401,但我们也接受200
|
|
|
+ return status === 200 || status === 401;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ logger.info("华为渠道登录鉴权响应", {
|
|
|
+ status: response.status,
|
|
|
+ data: response.data
|
|
|
+ });
|
|
|
+
|
|
|
+ const responseData = response.data;
|
|
|
+
|
|
|
+ // 判断登录结果
|
|
|
+ // 根据文档:status=20000 表示成功,其他都是失败
|
|
|
+ if (responseData.status === 20000) {
|
|
|
+ // 登录成功
|
|
|
+ logger.info("华为渠道登录鉴权成功", {
|
|
|
+ user_id: responseData.data?.user_id,
|
|
|
+ token: responseData.data?.token ? '***' : undefined
|
|
|
+ });
|
|
|
+
|
|
|
+ return {
|
|
|
+ code: 0,
|
|
|
+ msg: responseData.message || "登录成功",
|
|
|
+ data: {
|
|
|
+ token: responseData.data?.token,
|
|
|
+ user_id: responseData.data?.user_id,
|
|
|
+ ali_user_id: responseData.data?.ali_user_id,
|
|
|
+ channel_open_id: responseData.data?.channel_open_id
|
|
|
+ }
|
|
|
+ };
|
|
|
+ } else {
|
|
|
+ // 登录失败
|
|
|
+ logger.warn("华为渠道登录鉴权失败", {
|
|
|
+ status: responseData.status,
|
|
|
+ errCode: responseData.errCode,
|
|
|
+ message: responseData.message,
|
|
|
+ msg: responseData.msg
|
|
|
+ });
|
|
|
+
|
|
|
+ return {
|
|
|
+ code: -1,
|
|
|
+ msg: responseData.msg || responseData.message || "登录验证失败",
|
|
|
+ data: null
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ } catch (error: any) {
|
|
|
+ logger.error("华为渠道登录鉴权出错:", error);
|
|
|
+
|
|
|
+ // 处理axios错误
|
|
|
+ if (error.response) {
|
|
|
+ const responseData = error.response.data;
|
|
|
+ logger.error("华为渠道登录鉴权API错误响应", {
|
|
|
+ status: error.response.status,
|
|
|
+ data: responseData
|
|
|
+ });
|
|
|
+
|
|
|
+ // 如果返回了错误数据,尝试解析
|
|
|
+ if (responseData && responseData.status !== undefined) {
|
|
|
+ return {
|
|
|
+ code: -1,
|
|
|
+ msg: responseData.msg || responseData.message || "登录验证失败",
|
|
|
+ data: null
|
|
|
+ };
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ code: -1,
|
|
|
+ msg: error.message || "登录验证失败",
|
|
|
+ data: null
|
|
|
+ };
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 验证支付回调签名
|
|
|
+ * 根据文档:移除sign参数和空值 -> 按ASCII码升序排序 -> 拼接成key=value格式 -> 拼接&key={callback secret} -> MD5
|
|
|
+ * @param data 回调参数对象
|
|
|
+ * @param callbackSecret 回调密钥
|
|
|
+ * @returns 验证结果
|
|
|
+ */
|
|
|
+ private verifyPaymentSign(data: any, callbackSecret: string): boolean {
|
|
|
+ try {
|
|
|
+ // 1. 移除sign参数和空值字段
|
|
|
+ const filteredParams: any = {};
|
|
|
+ Object.keys(data).forEach(key => {
|
|
|
+ if (key !== 'sign' && data[key] !== null && data[key] !== undefined && data[key] !== '') {
|
|
|
+ filteredParams[key] = data[key].toString();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 2. 按ASCII码升序排序
|
|
|
+ const sortedKeys = Object.keys(filteredParams).sort();
|
|
|
+
|
|
|
+ // 3. 构建签名字符串:key1=value1&key2=value2
|
|
|
+ const queryString = sortedKeys.map(key => {
|
|
|
+ return `${key}=${filteredParams[key]}`;
|
|
|
+ }).join('&');
|
|
|
+
|
|
|
+ // 4. 拼接回调密钥
|
|
|
+ const signStr = `${queryString}&key=${callbackSecret}`;
|
|
|
+
|
|
|
+ logger.info("华为渠道支付回调签名验证详情:", {
|
|
|
+ filteredParams,
|
|
|
+ queryString,
|
|
|
+ signStr: signStr.substring(0, 100) + '...' // 只显示前100个字符
|
|
|
+ });
|
|
|
+
|
|
|
+ // 5. MD5哈希(32位小写)
|
|
|
+ const calculatedSign = crypto.createHash('md5').update(signStr).digest('hex').toLowerCase();
|
|
|
+ const receivedSign = (data.sign || '').toLowerCase();
|
|
|
+
|
|
|
+ const isValid = calculatedSign === receivedSign;
|
|
|
+
|
|
|
+ logger.info("华为渠道支付回调签名验证结果:", {
|
|
|
+ calculatedSign,
|
|
|
+ receivedSign,
|
|
|
+ isValid
|
|
|
+ });
|
|
|
+
|
|
|
+ return isValid;
|
|
|
+ } catch (error) {
|
|
|
+ logger.error("华为渠道支付回调签名验证出错:", error);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 华为渠道支付回调处理
|
|
|
+ * POST请求,处理支付成功后的发货通知
|
|
|
+ * @param ctx Koa上下文
|
|
|
+ * @param config 渠道配置
|
|
|
+ * @returns 支付结果(注意:返回格式为 {errCode: 0} 或 {errCode: 1, errMsg: "..."})
|
|
|
+ */
|
|
|
+ async handlePayment(ctx: Context, config: ChannelConfig): Promise<PaymentResult> {
|
|
|
+ const startTime = Date.now();
|
|
|
+
|
|
|
+ try {
|
|
|
+ const data = ctx.request.body as any;
|
|
|
+ logger.info("华为渠道支付回调参数:", { url: ctx.href, params: data });
|
|
|
+
|
|
|
+ // 1. 验证必要参数
|
|
|
+ const requiredParams = ['app_id', 'user_id', 'server_id', 'role_id', 'cp_order_id',
|
|
|
+ 'cs_order_no', 'cs_trade_no', 'pay_amount', 'swap_coin_num',
|
|
|
+ 'order_create_time', 'order_pay_time', 'order_status', 'sign'];
|
|
|
+
|
|
|
+ const missingParams = requiredParams.filter(param => !data[param]);
|
|
|
+ if (missingParams.length > 0) {
|
|
|
+ logger.warn("华为渠道支付回调失败: 缺少必要参数", { missingParams });
|
|
|
+ return {
|
|
|
+ code: 1,
|
|
|
+ msg: `缺少必要参数: ${missingParams.join(', ')}`
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 验证订单状态(只有SUCCESS状态才发货)
|
|
|
+ if (data.order_status !== 'SUCCESS') {
|
|
|
+ logger.warn("华为渠道支付回调失败: 订单状态非成功", { order_status: data.order_status });
|
|
|
+ return {
|
|
|
+ code: 1,
|
|
|
+ msg: `订单状态非成功: ${data.order_status}`
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. 获取回调密钥并验证签名
|
|
|
+ const callbackSecret = config.paymentConfig?.callbackKey;
|
|
|
+ if (!callbackSecret) {
|
|
|
+ logger.error("华为渠道支付回调失败: 未配置回调密钥");
|
|
|
+ return {
|
|
|
+ code: 1,
|
|
|
+ msg: "服务器配置错误: 未配置回调密钥"
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // 验证签名(必须验证,只有签名正确的订单才可发货)
|
|
|
+ if (!this.verifyPaymentSign(data, callbackSecret)) {
|
|
|
+ logger.error("华为渠道支付回调失败: 签名验证失败");
|
|
|
+ return {
|
|
|
+ code: 1,
|
|
|
+ msg: "签名验证失败"
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // 4. 验证订单(使用cp_order_id作为订单ID)
|
|
|
+ const orderId = data.cp_order_id;
|
|
|
+ const validation = await PaymentHelper.validateOrder(orderId);
|
|
|
+
|
|
|
+ if (!validation.valid) {
|
|
|
+ // 如果是重复发货,需要返回成功响应,但不重复发货
|
|
|
+ if (validation.message?.includes("重复发货")) {
|
|
|
+ logger.info("华为渠道支付回调: 订单已发货,返回成功响应", { orderId });
|
|
|
+ return {
|
|
|
+ code: 0,
|
|
|
+ msg: "订单已发货"
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ logger.warn("华为渠道支付回调订单验证失败", {
|
|
|
+ orderId,
|
|
|
+ validation
|
|
|
+ });
|
|
|
+ return {
|
|
|
+ code: 1,
|
|
|
+ msg: validation.message || "订单验证失败"
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ const orderInfo = validation.orderInfo;
|
|
|
+
|
|
|
+ // 5. 验证金额(pay_amount单位是分,需要转换为元进行比较)
|
|
|
+ const paymentAmount = parseInt(data.pay_amount) / 100; // 转换为元
|
|
|
+ if (Math.abs(Number(orderInfo.amount) - paymentAmount) > 0.01) {
|
|
|
+ logger.warn("华为渠道支付回调金额不匹配", {
|
|
|
+ orderId,
|
|
|
+ orderAmount: orderInfo.amount,
|
|
|
+ paymentAmount: paymentAmount
|
|
|
+ });
|
|
|
+ return {
|
|
|
+ code: 1,
|
|
|
+ msg: `订单金额不一致: 订单金额${orderInfo.amount}元,支付金额${paymentAmount}元`
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // 6. 发货处理
|
|
|
+ logger.info(`华为渠道支付订单${orderId}开始发货`, {
|
|
|
+ cs_order_no: data.cs_order_no,
|
|
|
+ cs_trade_no: data.cs_trade_no,
|
|
|
+ amount: paymentAmount,
|
|
|
+ swap_coin_num: data.swap_coin_num
|
|
|
+ });
|
|
|
+
|
|
|
+ const result = await PaymentHelper.deliverOrder(
|
|
|
+ orderInfo,
|
|
|
+ ctx.request.ip,
|
|
|
+ validation.url,
|
|
|
+ data.cs_order_no // 使用传盛订单号作为out_trade_no
|
|
|
+ );
|
|
|
+
|
|
|
+ // 检查是否超时(必须在7秒内响应)
|
|
|
+ const elapsedTime = Date.now() - startTime;
|
|
|
+ if (elapsedTime > 7000) {
|
|
|
+ logger.warn("华为渠道支付回调响应超时", {
|
|
|
+ orderId,
|
|
|
+ elapsedTime: `${elapsedTime}ms`
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 7. 返回响应(注意格式:成功返回 {errCode: 0},失败返回 {errCode: 1, errMsg: "..."})
|
|
|
+ // PaymentHelper.deliverOrder 成功返回 code=1,失败返回 code=0
|
|
|
+ // 华为渠道要求:成功返回 errCode=0,失败返回 errCode=1
|
|
|
+ if (result.code === 1) {
|
|
|
+ // 发货成功
|
|
|
+ logger.info(`华为渠道支付订单${orderId}发货成功`, {
|
|
|
+ elapsedTime: `${elapsedTime}ms`
|
|
|
+ });
|
|
|
+ // 返回 code=0 表示成功,ApiController会转换为 {errCode: 0}
|
|
|
+ return {
|
|
|
+ code: 0,
|
|
|
+ msg: "发货成功"
|
|
|
+ };
|
|
|
+ } else {
|
|
|
+ // 发货失败
|
|
|
+ logger.error(`华为渠道支付订单${orderId}发货失败`, {
|
|
|
+ result,
|
|
|
+ elapsedTime: `${elapsedTime}ms`
|
|
|
+ });
|
|
|
+ // 返回 code=1 表示失败,ApiController会转换为 {errCode: 1, errMsg: "..."}
|
|
|
+ return {
|
|
|
+ code: 1,
|
|
|
+ msg: `发放失败,原因:${result.msg || "未知错误"}`
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ } catch (error: any) {
|
|
|
+ const elapsedTime = Date.now() - startTime;
|
|
|
+ logger.error("华为渠道支付回调异常", {
|
|
|
+ error: error.message,
|
|
|
+ stack: error.stack,
|
|
|
+ elapsedTime: `${elapsedTime}ms`
|
|
|
+ });
|
|
|
+
|
|
|
+ return {
|
|
|
+ code: 1,
|
|
|
+ msg: `发放失败,原因:${error.message || "未知错误"}`
|
|
|
+ };
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|