Files
zt-closet-backend/internal/service/virtualpay_compensate.go
T
2026-09-14 02:39:00 +08:00

146 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}