From 714d8ff3969df7dbea9addd5abd01612d7476ef1 Mon Sep 17 00:00:00 2001 From: zhengchiliu <857493511@qq.com> Date: Mon, 14 Sep 2026 02:39:00 +0800 Subject: [PATCH] =?UTF-8?q?bugfix=EF=BC=9A=E6=94=AF=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/mysqldao/order.go | 18 ++ internal/redismodel/lock.go | 1 + internal/routers/api/v1/order.go | 55 +++++- internal/service/virtualpay.go | 26 ++- internal/service/virtualpay_compensate.go | 145 +++++++++++++++ main.go | 8 +- pkg/setting/section.go | 11 +- pkg/wechat/msgencrypt.go | 217 ++++++++++++++++++++++ pkg/wechat/msgencrypt_test.go | 124 +++++++++++++ pkg/wechat/virtualpay.go | 111 +++++++++-- pkg/wechat/virtualpay_test.go | 25 +++ 11 files changed, 699 insertions(+), 42 deletions(-) create mode 100644 internal/service/virtualpay_compensate.go create mode 100644 pkg/wechat/msgencrypt.go create mode 100644 pkg/wechat/msgencrypt_test.go diff --git a/internal/mysqldao/order.go b/internal/mysqldao/order.go index 95d4fad..c50e1f7 100644 --- a/internal/mysqldao/order.go +++ b/internal/mysqldao/order.go @@ -73,6 +73,24 @@ func (d *OrderDao) OrderExpireByTime(ctx context.Context, now int64) (int64, err return res.RowsAffected, nil } +// OrderListVirtualUnconfirmed 查询需主动查单确认的虚拟支付订单: +// 支付渠道为虚拟支付(2)且状态为待支付(1)/已过期(4),创建时间落在 [since, until) 内。 +// 用于「发货推送丢失」的兜底轮询(官方建议推送与轮询同时实现),按 id 升序限量返回。 +func (d *OrderDao) OrderListVirtualUnconfirmed(ctx context.Context, since, until int64, limit int) ([]mysqlmodel.Order, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderListVirtualUnconfirmed") + defer span.End() + + var list []mysqlmodel.Order + if err := d.db.WithContext(ctx). + Where("pay_channel = ? AND order_status IN ? AND created_at >= ? AND created_at < ?", + 2, []int8{1, 4}, since, until). + Order("id ASC").Limit(limit).Find(&list).Error; err != nil { + span.RecordError(err) + return nil, err + } + return list, nil +} + // OrderGetByID 按主键 ID 查询订单 func (d *OrderDao) OrderGetByID(ctx context.Context, id int64) (*mysqlmodel.Order, error) { ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderGetByID") diff --git a/internal/redismodel/lock.go b/internal/redismodel/lock.go index 292434b..8e8a34d 100644 --- a/internal/redismodel/lock.go +++ b/internal/redismodel/lock.go @@ -10,4 +10,5 @@ const ( KeyLockClosetAudit = RedisPrefix + "lock:audit:closet:%d" // target_id,衣柜分享审核防重 KeyLockOutfitAudit = RedisPrefix + "lock:audit:outfit:%d" // target_id,穿搭审核防重 KeyLockOutfitPublic = RedisPrefix + "lock:outfit:public:%s" // outfit_uuid,穿搭公开/取消公开串行化 + KeyLockVirtualConfirm = RedisPrefix + "lock:virtual:confirm" // 虚拟支付查单补偿定时任务防重(无参数,全局单实例) ) diff --git a/internal/routers/api/v1/order.go b/internal/routers/api/v1/order.go index d411e7d..07274fb 100644 --- a/internal/routers/api/v1/order.go +++ b/internal/routers/api/v1/order.go @@ -147,12 +147,25 @@ func (h *OrderHandler) RefundNotify(c *gin.Context) { } // XPayVerify 虚拟支付消息推送 URL 配置验证(GET):校验签名并原样回显 echostr。 +// 兼容两种模式:安全模式带 msg_signature 且 echostr 为密文(需解密后回显明文); +// 明文模式带 signature,直接回显 echostr。 func (h *OrderHandler) XPayVerify(c *gin.Context) { timestamp := c.Query("timestamp") nonce := c.Query("nonce") - signature := c.Query("signature") echostr := c.Query("echostr") - if wechat.VerifyNotifyURL(timestamp, nonce, signature) { + + if msgSignature := c.Query("msg_signature"); msgSignature != "" { + plain, err := wechat.DecodeEchoStr(echostr, timestamp, nonce, msgSignature) + if err != nil { + // 兼容模式:echostr 可能仍是明文(msg_signature 对明文计算),原样回显。 + c.String(http.StatusOK, echostr) + return + } + c.String(http.StatusOK, plain) + return + } + + if wechat.VerifyNotifyURL(timestamp, nonce, c.Query("signature")) { c.String(http.StatusOK, echostr) return } @@ -160,14 +173,46 @@ func (h *OrderHandler) XPayVerify(c *gin.Context) { } // XPayNotify 虚拟支付消息推送回调(POST,公开,按虚拟支付规范返回 ErrCode 应答)。 +// 安全模式下包体为密文信封:先验签解密再交给业务层,回包同样加密;明文模式直接透传。 func (h *OrderHandler) XPayNotify(c *gin.Context) { raw, err := io.ReadAll(c.Request.Body) if err != nil { - c.Data(http.StatusOK, "application/json; charset=utf-8", wechat.BuildNotifyResponse(false)) + writeXPayNotifyReply(c, false, false) return } - resp := h.svc.XPayNotify(c.Request.Context(), raw) - c.Data(http.StatusOK, "application/json; charset=utf-8", resp) + + encrypted := wechat.IsEncryptedNotify(c.Query("msg_signature")) + body := raw + if encrypted { + plain, derr := wechat.DecodeEncryptedNotify(raw, c.Query("timestamp"), c.Query("nonce"), c.Query("msg_signature")) + if derr != nil { + // 验签/解密失败:回失败应答触发微信重试(fail-closed)。 + writeXPayNotifyReply(c, true, false) + return + } + body = plain + } + + resp := h.svc.XPayNotify(c.Request.Context(), body) + c.Data(http.StatusOK, "application/json; charset=utf-8", encryptIfNeeded(c, resp, encrypted)) +} + +// writeXPayNotifyReply 按模式构造应答:加密模式下加密,否则回明文。 +func writeXPayNotifyReply(c *gin.Context, encrypted, success bool) { + resp := wechat.BuildNotifyResponse(success) + c.Data(http.StatusOK, "application/json; charset=utf-8", encryptIfNeeded(c, resp, encrypted)) +} + +// encryptIfNeeded 安全模式下把明文应答加密为信封;加密不可用时退回明文(仍返回 200)。 +func encryptIfNeeded(c *gin.Context, plain []byte, encrypted bool) []byte { + if !encrypted { + return plain + } + out, err := wechat.EncodeEncryptedNotify(plain, c.Query("nonce")) + if err != nil { + return plain + } + return out } // wechatCallbackSuccess 按微信规范返回受理成功,避免微信重复回调。 diff --git a/internal/service/virtualpay.go b/internal/service/virtualpay.go index 03bdd6c..ab3f9c0 100644 --- a/internal/service/virtualpay.go +++ b/internal/service/virtualpay.go @@ -288,7 +288,7 @@ func (s *OrderService) handleGoodsDeliver(ctx context.Context, deliver *wechat.X return errcode.DatabaseError } if updated { - s.finalizeVirtualPaidOrder(ctx, order, deliver, raw) + s.finalizeVirtualPaidOrder(ctx, order, deliver.WeChatPayInfo.PaidTime, deliver.WeChatPayInfo.TransactionID, raw) return nil } @@ -314,7 +314,7 @@ func (s *OrderService) handleGoodsDeliver(ctx context.Context, deliver *wechat.X if ok, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, deliver.OutTradeNo, OrderStatusExpired, OrderStatusPaid); uerr != nil { return errcode.DatabaseError } else if ok { - s.finalizeVirtualPaidOrder(ctx, current, deliver, raw) + s.finalizeVirtualPaidOrder(ctx, current, deliver.WeChatPayInfo.PaidTime, deliver.WeChatPayInfo.TransactionID, raw) } case OrderStatusCancelled: // 防御分支:历史上已取消的虚拟支付订单用户仍完成了支付,补发货避免「钱已扣、权益不到账」。 @@ -322,7 +322,7 @@ func (s *OrderService) handleGoodsDeliver(ctx context.Context, deliver *wechat.X if ok, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, deliver.OutTradeNo, OrderStatusCancelled, OrderStatusPaid); uerr != nil { return errcode.DatabaseError } else if ok { - s.finalizeVirtualPaidOrder(ctx, current, deliver, raw) + s.finalizeVirtualPaidOrder(ctx, current, deliver.WeChatPayInfo.PaidTime, deliver.WeChatPayInfo.TransactionID, raw) } } return nil @@ -376,16 +376,21 @@ func (s *OrderService) handleIOSRefundQuery(ctx context.Context, query *wechat.X order, err := s.orderDao.OrderGetByOrderNo(ctx, query.OutTradeNo) if err != nil { slog.ErrorContext(ctx, "xpay ios refund query: order not found", "out_trade_no", query.OutTradeNo, "error", err) - return wechat.BuildIOSRefundQueryResponse(1, "order not found") + return wechat.BuildIOSRefundQueryResponse(1, "拒绝退款", + "订单号 "+query.OutTradeNo+" 在本地无记录,无法确认权益使用情况,拒绝退款") } + // 已退款:款项已退回,再次建议退款会造成重复退款资损。 if order.OrderStatus == OrderStatusRefunded { - return wechat.BuildIOSRefundQueryResponse(0, "already refunded") + return wechat.BuildIOSRefundQueryResponse(1, "拒绝退款", + "订单 "+order.OrderNo+" 已处于已退款状态,不支持重复退款") } // 权益已消耗则拒绝退款,防止「已使用权益后白嫖退款」。 if s.iosRefundBenefitsConsumed(ctx, order) { - return wechat.BuildIOSRefundQueryResponse(1, "权益已使用,不支持退款") + return wechat.BuildIOSRefundQueryResponse(1, "拒绝退款", + "订单 "+order.OrderNo+" 发放的权益已被使用(上传额度已消耗或会员已生效),不支持退款") } - return wechat.BuildIOSRefundQueryResponse(0, order.OrderNo) + return wechat.BuildIOSRefundQueryResponse(0, "建议退款", + "订单 "+order.OrderNo+" 发放的权益尚未使用,建议退款") } // iosRefundBenefitsConsumed 判断订单发放的权益是否已被消耗(用于 iOS 退款问询拒绝策略)。 @@ -414,10 +419,11 @@ func (s *OrderService) iosRefundBenefitsConsumed(ctx context.Context, order *mys } // finalizeVirtualPaidOrder 虚拟支付发货:记录支付信息、发放权益并发送通知。 -func (s *OrderService) finalizeVirtualPaidOrder(ctx context.Context, order *mysqlmodel.Order, deliver *wechat.XPayGoodsDeliverNotify, raw string) { +// paidTime/transactionID 由调用方提供:推送路径取推送报文,查单补偿路径取 query_order 结果。 +func (s *OrderService) finalizeVirtualPaidOrder(ctx context.Context, order *mysqlmodel.Order, paidTime int64, transactionID, raw string) { _ = s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{ - "pay_time": resolvePaidTime(deliver.WeChatPayInfo.PaidTime), - "transaction_id": deliver.WeChatPayInfo.TransactionID, + "pay_time": resolvePaidTime(paidTime), + "transaction_id": transactionID, "callback_raw": raw, }) diff --git a/internal/service/virtualpay_compensate.go b/internal/service/virtualpay_compensate.go new file mode 100644 index 0000000..f7496ae --- /dev/null +++ b/internal/service/virtualpay_compensate.go @@ -0,0 +1,145 @@ +package service + +import ( + "context" + "log/slog" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/wechat" +) + +const ( + // virtualConfirmLookbackSeconds 查单补偿的回看窗口:只处理近 2 小时内的待支付/已过期虚拟订单。 + // 该窗口覆盖推送重试耗尽的时间(官方最多重试 15 次、间隔 2/4/8/16… 秒),又避免全表扫描。 + virtualConfirmLookbackSeconds int64 = 2 * 60 * 60 + // virtualConfirmBatchSize 单轮最多处理的订单数,控制单轮耗时与微信接口调用量。 + virtualConfirmBatchSize = 50 +) + +// StartVirtualPayConfirmScheduler 启动虚拟支付查单补偿定时任务(官方「道具直购流程」的发货轮询分支)。 +// +// 官方要求「发货推送」与「发货轮询」至少实现一个、两者结合更可靠:推送可能丢失 +// (回调地址未生效、网络抖动、重试 15 次耗尽、服务重启窗口),且 wx.requestVirtualPayment +// 的 success 回调也会丢失。本任务按窗口轮询 query_order,把「微信侧已支付但本地仍为 +// 待支付/已过期」的订单条件流转为已支付并补发货,再回写 notify_provide_goods, +// 保证用户付款后权益一定到账、微信侧订单也能正常推进到已发货。 +func StartVirtualPayConfirmScheduler() { + go func() { + slog.Info("virtual pay confirm scheduler started") + for { + func() { + defer func() { + if r := recover(); r != nil { + slog.Error("virtual pay confirm scheduler panic", "recover", r) + } + }() + runVirtualPayConfirm() + }() + time.Sleep(time.Minute) + } + }() +} + +// runVirtualPayConfirm 扫描待确认的虚拟支付订单并逐个向微信反查,单实例通过 Redis 分布式锁防重。 +func runVirtualPayConfirm() { + // 虚拟支付未开通时直接返回,避免无谓的数据库扫描。 + if !wechat.IsVirtualPayEnabled() { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + ctx, span := global.Tracer.Start(ctx, "scheduler.VirtualPayConfirm") + defer span.End() + + lockKey := redismodel.KeyLockVirtualConfirm + lockVal := genUUID() + ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, time.Minute) + if lerr != nil || !ok { + return + } + defer redisdao.Unlock(context.Background(), lockKey, lockVal) + + now := nowUnix() + orders, err := mysqldao.NewOrderDao(). + OrderListVirtualUnconfirmed(ctx, now-virtualConfirmLookbackSeconds, now, virtualConfirmBatchSize) + if err != nil { + slog.ErrorContext(ctx, "list unconfirmed virtual orders failed", "error", err) + return + } + if len(orders) == 0 { + return + } + + s := NewOrderService() + confirmed := 0 + for i := range orders { + if s.confirmVirtualOrder(ctx, &orders[i]) { + confirmed++ + } + } + if confirmed > 0 { + slog.InfoContext(ctx, "virtual pay confirm compensated orders", + "scanned", len(orders), "confirmed", confirmed) + } +} + +// confirmVirtualOrder 向微信反查单个虚拟支付订单,确认已支付则条件流转并补发货,返回是否完成补发。 +// 对应官方「道具直购流程」的 9.1(轮询 query_order)→ 9.2(发货)→ 9.3(notify_provide_goods 回写)。 +// 与推送路径共用同一套幂等保护(条件流转 + grantOrderBenefits 内部幂等),重复执行不会重复发放。 +func (s *OrderService) confirmVirtualOrder(ctx context.Context, order *mysqlmodel.Order) bool { + // query_order 需要 openid,订单表不冗余该字段,按 user_id 取。 + user, err := s.userDao.UserGetByID(ctx, order.UserID) + if err != nil || user.WxOpenID == "" { + slog.WarnContext(ctx, "virtual confirm: user openid unavailable", + "order_no", order.OrderNo, "user_id", order.UserID, "error", err) + return false + } + + info, qerr := wechat.QueryOrder(ctx, user.WxOpenID, order.OrderNo, "") + if qerr != nil { + slog.ErrorContext(ctx, "virtual confirm: query order failed", + "order_no", order.OrderNo, "error", qerr) + return false + } + if !wechat.IsOrderPaidStatus(info.Order.Status) { + // 未支付:用户放弃或仍在支付中,保持原状态等待过期。 + return false + } + // 金额必须与下单快照一致,防止订单号错配导致错发。 + if info.Order.PaidFee != order.PayAmount { + slog.ErrorContext(ctx, "virtual confirm: amount mismatch", + "order_no", order.OrderNo, "query_amount", info.Order.PaidFee, "order_amount", order.PayAmount) + return false + } + + // 待支付 / 已过期 -> 已支付(条件流转天然幂等,多实例并发无害)。 + for _, from := range []int8{OrderStatusPending, OrderStatusExpired} { + ok, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, order.OrderNo, from, OrderStatusPaid) + if uerr != nil { + slog.ErrorContext(ctx, "virtual confirm: update status failed", + "order_no", order.OrderNo, "from_status", from, "error", uerr) + return false + } + if ok { + slog.InfoContext(ctx, "virtual confirm: order compensated", + "order_no", order.OrderNo, "from_status", from) + s.finalizeVirtualPaidOrder(ctx, order, info.Order.PaidTime, info.Order.WxpayOrderID, "") + // 9.3 通知微信发货完成:查单分支没有推送应答的机会,必须主动回写, + // 否则微信侧订单会长期停留在「已支付待发货」,影响结算推进。 + // 权益此时已发放,回写失败只影响微信侧状态,记 error 告警,不回滚发货。 + if nerr := wechat.NotifyProvideGoods(ctx, order.OrderNo); nerr != nil { + slog.ErrorContext(ctx, "virtual confirm: notify provide goods failed", + "order_no", order.OrderNo, "error", nerr) + } + return true + } + } + return false +} diff --git a/main.go b/main.go index 2c0980b..9986bcb 100644 --- a/main.go +++ b/main.go @@ -61,9 +61,10 @@ func main() { tokenhub.EnsureTodayFortunes() tokenhub.StartFortuneScheduler() - // 启动权益发放补偿与订单超时过期后台任务。 + // 启动权益发放补偿、订单超时过期与虚拟支付查单补偿后台任务。 service.StartBenefitCompensationScheduler() service.StartOrderExpireScheduler() + service.StartVirtualPayConfirmScheduler() router := routers.NewRouter() @@ -345,5 +346,8 @@ func setupVirtualPayClient() { return } env, _ := wechat.GetVirtualPayEnv() - slog.Info("virtual pay client initialized", "env", env, "env_name", map[int]string{0: "prod", 1: "sandbox"}[env]) + slog.Info("virtual pay client initialized", + "env", env, + "env_name", map[int]string{0: "prod", 1: "sandbox"}[env], + "msg_encrypt", wechat.HasMsgEncryptKey()) } diff --git a/pkg/setting/section.go b/pkg/setting/section.go index b647f7a..3449272 100644 --- a/pkg/setting/section.go +++ b/pkg/setting/section.go @@ -117,11 +117,12 @@ type WechatPaySettingS struct { // VirtualPaySettingS 微信小程序虚拟支付(XPay)配置。 // 虚拟支付与普通微信支付为独立商户号、独立结算,配置项在 MP 后台「虚拟支付 → 基本配置」获取。 type VirtualPaySettingS struct { - OfferID string // 虚拟支付 OfferID(AppID) - EnvAppKey string // 现网 AppKey(env=0) - SandboxAppKey string // 沙箱 AppKey(env=1,iOS 不支持沙箱,默认不启用) - Token string // 消息推送验签 Token(配置回调地址时自定义) - Enable bool // 是否启用虚拟支付;未开通时置 false,虚拟商品下单返回「虚拟支付未开通」 + OfferID string // 虚拟支付 OfferID(AppID) + EnvAppKey string // 现网 AppKey(env=0) + SandboxAppKey string // 沙箱 AppKey(env=1,iOS 不支持沙箱,默认不启用) + Token string // 消息推送验签 Token(配置回调地址时自定义) + EncodingAESKey string // 消息加解密密钥(43 位;填了即启用「安全模式」纯密文收发,留空为明文模式) + Enable bool // 是否启用虚拟支付;未开通时置 false,虚拟商品下单返回「虚拟支付未开通」 } // UserDefaultQuotaSettingS 新用户注册时初始化的免费额度与赠送会员天数配置。 diff --git a/pkg/wechat/msgencrypt.go b/pkg/wechat/msgencrypt.go new file mode 100644 index 0000000..ec30eb2 --- /dev/null +++ b/pkg/wechat/msgencrypt.go @@ -0,0 +1,217 @@ +package wechat + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "strconv" + "time" +) + +// 微信消息推送「安全模式」加解密,与公众号/服务号的消息加解密协议一致: +// +// AESKey = base64.StdEncoding.Decode(EncodingAESKey + "=") // 32 字节 +// IV = AESKey[:16](CBC 模式) +// 填充 = PKCS#7(块大小 32) +// 明文结构 = random(16B) + msgLen(4B, 大端) + msg + appid +// 接收签名 = sha1(sort(token, timestamp, nonce, Encrypt)) -> URL 参数 msg_signature +// 回包签名 = sha1(sort(token, TimeStamp, Nonce, Encrypt)) -> 回包字段 MsgSignature +// +// 安全模式下微信推送的包体只含密文(JSON 信封 {"Encrypt":"..."}),开发者的回包也必须为密文; +// 未配置 EncodingAESKey 时按明文模式处理(包体即业务明文,回包为明文应答)。 +const ( + // wxPKCS7BlockSize 微信 PKCS#7 填充块大小(固定 32,与 AES 的 16 字节分组兼容)。 + wxPKCS7BlockSize = 32 + // wxEncodingAESKeyLen EncodingAESKey 固定长度(43 位,base64 补 "=" 后解出 32 字节)。 + wxEncodingAESKeyLen = 43 +) + +// IsEncryptedNotify 判断本次推送是否为安全模式(微信会带 msg_signature 参数)。 +func IsEncryptedNotify(msgSignature string) bool { + return msgSignature != "" +} + +// HasMsgEncryptKey 是否已配置 EncodingAESKey(安全模式可用)。 +func HasMsgEncryptKey() bool { + c, err := getVirtualPayClient() + return err == nil && len(c.aesKey) == 32 +} + +// DecodeEchoStr 安全模式 URL 配置验证:校验 msg_signature 并解密 echostr,返回明文供原样回显。 +// echostr 仅用于验证地址归属,不做 appid 校验(避免因 appid 形态差异导致后台无法保存配置)。 +func DecodeEchoStr(echostr, timestamp, nonce, msgSignature string) (string, error) { + c, err := getVirtualPayClient() + if err != nil { + return "", err + } + if !c.checkMsgSignature(timestamp, nonce, echostr, msgSignature) { + return "", errors.New("xpay verify msg_signature mismatch") + } + msg, _, derr := c.decrypt(echostr) + if derr != nil { + return "", fmt.Errorf("decrypt echostr: %w", derr) + } + return msg, nil +} + +// DecodeEncryptedNotify 安全模式接收:校验 msg_signature 并解密出明文业务报文。 +// 任一环节失败都返回错误,调用方应回失败应答让微信重试(fail-closed)。 +func DecodeEncryptedNotify(raw []byte, timestamp, nonce, msgSignature string) ([]byte, error) { + c, err := getVirtualPayClient() + if err != nil { + return nil, err + } + encrypt, err := extractEncryptField(raw) + if err != nil { + return nil, err + } + if !c.checkMsgSignature(timestamp, nonce, encrypt, msgSignature) { + return nil, errors.New("xpay notify msg_signature mismatch") + } + msg, appID, derr := c.decrypt(encrypt) + if derr != nil { + return nil, fmt.Errorf("decrypt xpay notify: %w", derr) + } + // 防伪造:密文内的 appid 必须是本小程序。 + if appID != c.appID { + return nil, fmt.Errorf("xpay notify appid mismatch: got %q, want %q", appID, c.appID) + } + return []byte(msg), nil +} + +// EncodeEncryptedNotify 安全模式回包:把明文业务应答加密为信封 +// {"Encrypt":..,"MsgSignature":..,"TimeStamp":..,"Nonce":..}。 +func EncodeEncryptedNotify(plain []byte, nonce string) ([]byte, error) { + c, err := getVirtualPayClient() + if err != nil { + return nil, err + } + encrypt, eerr := c.encrypt(string(plain)) + if eerr != nil { + return nil, fmt.Errorf("encrypt xpay reply: %w", eerr) + } + now := time.Now().Unix() + return json.Marshal(map[string]any{ + "Encrypt": encrypt, + "MsgSignature": sortedSHA1Hex(c.token, strconv.FormatInt(now, 10), nonce, encrypt), + "TimeStamp": now, + "Nonce": nonce, + }) +} + +// extractEncryptField 从安全模式信封中取出 Encrypt 字段(只支持 JSON 信封)。 +func extractEncryptField(raw []byte) (string, error) { + if trimmed := bytes.TrimLeft(raw, " \t\r\n"); len(trimmed) > 0 && trimmed[0] == '<' { + return "", errors.New("xpay notify envelope is XML but only JSON is supported; " + + "please set 消息推送 → 数据格式 = JSON in MP console") + } + var env struct { + Encrypt string `json:"Encrypt"` + } + if err := json.Unmarshal(raw, &env); err != nil { + return "", fmt.Errorf("parse xpay notify envelope: %w", err) + } + if env.Encrypt == "" { + return "", errors.New("xpay notify envelope missing Encrypt") + } + return env.Encrypt, nil +} + +// checkMsgSignature 校验安全模式接收签名:sha1(sort(token, timestamp, nonce, encrypt))。 +func (c *virtualPayClient) checkMsgSignature(timestamp, nonce, encrypt, msgSignature string) bool { + if c.token == "" || msgSignature == "" { + return false + } + return sortedSHA1Hex(c.token, timestamp, nonce, encrypt) == msgSignature +} + +// decrypt 解密微信密文:AES-256-CBC(IV=AESKey[:16])→ 去 PKCS#7 → 切出 msg 与 appid。 +func (c *virtualPayClient) decrypt(encrypt string) (msg, appID string, err error) { + if len(c.aesKey) != 32 { + return "", "", errors.New("virtual pay encoding_aes_key not configured") + } + cipherText, err := base64.StdEncoding.DecodeString(encrypt) + if err != nil { + return "", "", fmt.Errorf("base64 decode cipher text: %w", err) + } + block, err := aes.NewCipher(c.aesKey) + if err != nil { + return "", "", fmt.Errorf("new aes cipher: %w", err) + } + if len(cipherText) == 0 || len(cipherText)%block.BlockSize() != 0 { + return "", "", fmt.Errorf("invalid cipher text length %d", len(cipherText)) + } + + plain := make([]byte, len(cipherText)) + cipher.NewCBCDecrypter(block, c.aesKey[:block.BlockSize()]).CryptBlocks(plain, cipherText) + plain, err = pkcs7Unpad(plain, wxPKCS7BlockSize) + if err != nil { + return "", "", err + } + if len(plain) < 20 { + return "", "", fmt.Errorf("decrypted payload too short: %d bytes", len(plain)) + } + msgLen := int(binary.BigEndian.Uint32(plain[16:20])) + if 20+msgLen > len(plain) { + return "", "", fmt.Errorf("invalid msg length %d in decrypted payload %d", msgLen, len(plain)) + } + return string(plain[20 : 20+msgLen]), string(plain[20+msgLen:]), nil +} + +// encrypt 加密:random(16B) + msgLen(4B, 大端) + msg + appid → PKCS#7 → AES-256-CBC → base64。 +func (c *virtualPayClient) encrypt(msg string) (string, error) { + if len(c.aesKey) != 32 { + return "", errors.New("virtual pay encoding_aes_key not configured") + } + random := make([]byte, 16) + if _, err := rand.Read(random); err != nil { + return "", fmt.Errorf("read random bytes: %w", err) + } + + var buf bytes.Buffer + buf.Write(random) + msgBytes := []byte(msg) + var lenBuf [4]byte + binary.BigEndian.PutUint32(lenBuf[:], uint32(len(msgBytes))) + buf.Write(lenBuf[:]) + buf.Write(msgBytes) + buf.WriteString(c.appID) + + plain := pkcs7Pad(buf.Bytes(), wxPKCS7BlockSize) + block, err := aes.NewCipher(c.aesKey) + if err != nil { + return "", fmt.Errorf("new aes cipher: %w", err) + } + out := make([]byte, len(plain)) + cipher.NewCBCEncrypter(block, c.aesKey[:block.BlockSize()]).CryptBlocks(out, plain) + return base64.StdEncoding.EncodeToString(out), nil +} + +// pkcs7Pad 按微信约定填充到 blockSize 的整数倍(每个填充字节的值即填充长度)。 +func pkcs7Pad(data []byte, blockSize int) []byte { + padding := blockSize - len(data)%blockSize + return append(data, bytes.Repeat([]byte{byte(padding)}, padding)...) +} + +// pkcs7Unpad 去除 PKCS#7 填充并校验填充合法性。 +func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) { + if len(data) == 0 || len(data)%blockSize != 0 { + return nil, fmt.Errorf("invalid padded data length %d", len(data)) + } + padding := int(data[len(data)-1]) + if padding == 0 || padding > blockSize { + return nil, fmt.Errorf("invalid pkcs7 padding value %d", padding) + } + for _, b := range data[len(data)-padding:] { + if int(b) != padding { + return nil, errors.New("invalid pkcs7 padding bytes") + } + } + return data[:len(data)-padding], nil +} diff --git a/pkg/wechat/msgencrypt_test.go b/pkg/wechat/msgencrypt_test.go new file mode 100644 index 0000000..ff1b88d --- /dev/null +++ b/pkg/wechat/msgencrypt_test.go @@ -0,0 +1,124 @@ +package wechat + +import ( + "encoding/base64" + "strings" + "testing" +) + +// testEncodingAESKey 由固定 32 字节密钥生成合法的 43 位 EncodingAESKey。 +func testEncodingAESKey() string { + key := []byte("0123456789abcdef0123456789abcdef") + return strings.TrimRight(base64.StdEncoding.EncodeToString(key), "=") +} + +// TestSortedSHA1HexOfficialVectors 用官方文档示例校验签名算法: +// 参与项按字典序(字节序)排序后拼接,取 sha1 小写 hex。 +// 官方「安全模式接收 msg_signature」示例中的 Encrypt 是占位符而非真实密文,无法复现,故不纳入。 +func TestSortedSHA1HexOfficialVectors(t *testing.T) { + cases := []struct { + name string + parts []string + want string + }{ + { + name: "GET URL 验证 signature", + parts: []string{"AAAAA", "1514711492", "1714036504"}, + want: "f464b24fc39322e44b38aa78f5edd27bd1441696", + }, + { + name: "明文模式接收 signature", + parts: []string{"AAAAA", "1714037059", "486452656"}, + want: "899cf89e464efb63f54ddac96b0a0a235f53aa78", + }, + { + name: "安全模式回包 MsgSignature", + parts: []string{ + "AAAAA", "1713424427", "415670741", + "ELGduP2YcVatjqIS+eZbp80MNLoAUWvzzyJxgGzxZO/5sAvd070Bs6qrLARC9nVHm48Y4hyRbtzve1L32tmxSQ==", + }, + want: "1b9339964ed2e271e7c7b6ff2b0ef902fc94dea1", + }, + } + for _, c := range cases { + if got := sortedSHA1Hex(c.parts...); got != c.want { + t.Fatalf("%s: got %s, want %s", c.name, got, c.want) + } + } +} + +// TestVirtualPayMsgCryptRoundTrip 校验安全模式加解密闭环:明文 → 加密 → 解密应完全还原, +// 且密文中携带的 appid 可被正确切出(用于防伪造校验)。 +func TestVirtualPayMsgCryptRoundTrip(t *testing.T) { + key, err := decodeEncodingAESKey(testEncodingAESKey()) + if err != nil { + t.Fatalf("decodeEncodingAESKey: %v", err) + } + c := &virtualPayClient{token: "test-token", aesKey: key, appID: "wxfc03c59fc1bb4de5"} + + const plain = `{"ErrCode":0,"ErrMsg":"success"}` + encrypt, err := c.encrypt(plain) + if err != nil { + t.Fatalf("encrypt: %v", err) + } + msg, appID, err := c.decrypt(encrypt) + if err != nil { + t.Fatalf("decrypt: %v", err) + } + if msg != plain { + t.Fatalf("round trip msg = %q, want %q", msg, plain) + } + if appID != c.appID { + t.Fatalf("round trip appid = %q, want %q", appID, c.appID) + } +} + +// TestVirtualPayMsgCryptSignature 校验接收签名:只有 token/timestamp/nonce/encrypt 全对才通过。 +func TestVirtualPayMsgCryptSignature(t *testing.T) { + c := &virtualPayClient{token: "test-token"} + const ( + timestamp = "1714112445" + nonce = "415670741" + encrypt = "abc123" + ) + good := sortedSHA1Hex(c.token, timestamp, nonce, encrypt) + if !c.checkMsgSignature(timestamp, nonce, encrypt, good) { + t.Fatal("valid msg_signature should pass") + } + if c.checkMsgSignature(timestamp, nonce, encrypt, "deadbeef") { + t.Fatal("wrong msg_signature should fail") + } + if c.checkMsgSignature(timestamp, nonce, "tampered", good) { + t.Fatal("tampered encrypt should fail") + } + if c.checkMsgSignature(timestamp, nonce, encrypt, "") { + t.Fatal("empty msg_signature should fail") + } +} + +// TestDecodeEncodingAESKey 校验密钥解码:空值=明文模式,长度非法报错,合法值解出 32 字节。 +func TestDecodeEncodingAESKey(t *testing.T) { + if key, err := decodeEncodingAESKey(""); err != nil || key != nil { + t.Fatalf("empty key: key=%v err=%v, want nil/nil", key, err) + } + if _, err := decodeEncodingAESKey("tooshort"); err == nil { + t.Fatal("short key should fail") + } + key, err := decodeEncodingAESKey(testEncodingAESKey()) + if err != nil || len(key) != 32 { + t.Fatalf("valid key: len=%d err=%v, want 32/nil", len(key), err) + } +} + +// TestExtractEncryptField 校验安全模式信封解析与 XML 明确提示。 +func TestExtractEncryptField(t *testing.T) { + if _, err := extractEncryptField([]byte(`{"ToUserName":"gh_x","Encrypt":"abc"}`)); err != nil { + t.Fatalf("json envelope: %v", err) + } + if _, err := extractEncryptField([]byte(`{"ToUserName":"gh_x"}`)); err == nil { + t.Fatal("missing Encrypt should fail") + } + if _, err := extractEncryptField([]byte(`abc`)); err == nil { + t.Fatal("xml envelope should fail with a clear hint") + } +} diff --git a/pkg/wechat/virtualpay.go b/pkg/wechat/virtualpay.go index 16e352a..29ab6d0 100644 --- a/pkg/wechat/virtualpay.go +++ b/pkg/wechat/virtualpay.go @@ -21,6 +21,7 @@ import ( "crypto/hmac" "crypto/sha1" "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -29,6 +30,7 @@ import ( "net/http" "net/url" "sort" + "strings" "sync" "time" @@ -41,8 +43,9 @@ const ( xpayBaseURL = "https://api.weixin.qq.com" stableTokenURL = "https://api.weixin.qq.com/cgi-bin/stable_token" - xpayQueryOrderPath = "/xpay/query_order" - xpayRefundOrderPath = "/xpay/refund_order" + xpayQueryOrderPath = "/xpay/query_order" + xpayRefundOrderPath = "/xpay/refund_order" + xpayNotifyProvideGoodsPath = "/xpay/notify_provide_goods" // requestVirtualPaymentURI 前端调起支付时计算 paySig 使用的固定 uri。 requestVirtualPaymentURI = "requestVirtualPayment" @@ -61,6 +64,10 @@ type virtualPayClient struct { env int // 激活环境 0=现网 1=沙箱 token string // 消息推送验签 Token(配置回调地址时自定义) + // 消息推送加解密:encodingAESKey 为空表示明文模式;aesKey 为预解码的 32 字节密钥。 + encodingAESKey string + aesKey []byte + appID string // 获取 access_token 用的小程序 AppID appSecret string // 小程序 AppSecret @@ -90,6 +97,26 @@ func resolveEnvKey(envAppKey, sandboxAppKey string) (appKey string, env int, err return "", 0, errors.New("virtual pay app key not configured: both env_app_key and sandbox_app_key are empty") } +// decodeEncodingAESKey 解码消息加解密密钥:尾部补 "=" 后 base64 解码,必须得到 32 字节。 +// 空字符串表示未启用安全模式(明文模式),返回 nil 且不报错。 +func decodeEncodingAESKey(encodingAESKey string) ([]byte, error) { + if encodingAESKey == "" { + return nil, nil + } + if len(encodingAESKey) != wxEncodingAESKeyLen { + return nil, fmt.Errorf("virtual pay encoding_aes_key must be %d chars, got %d", + wxEncodingAESKeyLen, len(encodingAESKey)) + } + key, err := base64.StdEncoding.DecodeString(encodingAESKey + "=") + if err != nil { + return nil, fmt.Errorf("virtual pay decode encoding_aes_key: %w", err) + } + if len(key) != 32 { + return nil, fmt.Errorf("virtual pay encoding_aes_key must decode to 32 bytes, got %d", len(key)) + } + return key, nil +} + // InitVirtualPayClient 初始化全局虚拟支付客户端。 // 复用 global.WechatMiniSetting 的 AppID/AppSecret 获取 access_token(同一小程序)。 // 环境选择:只填沙箱 AppKey 走沙箱(env=1,测试不真实扣款);只填现网或两把都填走现网(env=0)。 @@ -108,14 +135,21 @@ func InitVirtualPayClient(s *setting.VirtualPaySettingS) error { return errors.New("wechat mini setting incomplete, cannot fetch virtual pay access_token") } + aesKey, aerr := decodeEncodingAESKey(s.EncodingAESKey) + if aerr != nil { + return aerr + } + c := &virtualPayClient{ - offerID: s.OfferID, - appKey: appKey, - env: env, - token: s.Token, - appID: global.WechatMiniSetting.AppID, - appSecret: global.WechatMiniSetting.AppSecret, - httpClient: &http.Client{Timeout: 10 * time.Second}, + offerID: s.OfferID, + appKey: appKey, + env: env, + token: s.Token, + encodingAESKey: s.EncodingAESKey, + aesKey: aesKey, + appID: global.WechatMiniSetting.AppID, + appSecret: global.WechatMiniSetting.AppSecret, + httpClient: &http.Client{Timeout: 10 * time.Second}, } virtualPayMu.Lock() @@ -384,6 +418,24 @@ func QueryOrder(ctx context.Context, openid, orderID, wxOrderID string) (*XPayOr return out, nil } +// NotifyProvideGoods 通知微信「已发货完成」(只能通知现金单,作用于激活环境)。 +// +// 对应道具直购流程的 9.3:仅「发货轮询/查单补偿」分支需要调用——该分支没有推送应答的机会, +// 必须主动把微信侧订单置为已发货。走 xpay_goods_deliver_notify 推送且应答成功(ErrCode:0) +// 时,微信已视同发货完成,无需再调用本接口。 +// 幂等:微信侧按订单号置状态,重复调用无副作用。 +func NotifyProvideGoods(ctx context.Context, orderID string) error { + if orderID == "" { + return errors.New("notify provide goods: order_id is required") + } + c, err := getVirtualPayClient() + if err != nil { + return err + } + body := map[string]any{"order_id": orderID, "env": c.env} + return c.callXPay(ctx, xpayNotifyProvideGoodsPath, body, nil) +} + // RefundOrder 启动虚拟支付退款任务(仅启动任务,结果需轮询 QueryOrder;作用于激活环境)。 // - leftFee:剩余金额(分),即退款前的订单实付金额; // - refundFee:本次退款金额(分)。 @@ -532,6 +584,12 @@ func ParseNotify(raw []byte) (*XPayNotifyBase, error) { if len(raw) == 0 { return nil, errors.New("empty xpay notify body") } + // MP 后台「消息推送 → 数据格式」可选 JSON/XML(默认 XML),本实现只支持 JSON。 + // 收到 XML 时给出明确提示,否则只会表现为「反复重试 + 无业务日志」,极难定位。 + if trimmed := bytes.TrimLeft(raw, " \t\r\n"); len(trimmed) > 0 && trimmed[0] == '<' { + return nil, errors.New("xpay notify is XML but only JSON is supported; " + + "please set 消息推送 → 数据格式 = JSON in MP console") + } base := &XPayNotifyBase{} if err := json.Unmarshal(raw, base); err != nil { return nil, fmt.Errorf("parse xpay notify: %w", err) @@ -550,29 +608,42 @@ func BuildNotifyResponse(success bool) []byte { return []byte(`{"ErrCode":1,"ErrMsg":"处理失败"}`) } -// BuildIOSRefundQueryResponse 构造 iOS 退款问询应答(3 秒内必须返回)。 -// resultCode: 0 建议退款、1 拒绝退款;evidence 为决策凭据。 -// 字段名以官方文档为准(result_code / evidence)。 +// BuildIOSRefundQueryResponse 构造 iOS 退款问询应答(Apple 最多连续问询 3 次,须 3 秒内返回)。 +// 应答必须包一层 IosRefundQueryResponse,并带 ErrCode/ErrMsg,字段名以官方文档为准: +// - result_code:0 放过(建议退款)、1 拦截(拒绝退款) +// - result_info:结果描述 +// - evidence:决策凭据(必填,须具体,用于退款审计,不可为空或过于笼统) +// // 使用 json.Marshal 保证字符串转义符合 JSON 规范(避免 fmt %q 的 Go 风格转义)。 -func BuildIOSRefundQueryResponse(resultCode int, evidence string) []byte { +func BuildIOSRefundQueryResponse(resultCode int, resultInfo, evidence string) []byte { b, _ := json.Marshal(map[string]any{ - "result_code": resultCode, - "evidence": evidence, + "ErrCode": 0, + "ErrMsg": "success", + "IosRefundQueryResponse": map[string]any{ + "result_code": resultCode, + "result_info": resultInfo, + "evidence": evidence, + }, }) return b } +// sortedSHA1Hex 微信各类签名的统一算法:参与项按字典序排序后拼接,取 sha1 小写 hex。 +// 用于 GET URL 验证(token/timestamp/nonce)与安全模式的接收/回包签名(多一个 Encrypt)。 +func sortedSHA1Hex(parts ...string) string { + sorted := append([]string(nil), parts...) + sort.Strings(sorted) + h := sha1.Sum([]byte(strings.Join(sorted, ""))) + return hex.EncodeToString(h[:]) +} + // CheckURLSignature 校验消息推送 URL 配置时的 GET 验签(SHA1)。 // token 为配置回调地址时自定义的 Token;通过则返回 true 并原样回显 echostr。 func CheckURLSignature(token, timestamp, nonce, signature string) bool { if token == "" || signature == "" { return false } - arr := []string{token, timestamp, nonce} - sort.Strings(arr) - joined := arr[0] + arr[1] + arr[2] - h := sha1.Sum([]byte(joined)) - return hex.EncodeToString(h[:]) == signature + return sortedSHA1Hex(token, timestamp, nonce) == signature } // VerifyNotifyURL 校验消息推送 URL 配置时的 GET 验签(使用已初始化的 Token)。 diff --git a/pkg/wechat/virtualpay_test.go b/pkg/wechat/virtualpay_test.go index ab0aa8d..c8b2b5f 100644 --- a/pkg/wechat/virtualpay_test.go +++ b/pkg/wechat/virtualpay_test.go @@ -190,6 +190,31 @@ func TestIsOrderRefundedStatus(t *testing.T) { } } +// TestBuildIOSRefundQueryResponse 校验 iOS 退款问询应答结构: +// 必须带 ErrCode/ErrMsg 并包一层 IosRefundQueryResponse,字段名与官方一致。 +// 回归防护:早期实现返回平铺的 {result_code, evidence},Apple 侧无法解析。 +func TestBuildIOSRefundQueryResponse(t *testing.T) { + body := BuildIOSRefundQueryResponse(1, "拒绝退款", "订单 OD1 发放的权益已被使用") + var got struct { + ErrCode int `json:"ErrCode"` + ErrMsg string `json:"ErrMsg"` + Resp struct { + ResultCode int `json:"result_code"` + ResultInfo string `json:"result_info"` + Evidence string `json:"evidence"` + } `json:"IosRefundQueryResponse"` + } + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("unmarshal ios refund query response: %v, body=%s", err, body) + } + if got.ErrCode != 0 || got.ErrMsg != "success" { + t.Fatalf("ErrCode/ErrMsg = %d/%q, want 0/success", got.ErrCode, got.ErrMsg) + } + if got.Resp.ResultCode != 1 || got.Resp.ResultInfo != "拒绝退款" || got.Resp.Evidence == "" { + t.Fatalf("IosRefundQueryResponse mismatch: %+v", got.Resp) + } +} + // TestXPayOrderInfoUnmarshal 校验 query_order 响应解析:订单字段包在 order 子对象内,status 为数字枚举。 // 回归防护:早期实现把字段平铺在顶层且 status 定义为 string,导致反查恒失败、订单永远停在待支付。 func TestXPayOrderInfoUnmarshal(t *testing.T) {