bugfix:支付
This commit is contained in:
@@ -73,6 +73,24 @@ func (d *OrderDao) OrderExpireByTime(ctx context.Context, now int64) (int64, err
|
|||||||
return res.RowsAffected, nil
|
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 查询订单
|
// OrderGetByID 按主键 ID 查询订单
|
||||||
func (d *OrderDao) OrderGetByID(ctx context.Context, id int64) (*mysqlmodel.Order, error) {
|
func (d *OrderDao) OrderGetByID(ctx context.Context, id int64) (*mysqlmodel.Order, error) {
|
||||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderGetByID")
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderGetByID")
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ const (
|
|||||||
KeyLockClosetAudit = RedisPrefix + "lock:audit:closet:%d" // target_id,衣柜分享审核防重
|
KeyLockClosetAudit = RedisPrefix + "lock:audit:closet:%d" // target_id,衣柜分享审核防重
|
||||||
KeyLockOutfitAudit = RedisPrefix + "lock:audit:outfit:%d" // target_id,穿搭审核防重
|
KeyLockOutfitAudit = RedisPrefix + "lock:audit:outfit:%d" // target_id,穿搭审核防重
|
||||||
KeyLockOutfitPublic = RedisPrefix + "lock:outfit:public:%s" // outfit_uuid,穿搭公开/取消公开串行化
|
KeyLockOutfitPublic = RedisPrefix + "lock:outfit:public:%s" // outfit_uuid,穿搭公开/取消公开串行化
|
||||||
|
KeyLockVirtualConfirm = RedisPrefix + "lock:virtual:confirm" // 虚拟支付查单补偿定时任务防重(无参数,全局单实例)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -147,12 +147,25 @@ func (h *OrderHandler) RefundNotify(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// XPayVerify 虚拟支付消息推送 URL 配置验证(GET):校验签名并原样回显 echostr。
|
// XPayVerify 虚拟支付消息推送 URL 配置验证(GET):校验签名并原样回显 echostr。
|
||||||
|
// 兼容两种模式:安全模式带 msg_signature 且 echostr 为密文(需解密后回显明文);
|
||||||
|
// 明文模式带 signature,直接回显 echostr。
|
||||||
func (h *OrderHandler) XPayVerify(c *gin.Context) {
|
func (h *OrderHandler) XPayVerify(c *gin.Context) {
|
||||||
timestamp := c.Query("timestamp")
|
timestamp := c.Query("timestamp")
|
||||||
nonce := c.Query("nonce")
|
nonce := c.Query("nonce")
|
||||||
signature := c.Query("signature")
|
|
||||||
echostr := c.Query("echostr")
|
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)
|
c.String(http.StatusOK, echostr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -160,14 +173,46 @@ func (h *OrderHandler) XPayVerify(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// XPayNotify 虚拟支付消息推送回调(POST,公开,按虚拟支付规范返回 ErrCode 应答)。
|
// XPayNotify 虚拟支付消息推送回调(POST,公开,按虚拟支付规范返回 ErrCode 应答)。
|
||||||
|
// 安全模式下包体为密文信封:先验签解密再交给业务层,回包同样加密;明文模式直接透传。
|
||||||
func (h *OrderHandler) XPayNotify(c *gin.Context) {
|
func (h *OrderHandler) XPayNotify(c *gin.Context) {
|
||||||
raw, err := io.ReadAll(c.Request.Body)
|
raw, err := io.ReadAll(c.Request.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Data(http.StatusOK, "application/json; charset=utf-8", wechat.BuildNotifyResponse(false))
|
writeXPayNotifyReply(c, false, false)
|
||||||
return
|
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 按微信规范返回受理成功,避免微信重复回调。
|
// wechatCallbackSuccess 按微信规范返回受理成功,避免微信重复回调。
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ func (s *OrderService) handleGoodsDeliver(ctx context.Context, deliver *wechat.X
|
|||||||
return errcode.DatabaseError
|
return errcode.DatabaseError
|
||||||
}
|
}
|
||||||
if updated {
|
if updated {
|
||||||
s.finalizeVirtualPaidOrder(ctx, order, deliver, raw)
|
s.finalizeVirtualPaidOrder(ctx, order, deliver.WeChatPayInfo.PaidTime, deliver.WeChatPayInfo.TransactionID, raw)
|
||||||
return nil
|
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 {
|
if ok, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, deliver.OutTradeNo, OrderStatusExpired, OrderStatusPaid); uerr != nil {
|
||||||
return errcode.DatabaseError
|
return errcode.DatabaseError
|
||||||
} else if ok {
|
} else if ok {
|
||||||
s.finalizeVirtualPaidOrder(ctx, current, deliver, raw)
|
s.finalizeVirtualPaidOrder(ctx, current, deliver.WeChatPayInfo.PaidTime, deliver.WeChatPayInfo.TransactionID, raw)
|
||||||
}
|
}
|
||||||
case OrderStatusCancelled:
|
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 {
|
if ok, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, deliver.OutTradeNo, OrderStatusCancelled, OrderStatusPaid); uerr != nil {
|
||||||
return errcode.DatabaseError
|
return errcode.DatabaseError
|
||||||
} else if ok {
|
} else if ok {
|
||||||
s.finalizeVirtualPaidOrder(ctx, current, deliver, raw)
|
s.finalizeVirtualPaidOrder(ctx, current, deliver.WeChatPayInfo.PaidTime, deliver.WeChatPayInfo.TransactionID, raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -376,16 +376,21 @@ func (s *OrderService) handleIOSRefundQuery(ctx context.Context, query *wechat.X
|
|||||||
order, err := s.orderDao.OrderGetByOrderNo(ctx, query.OutTradeNo)
|
order, err := s.orderDao.OrderGetByOrderNo(ctx, query.OutTradeNo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "xpay ios refund query: order not found", "out_trade_no", query.OutTradeNo, "error", err)
|
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 {
|
if order.OrderStatus == OrderStatusRefunded {
|
||||||
return wechat.BuildIOSRefundQueryResponse(0, "already refunded")
|
return wechat.BuildIOSRefundQueryResponse(1, "拒绝退款",
|
||||||
|
"订单 "+order.OrderNo+" 已处于已退款状态,不支持重复退款")
|
||||||
}
|
}
|
||||||
// 权益已消耗则拒绝退款,防止「已使用权益后白嫖退款」。
|
// 权益已消耗则拒绝退款,防止「已使用权益后白嫖退款」。
|
||||||
if s.iosRefundBenefitsConsumed(ctx, order) {
|
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 退款问询拒绝策略)。
|
// iosRefundBenefitsConsumed 判断订单发放的权益是否已被消耗(用于 iOS 退款问询拒绝策略)。
|
||||||
@@ -414,10 +419,11 @@ func (s *OrderService) iosRefundBenefitsConsumed(ctx context.Context, order *mys
|
|||||||
}
|
}
|
||||||
|
|
||||||
// finalizeVirtualPaidOrder 虚拟支付发货:记录支付信息、发放权益并发送通知。
|
// 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{
|
_ = s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{
|
||||||
"pay_time": resolvePaidTime(deliver.WeChatPayInfo.PaidTime),
|
"pay_time": resolvePaidTime(paidTime),
|
||||||
"transaction_id": deliver.WeChatPayInfo.TransactionID,
|
"transaction_id": transactionID,
|
||||||
"callback_raw": raw,
|
"callback_raw": raw,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -61,9 +61,10 @@ func main() {
|
|||||||
tokenhub.EnsureTodayFortunes()
|
tokenhub.EnsureTodayFortunes()
|
||||||
tokenhub.StartFortuneScheduler()
|
tokenhub.StartFortuneScheduler()
|
||||||
|
|
||||||
// 启动权益发放补偿与订单超时过期后台任务。
|
// 启动权益发放补偿、订单超时过期与虚拟支付查单补偿后台任务。
|
||||||
service.StartBenefitCompensationScheduler()
|
service.StartBenefitCompensationScheduler()
|
||||||
service.StartOrderExpireScheduler()
|
service.StartOrderExpireScheduler()
|
||||||
|
service.StartVirtualPayConfirmScheduler()
|
||||||
|
|
||||||
router := routers.NewRouter()
|
router := routers.NewRouter()
|
||||||
|
|
||||||
@@ -345,5 +346,8 @@ func setupVirtualPayClient() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
env, _ := wechat.GetVirtualPayEnv()
|
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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,11 +117,12 @@ type WechatPaySettingS struct {
|
|||||||
// VirtualPaySettingS 微信小程序虚拟支付(XPay)配置。
|
// VirtualPaySettingS 微信小程序虚拟支付(XPay)配置。
|
||||||
// 虚拟支付与普通微信支付为独立商户号、独立结算,配置项在 MP 后台「虚拟支付 → 基本配置」获取。
|
// 虚拟支付与普通微信支付为独立商户号、独立结算,配置项在 MP 后台「虚拟支付 → 基本配置」获取。
|
||||||
type VirtualPaySettingS struct {
|
type VirtualPaySettingS struct {
|
||||||
OfferID string // 虚拟支付 OfferID(AppID)
|
OfferID string // 虚拟支付 OfferID(AppID)
|
||||||
EnvAppKey string // 现网 AppKey(env=0)
|
EnvAppKey string // 现网 AppKey(env=0)
|
||||||
SandboxAppKey string // 沙箱 AppKey(env=1,iOS 不支持沙箱,默认不启用)
|
SandboxAppKey string // 沙箱 AppKey(env=1,iOS 不支持沙箱,默认不启用)
|
||||||
Token string // 消息推送验签 Token(配置回调地址时自定义)
|
Token string // 消息推送验签 Token(配置回调地址时自定义)
|
||||||
Enable bool // 是否启用虚拟支付;未开通时置 false,虚拟商品下单返回「虚拟支付未开通」
|
EncodingAESKey string // 消息加解密密钥(43 位;填了即启用「安全模式」纯密文收发,留空为明文模式)
|
||||||
|
Enable bool // 是否启用虚拟支付;未开通时置 false,虚拟商品下单返回「虚拟支付未开通」
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserDefaultQuotaSettingS 新用户注册时初始化的免费额度与赠送会员天数配置。
|
// UserDefaultQuotaSettingS 新用户注册时初始化的免费额度与赠送会员天数配置。
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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(`<xml><Encrypt>abc</Encrypt></xml>`)); err == nil {
|
||||||
|
t.Fatal("xml envelope should fail with a clear hint")
|
||||||
|
}
|
||||||
|
}
|
||||||
+91
-20
@@ -21,6 +21,7 @@ import (
|
|||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -29,6 +30,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -41,8 +43,9 @@ const (
|
|||||||
xpayBaseURL = "https://api.weixin.qq.com"
|
xpayBaseURL = "https://api.weixin.qq.com"
|
||||||
stableTokenURL = "https://api.weixin.qq.com/cgi-bin/stable_token"
|
stableTokenURL = "https://api.weixin.qq.com/cgi-bin/stable_token"
|
||||||
|
|
||||||
xpayQueryOrderPath = "/xpay/query_order"
|
xpayQueryOrderPath = "/xpay/query_order"
|
||||||
xpayRefundOrderPath = "/xpay/refund_order"
|
xpayRefundOrderPath = "/xpay/refund_order"
|
||||||
|
xpayNotifyProvideGoodsPath = "/xpay/notify_provide_goods"
|
||||||
|
|
||||||
// requestVirtualPaymentURI 前端调起支付时计算 paySig 使用的固定 uri。
|
// requestVirtualPaymentURI 前端调起支付时计算 paySig 使用的固定 uri。
|
||||||
requestVirtualPaymentURI = "requestVirtualPayment"
|
requestVirtualPaymentURI = "requestVirtualPayment"
|
||||||
@@ -61,6 +64,10 @@ type virtualPayClient struct {
|
|||||||
env int // 激活环境 0=现网 1=沙箱
|
env int // 激活环境 0=现网 1=沙箱
|
||||||
token string // 消息推送验签 Token(配置回调地址时自定义)
|
token string // 消息推送验签 Token(配置回调地址时自定义)
|
||||||
|
|
||||||
|
// 消息推送加解密:encodingAESKey 为空表示明文模式;aesKey 为预解码的 32 字节密钥。
|
||||||
|
encodingAESKey string
|
||||||
|
aesKey []byte
|
||||||
|
|
||||||
appID string // 获取 access_token 用的小程序 AppID
|
appID string // 获取 access_token 用的小程序 AppID
|
||||||
appSecret string // 小程序 AppSecret
|
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")
|
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 初始化全局虚拟支付客户端。
|
// InitVirtualPayClient 初始化全局虚拟支付客户端。
|
||||||
// 复用 global.WechatMiniSetting 的 AppID/AppSecret 获取 access_token(同一小程序)。
|
// 复用 global.WechatMiniSetting 的 AppID/AppSecret 获取 access_token(同一小程序)。
|
||||||
// 环境选择:只填沙箱 AppKey 走沙箱(env=1,测试不真实扣款);只填现网或两把都填走现网(env=0)。
|
// 环境选择:只填沙箱 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")
|
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{
|
c := &virtualPayClient{
|
||||||
offerID: s.OfferID,
|
offerID: s.OfferID,
|
||||||
appKey: appKey,
|
appKey: appKey,
|
||||||
env: env,
|
env: env,
|
||||||
token: s.Token,
|
token: s.Token,
|
||||||
appID: global.WechatMiniSetting.AppID,
|
encodingAESKey: s.EncodingAESKey,
|
||||||
appSecret: global.WechatMiniSetting.AppSecret,
|
aesKey: aesKey,
|
||||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
appID: global.WechatMiniSetting.AppID,
|
||||||
|
appSecret: global.WechatMiniSetting.AppSecret,
|
||||||
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
}
|
}
|
||||||
|
|
||||||
virtualPayMu.Lock()
|
virtualPayMu.Lock()
|
||||||
@@ -384,6 +418,24 @@ func QueryOrder(ctx context.Context, openid, orderID, wxOrderID string) (*XPayOr
|
|||||||
return out, nil
|
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;作用于激活环境)。
|
// RefundOrder 启动虚拟支付退款任务(仅启动任务,结果需轮询 QueryOrder;作用于激活环境)。
|
||||||
// - leftFee:剩余金额(分),即退款前的订单实付金额;
|
// - leftFee:剩余金额(分),即退款前的订单实付金额;
|
||||||
// - refundFee:本次退款金额(分)。
|
// - refundFee:本次退款金额(分)。
|
||||||
@@ -532,6 +584,12 @@ func ParseNotify(raw []byte) (*XPayNotifyBase, error) {
|
|||||||
if len(raw) == 0 {
|
if len(raw) == 0 {
|
||||||
return nil, errors.New("empty xpay notify body")
|
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{}
|
base := &XPayNotifyBase{}
|
||||||
if err := json.Unmarshal(raw, base); err != nil {
|
if err := json.Unmarshal(raw, base); err != nil {
|
||||||
return nil, fmt.Errorf("parse xpay notify: %w", err)
|
return nil, fmt.Errorf("parse xpay notify: %w", err)
|
||||||
@@ -550,29 +608,42 @@ func BuildNotifyResponse(success bool) []byte {
|
|||||||
return []byte(`{"ErrCode":1,"ErrMsg":"处理失败"}`)
|
return []byte(`{"ErrCode":1,"ErrMsg":"处理失败"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildIOSRefundQueryResponse 构造 iOS 退款问询应答(3 秒内必须返回)。
|
// BuildIOSRefundQueryResponse 构造 iOS 退款问询应答(Apple 最多连续问询 3 次,须 3 秒内返回)。
|
||||||
// resultCode: 0 建议退款、1 拒绝退款;evidence 为决策凭据。
|
// 应答必须包一层 IosRefundQueryResponse,并带 ErrCode/ErrMsg,字段名以官方文档为准:
|
||||||
// 字段名以官方文档为准(result_code / evidence)。
|
// - result_code:0 放过(建议退款)、1 拦截(拒绝退款)
|
||||||
|
// - result_info:结果描述
|
||||||
|
// - evidence:决策凭据(必填,须具体,用于退款审计,不可为空或过于笼统)
|
||||||
|
//
|
||||||
// 使用 json.Marshal 保证字符串转义符合 JSON 规范(避免 fmt %q 的 Go 风格转义)。
|
// 使用 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{
|
b, _ := json.Marshal(map[string]any{
|
||||||
"result_code": resultCode,
|
"ErrCode": 0,
|
||||||
"evidence": evidence,
|
"ErrMsg": "success",
|
||||||
|
"IosRefundQueryResponse": map[string]any{
|
||||||
|
"result_code": resultCode,
|
||||||
|
"result_info": resultInfo,
|
||||||
|
"evidence": evidence,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
return b
|
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)。
|
// CheckURLSignature 校验消息推送 URL 配置时的 GET 验签(SHA1)。
|
||||||
// token 为配置回调地址时自定义的 Token;通过则返回 true 并原样回显 echostr。
|
// token 为配置回调地址时自定义的 Token;通过则返回 true 并原样回显 echostr。
|
||||||
func CheckURLSignature(token, timestamp, nonce, signature string) bool {
|
func CheckURLSignature(token, timestamp, nonce, signature string) bool {
|
||||||
if token == "" || signature == "" {
|
if token == "" || signature == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
arr := []string{token, timestamp, nonce}
|
return sortedSHA1Hex(token, timestamp, nonce) == signature
|
||||||
sort.Strings(arr)
|
|
||||||
joined := arr[0] + arr[1] + arr[2]
|
|
||||||
h := sha1.Sum([]byte(joined))
|
|
||||||
return hex.EncodeToString(h[:]) == signature
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifyNotifyURL 校验消息推送 URL 配置时的 GET 验签(使用已初始化的 Token)。
|
// VerifyNotifyURL 校验消息推送 URL 配置时的 GET 验签(使用已初始化的 Token)。
|
||||||
|
|||||||
@@ -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 为数字枚举。
|
// TestXPayOrderInfoUnmarshal 校验 query_order 响应解析:订单字段包在 order 子对象内,status 为数字枚举。
|
||||||
// 回归防护:早期实现把字段平铺在顶层且 status 定义为 string,导致反查恒失败、订单永远停在待支付。
|
// 回归防护:早期实现把字段平铺在顶层且 status 定义为 string,导致反查恒失败、订单永远停在待支付。
|
||||||
func TestXPayOrderInfoUnmarshal(t *testing.T) {
|
func TestXPayOrderInfoUnmarshal(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user