bugfix:支付

This commit is contained in:
2026-09-14 02:39:00 +08:00
parent d87f904e33
commit 714d8ff396
11 changed files with 699 additions and 42 deletions
+18
View File
@@ -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")
+1
View File
@@ -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" // 虚拟支付查单补偿定时任务防重(无参数,全局单实例)
)
+50 -5
View File
@@ -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 按微信规范返回受理成功,避免微信重复回调。
+16 -10
View File
@@ -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,
})
+145
View File
@@ -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
}