146 lines
5.8 KiB
Go
146 lines
5.8 KiB
Go
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
|
||
}
|