feat(order): 重构退款全流程并新增申请退款中状态

- 订单状态新增「6 申请退款中」,用户申请退款原子流转并落待审核退款流水
- 退款失败回退订单至已支付并恢复被取消的权益发放任务
- 新增虚拟支付退款查单兜底任务,反查超时订单并自动订正
- 会员包来源额度不叠加,扣减/回退/回收口径统一为只取生效一份
- 退款回收流水按吊销前后有效剩余差值记账,避免无效回收记录
- 同步更新 README 与 Stitch 设计文档
This commit is contained in:
2026-09-16 03:44:09 +08:00
parent 89b59c7b91
commit b94dd6dffb
29 changed files with 2141 additions and 158 deletions
+2 -2
View File
@@ -45,7 +45,7 @@ type OrderResponse struct {
UnitPrice int64 `json:"unit_price"` // 单价(分)
PayAmount int64 `json:"pay_amount"` // 实付(分)
PayChannel int8 `json:"pay_channel"` // 支付渠道 1=微信支付 2=虚拟支付
OrderStatus int8 `json:"order_status"` // 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款
OrderStatus int8 `json:"order_status"` // 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款 6=申请退款中
MemberDays int64 `json:"member_days"`
ExpireTime int64 `json:"expire_time"`
PayTime int64 `json:"pay_time"`
@@ -57,7 +57,7 @@ type OrderResponse struct {
// OrderListRequest 订单列表
type OrderListRequest struct {
PageRequest
Status int8 `form:"status" binding:"omitempty,oneof=1 2 3 4 5"`
Status int8 `form:"status" binding:"omitempty,oneof=1 2 3 4 5 6"`
}
// OrderListResponse 订单列表
+4 -2
View File
@@ -36,6 +36,7 @@ func (d *BenefitRevokeTaskDao) RecoverStale(ctx context.Context, before int64) e
}
// RefundSettleWithRevokeTask 原子写入退款订单终态、取消未执行发放并创建回收任务。
// 前置状态:2=已支付(正常退款)、3=已取消但已付款(竞态兜底)、6=申请退款中(用户已提交退款申请)。
func (d *BenefitRevokeTaskDao) RefundSettleWithRevokeTask(ctx context.Context, orderNo string, refundAmount, refundTime int64) (bool, error) {
settled := false
err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
@@ -46,7 +47,7 @@ func (d *BenefitRevokeTaskDao) RefundSettleWithRevokeTask(ctx context.Context, o
if order.OrderStatus == 5 {
return nil
}
if order.OrderStatus != 2 && order.OrderStatus != 3 {
if order.OrderStatus != 2 && order.OrderStatus != 3 && order.OrderStatus != 6 {
return nil
}
if err := tx.Model(&mysqlmodel.Order{}).Where("id = ?", order.ID).Updates(map[string]any{"order_status": 5, "refund_time": refundTime, "refund_amount": refundAmount}).Error; err != nil {
@@ -55,7 +56,8 @@ func (d *BenefitRevokeTaskDao) RefundSettleWithRevokeTask(ctx context.Context, o
if err := tx.Model(&mysqlmodel.BenefitGrantTask{}).Where("order_no = ? AND status IN ?", orderNo, []int8{0, 2}).Update("status", 4).Error; err != nil {
return err
}
if order.OrderStatus == 2 {
// 2 / 6 表示权益已按订单发放过,需创建回收任务;3 从未发放,跳过避免误扣其它来源的会员天数/额度。
if order.OrderStatus == 2 || order.OrderStatus == 6 {
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&mysqlmodel.BenefitRevokeTask{OrderNo: orderNo, UserID: order.UserID, MaxRetry: 10}).Error; err != nil {
return err
}
+104 -5
View File
@@ -2,10 +2,12 @@ package mysqldao
import (
"context"
"errors"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// OrderDao 订单数据访问
@@ -91,15 +93,34 @@ func (d *OrderDao) OrderListVirtualUnconfirmed(ctx context.Context, since, until
return list, nil
}
// OrderListVirtualRefunding 查询停留在「申请退款中」的虚拟支付订单(updated_at 早于水位),
// 供退款结果查单兜底任务反查微信侧真实退款结果:推送丢失/未识别时避免订单永久卡单。
// 状态字面量:2=虚拟支付 6=申请退款中(与 service 层 PayChannelVirtual/OrderStatusRefunding 一致)。
func (d *OrderDao) OrderListVirtualRefunding(ctx context.Context, beforeUpdatedAt int64, limit int) ([]mysqlmodel.Order, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderListVirtualRefunding")
defer span.End()
var list []mysqlmodel.Order
if err := d.db.WithContext(ctx).
Where("pay_channel = ? AND order_status = ? AND updated_at < ?", 2, 6, beforeUpdatedAt).
Order("id ASC").Limit(limit).Find(&list).Error; err != nil {
span.RecordError(err)
return nil, err
}
return list, nil
}
// OrderListPaidMissingBenefitTasks 查询存在未建权益任务的已支付订单,修复支付回调与任务落库之间的崩溃窗口。
// 除「任务不存在」外,也把「任务被取消(status=4)」视为待修复:订单曾进入退款流程时任务被取消,
// 退款失败回退为已支付后需要重新发放,避免「已付款却无权益」(已退款订单状态为 5,不会被本查询命中)。
func (d *OrderDao) OrderListPaidMissingBenefitTasks(ctx context.Context, limit int) ([]mysqlmodel.Order, error) {
var list []mysqlmodel.Order
err := d.db.WithContext(ctx).Where("order_status = ?", 2).
Where(`(member_days > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=1)) OR
(cos_quota > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=2)) OR
(ai_recommend_quota > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=3)) OR
(closet_share_quota > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=4)) OR
(outfit_share_quota > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=5))`).
Where(`(member_days > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=1 AND t.status<>4)) OR
(cos_quota > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=2 AND t.status<>4)) OR
(ai_recommend_quota > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=3 AND t.status<>4)) OR
(closet_share_quota > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=4 AND t.status<>4)) OR
(outfit_share_quota > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=5 AND t.status<>4))`).
Order("id ASC").Limit(limit).Find(&list).Error
return list, err
}
@@ -162,6 +183,84 @@ func NewRefundFlowLogDao() *RefundFlowLogDao {
return &RefundFlowLogDao{db: global.MysqlEngine}
}
// RefundApplyWithOrderStatus 原子受理退款申请:锁定订单行并校验「2 已支付」,
// 将订单流转为「6 申请退款中」,同时新建退款流水或复用上次失败的流水(换新退款单号并重置为待处理)。
// allowed=false 表示订单状态不满足(非已支付/已被并发受理)或已有进行中的退款流水,
// 此时不做任何写入,调用方应返回「当前状态不允许退款」。
// 状态字面量:0=待处理(流水) 2=已支付 3=退款失败(流水) 6=申请退款中(与 service 层常量一致)。
func (d *RefundFlowLogDao) RefundApplyWithOrderStatus(ctx context.Context, orderNo, refundNo, reason string, applyTime int64) (*mysqlmodel.RefundFlowLog, bool, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.RefundApplyWithOrderStatus")
defer span.End()
var flow *mysqlmodel.RefundFlowLog
allowed := false
// 订单行锁串行化同一订单的并发申请:仅首个请求能把 2 流转为 6,其余请求读到 6 后直接拒绝。
err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order mysqlmodel.Order
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("order_no = ?", orderNo).First(&order).Error; err != nil {
return err
}
if order.OrderStatus != 2 {
return nil
}
var existing mysqlmodel.RefundFlowLog
gerr := tx.Where("order_no = ?", orderNo).First(&existing).Error
switch {
case gerr != nil && !errors.Is(gerr, gorm.ErrRecordNotFound):
return gerr
case gerr == nil && existing.Status != 3:
// 已有待处理 / 退款中 / 已成功的退款流水:不允许重复申请。
return nil
case gerr == nil:
// 失败重试:复用旧记录,换新退款单号并重置为待处理。
if err := tx.Model(&mysqlmodel.RefundFlowLog{}).Where("id = ?", existing.ID).Updates(map[string]any{
"refund_no": refundNo,
"refund_amount": order.PayAmount,
"refund_reason": reason,
"status": 0,
"apply_time": applyTime,
"fail_reason": "",
}).Error; err != nil {
return err
}
existing.RefundNo = refundNo
existing.RefundAmount = order.PayAmount
existing.RefundReason = reason
existing.Status = 0
existing.ApplyTime = applyTime
existing.FailReason = ""
flow = &existing
default:
created := &mysqlmodel.RefundFlowLog{
OrderID: order.ID,
OrderNo: order.OrderNo,
UserID: order.UserID,
RefundNo: refundNo,
RefundAmount: order.PayAmount,
RefundReason: reason,
Status: 0,
ApplyTime: applyTime,
}
if err := tx.Create(created).Error; err != nil {
return err
}
flow = created
}
if err := tx.Model(&mysqlmodel.Order{}).Where("id = ?", order.ID).Update("order_status", 6).Error; err != nil {
return err
}
allowed = true
return nil
})
if err != nil {
span.RecordError(err)
return nil, false, err
}
return flow, allowed, nil
}
// RefundFlowLogCreate 创建退款流水
func (d *RefundFlowLogDao) RefundFlowLogCreate(ctx context.Context, log *mysqlmodel.RefundFlowLog) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.RefundFlowLogCreate")
+225
View File
@@ -0,0 +1,225 @@
package mysqldao
import (
"context"
"testing"
"github.com/DATA-DOG/go-sqlmock"
)
// TestRefundApplyWithOrderStatusCreatesFlowAndMarksRefunding 受理成功:订单 2 已支付 -> 6 申请退款中,并新建退款流水。
func TestRefundApplyWithOrderStatusCreatesFlowAndMarksRefunding(t *testing.T) {
base, mock := newQuotaTestDAO(t)
dao := &RefundFlowLogDao{db: base.db}
mock.ExpectBegin()
mock.ExpectQuery("SELECT .* FROM `orders` .*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status", "pay_amount"}).
AddRow(8, "ORDER-1", 7, 2, 990))
mock.ExpectQuery("SELECT .* FROM `refund_flow_logs`").
WillReturnRows(sqlmock.NewRows([]string{"id"}))
mock.ExpectExec("INSERT INTO `refund_flow_logs`").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("UPDATE `orders` SET").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
flow, allowed, err := dao.RefundApplyWithOrderStatus(context.Background(), "ORDER-1", "RF-NEW", "不想要了", 1234)
if err != nil || !allowed {
t.Fatalf("allowed=%v err=%v", allowed, err)
}
if flow == nil || flow.RefundNo != "RF-NEW" || flow.Status != 0 || flow.RefundAmount != 990 || flow.UserID != 7 {
t.Fatalf("unexpected flow: %+v", flow)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// TestRefundApplyWithOrderStatusRejectsNonPaidOrder 订单已处于申请退款中(6)时拒绝受理且不写任何数据。
func TestRefundApplyWithOrderStatusRejectsNonPaidOrder(t *testing.T) {
base, mock := newQuotaTestDAO(t)
dao := &RefundFlowLogDao{db: base.db}
mock.ExpectBegin()
mock.ExpectQuery("SELECT .* FROM `orders` .*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status"}).
AddRow(8, "ORDER-1", 7, 6))
mock.ExpectCommit()
flow, allowed, err := dao.RefundApplyWithOrderStatus(context.Background(), "ORDER-1", "RF-NEW", "重复申请", 1234)
if err != nil || allowed || flow != nil {
t.Fatalf("allowed=%v flow=%v err=%v", allowed, flow, err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// TestRefundApplyWithOrderStatusReusesFailedFlow 上次退款失败:复用旧流水并换新退款单号、重置为待处理。
func TestRefundApplyWithOrderStatusReusesFailedFlow(t *testing.T) {
base, mock := newQuotaTestDAO(t)
dao := &RefundFlowLogDao{db: base.db}
mock.ExpectBegin()
mock.ExpectQuery("SELECT .* FROM `orders` .*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status", "pay_amount"}).
AddRow(8, "ORDER-1", 7, 2, 990))
mock.ExpectQuery("SELECT .* FROM `refund_flow_logs`").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "refund_no", "status"}).
AddRow(5, "ORDER-1", 7, "RF-OLD", 3))
mock.ExpectExec("UPDATE `refund_flow_logs` SET").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("UPDATE `orders` SET").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
flow, allowed, err := dao.RefundApplyWithOrderStatus(context.Background(), "ORDER-1", "RF-NEW", "再次申请", 2222)
if err != nil || !allowed {
t.Fatalf("allowed=%v err=%v", allowed, err)
}
if flow == nil || flow.ID != 5 || flow.RefundNo != "RF-NEW" || flow.Status != 0 || flow.ApplyTime != 2222 {
t.Fatalf("unexpected flow: %+v", flow)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// TestRefundApplyWithOrderStatusRejectsInProgressFlow 已有待处理/退款中/成功的流水时拒绝重复申请。
func TestRefundApplyWithOrderStatusRejectsInProgressFlow(t *testing.T) {
base, mock := newQuotaTestDAO(t)
dao := &RefundFlowLogDao{db: base.db}
mock.ExpectBegin()
mock.ExpectQuery("SELECT .* FROM `orders` .*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status"}).
AddRow(8, "ORDER-1", 7, 2))
mock.ExpectQuery("SELECT .* FROM `refund_flow_logs`").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "refund_no", "status"}).
AddRow(5, "ORDER-1", "RF-OLD", 0))
mock.ExpectCommit()
flow, allowed, err := dao.RefundApplyWithOrderStatus(context.Background(), "ORDER-1", "RF-NEW", "重复申请", 1234)
if err != nil || allowed || flow != nil {
t.Fatalf("allowed=%v flow=%v err=%v", allowed, flow, err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// TestOrderStatusRevertRefundingToPaid 退款失败回退依赖的条件流转:6 -> 2 仅在订单仍为 6 时生效。
func TestOrderStatusRevertRefundingToPaid(t *testing.T) {
for _, tc := range []struct {
name string
rows int64
want bool
}{
{name: "still_refunding", rows: 1, want: true},
{name: "already_settled", rows: 0, want: false},
} {
t.Run(tc.name, func(t *testing.T) {
base, mock := newQuotaTestDAO(t)
dao := &OrderDao{db: base.db}
// gorm 默认把单条写语句包在事务里执行。
mock.ExpectBegin()
mock.ExpectExec("UPDATE `orders` SET .* WHERE \\(order_no = \\? AND order_status = \\?\\).*").
WillReturnResult(sqlmock.NewResult(0, tc.rows))
mock.ExpectCommit()
ok, err := dao.OrderUpdateStatusByOrderNo(context.Background(), "ORDER-1", 6, 2)
if err != nil || ok != tc.want {
t.Fatalf("ok=%v err=%v, want %v", ok, err, tc.want)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
})
}
}
// TestOrderListVirtualRefunding 退款查单兜底扫描:仅取「虚拟支付 + 申请退款中 + updated_at 早于水位」的订单。
func TestOrderListVirtualRefunding(t *testing.T) {
base, mock := newQuotaTestDAO(t)
dao := &OrderDao{db: base.db}
mock.ExpectQuery("SELECT .* FROM `orders` WHERE .*pay_channel = \\? AND order_status = \\? AND updated_at < \\?.*").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status", "pay_channel", "updated_at"}).
AddRow(8, "ORDER-1", 7, 6, 2, 900))
orders, err := dao.OrderListVirtualRefunding(context.Background(), 1000, 50)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(orders) != 1 || orders[0].OrderNo != "ORDER-1" || orders[0].OrderStatus != 6 {
t.Fatalf("unexpected orders: %+v", orders)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// TestRefundSettlementAcceptsRefundingOrder 退款成功结算放行状态 6,并按「权益是否已发放」决定是否创建回收任务。
func TestRefundSettlementAcceptsRefundingOrder(t *testing.T) {
for _, tc := range []struct {
name string
orderStatus int8
wantRevoke bool
}{
{name: "refunding_creates_revoke_task", orderStatus: 6, wantRevoke: true},
{name: "cancelled_paid_skips_revoke_task", orderStatus: 3, wantRevoke: false},
} {
t.Run(tc.name, func(t *testing.T) {
base, mock := newQuotaTestDAO(t)
dao := &BenefitRevokeTaskDao{db: base.db}
mock.ExpectBegin()
mock.ExpectQuery("SELECT .* FROM `orders` .*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status"}).
AddRow(8, "ORDER-1", 7, tc.orderStatus))
mock.ExpectExec("UPDATE `orders` SET").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("UPDATE `benefit_grant_tasks` SET").WillReturnResult(sqlmock.NewResult(0, 2))
if tc.wantRevoke {
mock.ExpectExec("INSERT INTO `benefit_revoke_tasks`").WillReturnResult(sqlmock.NewResult(3, 1))
}
mock.ExpectCommit()
settled, err := dao.RefundSettleWithRevokeTask(context.Background(), "ORDER-1", 990, 1234)
if err != nil || !settled {
t.Fatalf("settled=%v err=%v", settled, err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
})
}
}
// TestRefundSettlementSkipsAlreadyRefunded 已退款订单重复回调直接跳过(幂等,不重复回退权益)。
func TestRefundSettlementSkipsAlreadyRefunded(t *testing.T) {
base, mock := newQuotaTestDAO(t)
dao := &BenefitRevokeTaskDao{db: base.db}
mock.ExpectBegin()
mock.ExpectQuery("SELECT .* FROM `orders` .*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status"}).
AddRow(8, "ORDER-1", 7, 5))
mock.ExpectCommit()
settled, err := dao.RefundSettleWithRevokeTask(context.Background(), "ORDER-1", 990, 1234)
if err != nil || settled {
t.Fatalf("settled=%v err=%v", settled, err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// TestRefundSettlementRejectsExpiredOrder 未支付流转而来的终态(如已过期 4)不允许结算为已退款。
func TestRefundSettlementRejectsExpiredOrder(t *testing.T) {
base, mock := newQuotaTestDAO(t)
dao := &BenefitRevokeTaskDao{db: base.db}
mock.ExpectBegin()
mock.ExpectQuery("SELECT .* FROM `orders` .*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status"}).
AddRow(8, "ORDER-1", 7, 4))
mock.ExpectCommit()
settled, err := dao.RefundSettleWithRevokeTask(context.Background(), "ORDER-1", 990, 1234)
if err != nil || settled {
t.Fatalf("settled=%v err=%v", settled, err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ type Order struct {
TotalAmount int64 `gorm:"column:total_amount"` // 订单总金额(分)
PayAmount int64 `gorm:"column:pay_amount"` // 实付金额(分)
PayChannel int8 `gorm:"column:pay_channel"` // 支付渠道 1=微信支付
OrderStatus int8 `gorm:"column:order_status"` // 订单状态 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款
OrderStatus int8 `gorm:"column:order_status"` // 订单状态 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款 6=申请退款中
TransactionID string `gorm:"column:transaction_id"` // 微信支付流水号
MemberDays int64 `gorm:"column:member_days"` // 购买的会员天数(快照)
GoodsType int8 `gorm:"column:goods_type"` // 商品类型(快照)
+41
View File
@@ -35,6 +35,47 @@ func VirtualPayUnpaidDel(ctx context.Context, orderNo string) error {
return nil
}
// VirtualRefundCheckedSetNX 标记某订单的退款结果本轮已完成反查(TTL 内不重复查询),返回是否为首次标记。
func VirtualRefundCheckedSetNX(ctx context.Context, orderNo string, ttl time.Duration) (bool, error) {
ctx, span := global.Tracer.Start(ctx, "redisdao.VirtualRefundCheckedSetNX")
defer span.End()
key := fmt.Sprintf(redismodel.KeyVirtualRefundChecked, orderNo)
ok, err := global.RedisEngine.SetNX(ctx, key, 1, ttl).Result()
if err != nil {
span.RecordError(err)
return false, err
}
return ok, nil
}
// VirtualRefundCheckedDel 撤销「已核对」标记:查单失败时调用,让下一轮立即重试而非等待 TTL 过期。
func VirtualRefundCheckedDel(ctx context.Context, orderNo string) error {
ctx, span := global.Tracer.Start(ctx, "redisdao.VirtualRefundCheckedDel")
defer span.End()
key := fmt.Sprintf(redismodel.KeyVirtualRefundChecked, orderNo)
if err := global.RedisEngine.Del(ctx, key).Err(); err != nil {
span.RecordError(err)
return err
}
return nil
}
// VirtualRefundStuckAlertSetNX 标记某订单的「退款长期未完成」告警已发出(TTL 内不重复告警),返回是否为首次。
func VirtualRefundStuckAlertSetNX(ctx context.Context, orderNo string, ttl time.Duration) (bool, error) {
ctx, span := global.Tracer.Start(ctx, "redisdao.VirtualRefundStuckAlertSetNX")
defer span.End()
key := fmt.Sprintf(redismodel.KeyVirtualRefundStuckAlert, orderNo)
ok, err := global.RedisEngine.SetNX(ctx, key, 1, ttl).Result()
if err != nil {
span.RecordError(err)
return false, err
}
return ok, nil
}
// VirtualPayUnpaidBatchGet 批量判断哪些订单命中负缓存。
// 用一次 MGET 代替 N 次 GET,避免逐单往返 Redis 放大单轮耗时。
func VirtualPayUnpaidBatchGet(ctx context.Context, orderNos []string) (map[string]bool, error) {
+10 -9
View File
@@ -2,13 +2,14 @@ package redismodel
// 分布式锁与幂等模块:对齐 mysqlmodel 的 Order、AIRecommend、AIHoroscope、审核等业务。
const (
KeyLockOrderCreate = RedisPrefix + "lock:order:create:%d" // order_id,下单防重
KeyLockPayCallback = RedisPrefix + "lock:pay:callback:%s" // order_no,支付回调幂等
KeyLockRefundCallback = RedisPrefix + "lock:refund:callback:%s" // refund_no,退款回调幂等
KeyLockAIRecommend = RedisPrefix + "lock:ai:recommend:%d" // user_id,AI推荐活跃位:值为进行中的 task_id,兼作防并发
KeyLockAIHoroscope = RedisPrefix + "lock:ai:horoscope:%d:%s" // user_id:zodiac,AI星座防并发
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" // 虚拟支付查单补偿定时任务防重(无参数,全局单实例)
KeyLockOrderCreate = RedisPrefix + "lock:order:create:%d" // order_id,下单防重
KeyLockPayCallback = RedisPrefix + "lock:pay:callback:%s" // order_no,支付回调幂等
KeyLockRefundCallback = RedisPrefix + "lock:refund:callback:%s" // refund_no,退款回调幂等
KeyLockAIRecommend = RedisPrefix + "lock:ai:recommend:%d" // user_id,AI推荐活跃位:值为进行中的 task_id,兼作防并发
KeyLockAIHoroscope = RedisPrefix + "lock:ai:horoscope:%d:%s" // user_id:zodiac,AI星座防并发
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" // 虚拟支付查单补偿定时任务防重(无参数,全局单实例)
KeyLockVirtualRefundConfirm = RedisPrefix + "lock:virtual:refund:confirm" // 虚拟支付退款查单兜底定时任务防重(无参数,全局单实例)
)
+7
View File
@@ -6,4 +6,11 @@ const (
// 命中表示最近一次 query_order 反查结果为未支付,本轮跳过该订单,
// 避免大量「下单后不付款」的积压订单长期占满扫描名额,把真正已支付的订单挤出去。
KeyVirtualPayUnpaid = RedisPrefix + "xpay:unpaid:%s"
// KeyVirtualRefundChecked 退款查单兜底的「已核对」标记:order_no。
// 命中表示 TTL 内已向微信反查过该订单的退款结果,本轮跳过,避免每轮重复调用 query_order。
KeyVirtualRefundChecked = RedisPrefix + "xpay:refund:checked:%s"
// KeyVirtualRefundStuckAlert 「退款长期未完成」告警去重标记:order_no,TTL 内只告警一次,避免刷屏。
KeyVirtualRefundStuckAlert = RedisPrefix + "xpay:refund:stuck:%s"
)
+1
View File
@@ -152,6 +152,7 @@ const (
OrderStatusCancelled int8 = 3 // 已取消
OrderStatusExpired int8 = 4 // 已过期
OrderStatusRefunded int8 = 5 // 已退款
OrderStatusRefunding int8 = 6 // 申请退款中(已受理退款申请,待运营审核并手动发起退款)
PayChannelWechat int8 = 1 // 微信支付(普通 JSAPI)
PayChannelVirtual int8 = 2 // 微信虚拟支付(XPay)
+193
View File
@@ -0,0 +1,193 @@
package service
import (
"context"
"testing"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
"github.com/DATA-DOG/go-sqlmock"
"gorm.io/gorm"
)
// setupQuotaTest 固定免费额度配置为内置默认值(Cos 20 / AI 3 / 分享 10 / 衣柜 5),保证断言确定。
func setupQuotaTest(t *testing.T) sqlmock.Sqlmock {
t.Helper()
old := global.UserDefaultQuotaSetting
global.UserDefaultQuotaSetting = nil
t.Cleanup(func() { global.UserDefaultQuotaSetting = old })
return setupOrderQuotaTest(t)
}
func TestEffectiveQuotaMemberGrantsNotStacked(t *testing.T) {
mock := setupQuotaTest(t)
// Cos 永久额度:免费 20 已用 0;会员包同额两份只计一份,加量包/体验额度仍叠加。
mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(0))
grants := []mysqlmodel.QuotaGrant{
memberGrantFixture(1, 100, 10), // 月度包:生效份(同额时保留正在使用的一份)
memberGrantFixture(2, 100, 0), // 年度包:不叠加,不计入
otherGrantFixture(3, QuotaGrantSourceAddon, 5, 2), // 加量包:叠加
otherGrantFixture(4, QuotaGrantSourceTrial, 7, 0), // 体验额度:叠加
}
eff := effectiveQuotaFromGrants(context.Background(), 7, QuotaTypeCos, grants)
// 免费 20 + 会员包 100 + 加量包 5 + 体验 7 = 132;已用 0 + 10 + 2 + 0 = 12。
if eff.Total != 132 || eff.Used != 12 || eff.Remain != 120 {
t.Fatalf("cos quota = %+v, want total=132 used=12 remain=120", eff)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestEffectiveQuotaDailyGrantsNotStacked(t *testing.T) {
mock := setupQuotaTest(t)
// AI 每日额度:免费 3,当日已用 1,会员包同额两份只计一份,广告激励仍叠加。
mock.ExpectQuery("SELECT .* FROM `user_daily_usages`").WillReturnRows(sqlmock.NewRows([]string{"ai_recommend_count"}).AddRow(1))
grants := []mysqlmodel.QuotaGrant{
memberGrantFixture(1, 50, 0),
memberGrantFixture(2, 50, 0),
otherGrantFixture(3, QuotaGrantSourceAdReward, 10, 0),
}
eff := effectiveQuotaFromGrants(context.Background(), 7, QuotaTypeAI, grants)
// 免费 3 + 会员包 50 + 广告 10 = 63;当日已用 1。
if eff.Total != 63 || eff.Used != 1 || eff.Remain != 62 {
t.Fatalf("ai quota = %+v, want total=63 used=1 remain=62", eff)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestConsumeCosQuotaOnlyConsumesEffectiveMemberGrant(t *testing.T) {
mock := setupQuotaTest(t)
future := nowUnix() + 86400
// 有效凭证:加量包(先到期先用)→ 月度包(生效份)→ 年度包(未生效,不得扣减)。
mock.ExpectQuery("SELECT .* FROM `users`").WillReturnRows(sqlmock.NewRows([]string{"id", "vip_expire"}).AddRow(7, nowUnix()+86400))
mock.ExpectBegin()
mock.ExpectQuery("SELECT .* FROM `quota_grants`.*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "source", "amount", "used", "expire_time"}).
AddRow(3, QuotaGrantSourceAddon, 10, 0, future).
AddRow(1, QuotaGrantSourceMember, 100, 30, 0).
AddRow(2, QuotaGrantSourceMember, 100, 0, 0))
mock.ExpectExec("UPDATE .*`quota_grants`.*").WillReturnResult(sqlmock.NewResult(0, 1)) // 加量包 +10
mock.ExpectExec("UPDATE .*`quota_grants`.*").WillReturnResult(sqlmock.NewResult(0, 1)) // 月度包 +5(年度包不参与扣减)
mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(0))
mock.ExpectQuery("SELECT .* FROM `quota_grants`").
WillReturnRows(sqlmock.NewRows([]string{"id", "source", "amount", "used", "expire_time"}).
AddRow(3, QuotaGrantSourceAddon, 10, 10, future).
AddRow(1, QuotaGrantSourceMember, 100, 35, 0).
AddRow(2, QuotaGrantSourceMember, 100, 0, 0))
mock.ExpectExec("INSERT INTO `quota_flow_logs`").WillReturnResult(sqlmock.NewResult(9, 1))
mock.ExpectCommit()
if err := consumeCosQuota(context.Background(), 7, 15, 123, "上传衣物"); err != nil {
t.Fatal(err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestRefundCosQuotaOnlyRefundsEffectiveMemberGrant(t *testing.T) {
mock := setupQuotaTest(t)
mock.ExpectBegin()
// 免费额度已用 5,不足整体回退 10。
mock.ExpectExec("UPDATE .*`user_quotas`.*").WillReturnResult(sqlmock.NewResult(0, 0))
// 非会员包凭证无已用。
mock.ExpectQuery("SELECT .* FROM `quota_grants`.*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "source", "amount", "used", "expire_time"}))
// 会员包凭证:月度包为生效份(used=30),年度包未生效。
mock.ExpectQuery("SELECT .* FROM `quota_grants`.*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}).
AddRow(1, QuotaTypeCos, QuotaGrantSourceMember, 100, 30, 0).
AddRow(2, QuotaTypeCos, QuotaGrantSourceMember, 100, 0, 0))
mock.ExpectExec("UPDATE .*`quota_grants`.*").WillReturnResult(sqlmock.NewResult(0, 1)) // 月度包 -10
mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(5))
mock.ExpectQuery("SELECT .* FROM `quota_grants`").
WillReturnRows(sqlmock.NewRows([]string{"id", "source", "amount", "used", "expire_time"}).
AddRow(1, QuotaGrantSourceMember, 100, 20, 0).
AddRow(2, QuotaGrantSourceMember, 100, 0, 0))
mock.ExpectExec("INSERT INTO `quota_flow_logs`").WillReturnResult(sqlmock.NewResult(9, 1))
mock.ExpectCommit()
vipExpire := nowUnix() + 86400
if err := global.MysqlEngine.WithContext(context.Background()).Transaction(func(tx *gorm.DB) error {
return refundCosQuotaTx(context.Background(), tx, 7, vipExpire, 10, 123, "上传失败回退")
}); err != nil {
t.Fatal(err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestBuildQuotaRevokeFlowsSkipsNonEffectiveMemberGrant(t *testing.T) {
mock := setupQuotaTest(t)
// 订单凭证是年度包(未生效),月度包(生效份)来自其他订单:
// 吊销年度包不改变有效剩余,不应写回收流水。
mock.ExpectQuery("SELECT .* FROM `quota_grants`").
WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}).
AddRow(2, QuotaTypeCos, QuotaGrantSourceMember, 100, 0, 0))
mock.ExpectQuery("SELECT .* FROM `users`").WillReturnRows(sqlmock.NewRows([]string{"id", "vip_expire"}).AddRow(7, nowUnix()+86400))
mock.ExpectQuery("SELECT .* FROM `quota_grants`").
WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}).
AddRow(1, QuotaTypeCos, QuotaGrantSourceMember, 100, 30, 0).
AddRow(2, QuotaTypeCos, QuotaGrantSourceMember, 100, 0, 0))
mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(0))
mock.ExpectQuery("SELECT .* FROM `users`").WillReturnRows(sqlmock.NewRows([]string{"id", "vip_expire"}).AddRow(7, nowUnix()+86400))
mock.ExpectQuery("SELECT .* FROM `quota_grants`").
WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}).
AddRow(1, QuotaTypeCos, QuotaGrantSourceMember, 100, 30, 0).
AddRow(2, QuotaTypeCos, QuotaGrantSourceMember, 100, 0, 0))
mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(0))
flows, err := NewOrderService().buildQuotaRevokeFlows(context.Background(), &mysqlmodel.Order{OrderNo: "O1", UserID: 7})
if err != nil {
t.Fatal(err)
}
if len(flows) != 0 {
t.Fatalf("want no revoke flows, got %d", len(flows))
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestBuildQuotaRevokeFlowsRecordsEffectiveGrantRevoke(t *testing.T) {
mock := setupQuotaTest(t)
// 订单凭证即生效份(月度包 100/30):吊销前有效剩余 20+70=90,吊销后仅剩免费 20,
// 回收流水变更量应为 -70。
mock.ExpectQuery("SELECT .* FROM `quota_grants`").
WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}).
AddRow(1, QuotaTypeCos, QuotaGrantSourceMember, 100, 30, 0))
mock.ExpectQuery("SELECT .* FROM `users`").WillReturnRows(sqlmock.NewRows([]string{"id", "vip_expire"}).AddRow(7, nowUnix()+86400))
mock.ExpectQuery("SELECT .* FROM `quota_grants`").
WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}).
AddRow(1, QuotaTypeCos, QuotaGrantSourceMember, 100, 30, 0))
mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(0))
mock.ExpectQuery("SELECT .* FROM `users`").WillReturnRows(sqlmock.NewRows([]string{"id", "vip_expire"}).AddRow(7, nowUnix()+86400))
mock.ExpectQuery("SELECT .* FROM `quota_grants`").
WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}).
AddRow(1, QuotaTypeCos, QuotaGrantSourceMember, 100, 30, 0))
mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(0))
flows, err := NewOrderService().buildQuotaRevokeFlows(context.Background(), &mysqlmodel.Order{OrderNo: "O2", UserID: 7})
if err != nil {
t.Fatal(err)
}
if len(flows) != 1 {
t.Fatalf("want 1 revoke flow, got %d", len(flows))
}
f := flows[0]
if f.ChangeNum != -70 || f.OldValue != 90 || f.NewValue != 20 || f.QuotaType != QuotaTypeCos {
t.Fatalf("flow = %+v, want change=-70 old=90 new=20", f)
}
}
+76 -52
View File
@@ -326,52 +326,25 @@ func (s *OrderService) RefundApply(ctx context.Context, userID int64, request *d
return nil, errcode.RefundNotAllowed
}
// 已有退款流水则按状态处理:非失败不允许重复申请;失败则复用旧记录重新申请。
existing, gerr := s.refundDao.RefundFlowLogGetByOrderNo(ctx, request.OrderNo)
if gerr != nil && !isNotFound(gerr) {
return nil, errcode.DatabaseError
}
if existing != nil && existing.Status != RefundStatusFailed {
return nil, errcode.RefundNotAllowed
}
refundNo := genRefundNo()
applyTime := nowUnix()
if existing != nil {
// 失败重试:复用旧记录,更新退款单号与状态。
if err := s.refundDao.RefundFlowLogUpdateStatus(ctx, existing.RefundNo, map[string]any{
"refund_no": refundNo,
"refund_amount": order.PayAmount,
"refund_reason": request.Reason,
"status": RefundStatusPending,
"apply_time": applyTime,
"fail_reason": "",
}); err != nil {
return nil, errcode.RefundApplyFailed
}
} else {
// 新建退款流水(order_no 唯一索引兜底并发,冲突即已有申请)。
flow := &mysqlmodel.RefundFlowLog{
OrderID: order.ID,
OrderNo: order.OrderNo,
UserID: userID,
RefundNo: refundNo,
RefundAmount: order.PayAmount,
RefundReason: request.Reason,
Status: RefundStatusPending,
ApplyTime: applyTime,
}
if err := s.refundDao.RefundFlowLogCreate(ctx, flow); err != nil {
if isDuplicateKey(err) {
return nil, errcode.RefundNotAllowed
}
return nil, errcode.RefundApplyFailed
}
// 受理与订单状态流转原子完成:订单 2 已支付 -> 6 申请退款中,并落退款流水
// (已有失败流水则复用旧记录换新退款单号)。订单行锁兜底并发与重复申请。
if _, allowed, err := s.refundDao.RefundApplyWithOrderStatus(ctx, order.OrderNo, refundNo, request.Reason, applyTime); err != nil {
slog.ErrorContext(ctx, "refund apply failed", "order_no", order.OrderNo, "user_id", userID, "error", err)
return nil, errcode.RefundApplyFailed
} else if !allowed {
slog.WarnContext(ctx, "refund apply rejected: order not paid or refund already in progress",
"order_no", order.OrderNo, "user_id", userID, "order_status", order.OrderStatus)
return nil, errcode.RefundNotAllowed
}
// 人工审核:不再自动调用微信退款,仅创建待审核退款申请,避免自动退款造成权益未回退的资损。
// 运营审核通过后在微信后台手动发起退款,微信退款回调 RefundNotify 会更新订单与流水状态。
slog.InfoContext(ctx, "refund applied, order marked refunding",
"order_no", order.OrderNo, "user_id", userID, "refund_no", refundNo,
"refund_amount", order.PayAmount, "order_status", OrderStatusRefunding)
return &dto.RefundResponse{
RefundNo: refundNo,
RefundAmount: order.PayAmount,
@@ -446,11 +419,23 @@ func (s *OrderService) RefundNotify(ctx context.Context, request *http.Request)
slog.InfoContext(ctx, "refund notify: refund success settled",
"order_no", order.OrderNo, "out_refund_no", data.OutRefundNo, "refund_amount", flow.RefundAmount)
} else if flow.Status != RefundStatusSuccess {
// 退款失败(微信返回 CLOSED/ABNORMAL 等):先把订单回退为「2 已支付」,允许用户重新申请;
// 顺序上先回退订单再落流水失败态,任一步失败时微信重试仍会回到本分支(流水尚未置失败),
// 不会出现「流水已失败但订单卡在 6 申请退款中」的死角。
reverted, rerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, data.OutTradeNo, OrderStatusRefunding, OrderStatusPaid)
if rerr != nil {
slog.ErrorContext(ctx, "refund notify: revert order status failed",
"out_trade_no", data.OutTradeNo, "out_refund_no", data.OutRefundNo, "error", rerr)
return errcode.DatabaseError
}
if _, err := s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, data.OutRefundNo, flow.Status, map[string]any{"status": RefundStatusFailed, "fail_reason": "微信退款未成功:" + data.RefundStatus, "callback_raw": data.Raw}); err != nil {
slog.ErrorContext(ctx, "refund notify: mark refund failed status failed",
"out_refund_no", data.OutRefundNo, "refund_status", data.RefundStatus, "error", err)
return errcode.DatabaseError
}
slog.WarnContext(ctx, "refund notify: refund failed, order reverted to paid",
"order_no", data.OutTradeNo, "out_refund_no", data.OutRefundNo,
"refund_status", data.RefundStatus, "order_reverted", reverted)
}
return nil
}
@@ -592,6 +577,21 @@ func (s *OrderService) grantBenefitIdempotent(ctx context.Context, order *mysqlm
return
}
s.executeGrant(ctx, existing)
return
}
if existing.Status == BenefitTaskCancelled {
// 订单曾进入退款流程导致任务被取消,但订单已回到已支付(如退款失败回退):
// 恢复为待发放并立即尝试,避免「已付款却拿不到权益」;若订单实际仍为已退款,
// executeGrant 内的订单状态校验会重新取消该任务(幂等兜底)。
if rerr := taskDao.BenefitGrantTaskReset(ctx, existing.ID); rerr != nil {
slog.WarnContext(ctx, "reset cancelled benefit grant task failed",
"order_no", order.OrderNo, "benefit_type", benefitType, "error", rerr)
return
}
slog.InfoContext(ctx, "revive cancelled benefit grant task for paid order",
"order_no", order.OrderNo, "benefit_type", benefitType, "task_id", existing.ID)
s.executeGrant(ctx, existing)
return
}
return // pending 交由定时任务处理。
}
@@ -814,7 +814,9 @@ func (s *OrderService) revokeOrderBenefits(ctx context.Context, order *mysqlmode
}
// buildQuotaRevokeFlows 构造退款回收流水:按该订单的额度凭证逐类记录回收量。
// 已用部分不追回,故变更量只记未用量 (amount - used);已全部用掉的凭证不写流水。
// 记账口径为「吊销前有效剩余 vs 排除该订单凭证后重算的有效剩余」的差值,与额度聚合规则
// (会员包来源不叠加、只取一份)保持一致:已用部分不追回;吊销后有效剩余不变的凭证
// (例如被其他生效会员包覆盖的重复凭证)不写流水,避免出现用户实际感知不到的回收记录。
func (s *OrderService) buildQuotaRevokeFlows(ctx context.Context, order *mysqlmodel.Order) ([]*mysqlmodel.QuotaFlowLog, error) {
grants, err := mysqldao.NewQuotaGrantDao().QuotaGrantListByRefID(ctx, order.OrderNo)
if err != nil {
@@ -823,34 +825,56 @@ func (s *OrderService) buildQuotaRevokeFlows(ctx context.Context, order *mysqlmo
}
flows := make([]*mysqlmodel.QuotaFlowLog, 0, len(grants))
// 每类额度的当前有效剩余,逐张凭证递减(同一订单同类凭证通常只有一张,此处做通用处理)。
current := make(map[int8]int64, len(grants))
computed := make(map[int8]bool, len(grants))
// 每类额度「吊销前」的有效剩余基准;excluded 累积本订单已计划吊销的凭证。
before := make(map[int8]int64, len(grants))
beforeKnown := make(map[int8]bool, len(grants))
excluded := make(map[int64]bool, len(grants))
for i := range grants {
g := grants[i]
revoke := g.Amount - g.Used
if revoke <= 0 {
if g.Amount-g.Used <= 0 {
continue // 已全部用掉,无可回收额度。
}
if !computed[g.QuotaType] {
if !beforeKnown[g.QuotaType] {
if eff, qerr := getEffectiveQuota(ctx, order.UserID, g.QuotaType); qerr == nil {
current[g.QuotaType] = eff.Remain
before[g.QuotaType] = eff.Remain
beforeKnown[g.QuotaType] = true
} else {
slog.WarnContext(ctx, "build quota revoke flows: calc effective quota failed",
"order_no", order.OrderNo, "user_id", order.UserID, "quota_type", g.QuotaType, "error", qerr)
}
computed[g.QuotaType] = true
}
before := current[g.QuotaType]
current[g.QuotaType] = before - revoke
excluded[g.ID] = true
if !beforeKnown[g.QuotaType] {
continue // 基准未知:宁可少记不可记错,跳过该凭证的回收流水。
}
after, aerr := effectiveQuotaRemainExcluding(ctx, order.UserID, g.QuotaType, excluded)
if aerr != nil {
slog.WarnContext(ctx, "build quota revoke flows: calc remain after revoke failed",
"order_no", order.OrderNo, "user_id", order.UserID, "quota_type", g.QuotaType, "grant_id", g.ID, "error", aerr)
beforeKnown[g.QuotaType] = false // 后续同类凭证不再基于错误基准记账。
continue
}
revoke := before[g.QuotaType] - after
before[g.QuotaType] = after
if revoke < 0 {
// 吊销后有效剩余反而增加(例如另一份未生效会员包被启用),属预期内的覆盖切换,只留痕不改动用户剩余。
slog.InfoContext(ctx, "quota revoke increases effective remain, skip flow",
"order_no", order.OrderNo, "user_id", order.UserID, "quota_type", g.QuotaType, "grant_id", g.ID, "delta", -revoke)
continue
}
if revoke == 0 {
continue // 吊销不改变有效剩余(重复凭证被覆盖),无回收流水。
}
flows = append(flows, &mysqlmodel.QuotaFlowLog{
QuotaFlowUUID: genUUID(),
UserID: order.UserID,
ChangeTarget: QuotaChangeTargetAdjust,
ChangeType: QuotaChangeTypeRefundRevoke,
ChangeNum: -revoke,
OldValue: before,
NewValue: before - revoke,
OldValue: before[g.QuotaType] + revoke,
NewValue: before[g.QuotaType],
OrderNo: order.OrderNo,
QuotaType: g.QuotaType,
Reason: "退款回收未使用的赠送额度",
+113
View File
@@ -0,0 +1,113 @@
package service
import (
"testing"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
)
// memberGrantFixture 构造会员包来源(source=1)额度凭证。
func memberGrantFixture(id, amount, used int64) mysqlmodel.QuotaGrant {
return mysqlmodel.QuotaGrant{ID: id, Source: QuotaGrantSourceMember, Amount: amount, Used: used}
}
// otherGrantFixture 构造非会员包来源(加量包/广告/体验)额度凭证。
func otherGrantFixture(id int64, source int8, amount, used int64) mysqlmodel.QuotaGrant {
return mysqlmodel.QuotaGrant{ID: id, Source: source, Amount: amount, Used: used}
}
func TestPickEffectiveMemberGrant(t *testing.T) {
for _, tc := range []struct {
name string
grants []mysqlmodel.QuotaGrant
wantID int64 // 0 表示期望无生效份(nil)
}{
{
name: "月度年度同额只取一份且保留正在使用的份",
grants: []mysqlmodel.QuotaGrant{memberGrantFixture(1, 100, 30), memberGrantFixture(2, 100, 0)},
wantID: 1,
},
{
name: "额度不同时取额度最大的一份",
grants: []mysqlmodel.QuotaGrant{memberGrantFixture(1, 100, 0), memberGrantFixture(2, 200, 0)},
wantID: 2,
},
{
name: "旧包用尽后顺延到新购的一份",
grants: []mysqlmodel.QuotaGrant{memberGrantFixture(1, 100, 100), memberGrantFixture(2, 100, 0)},
wantID: 2,
},
{
name: "同额同用量时优先新购",
grants: []mysqlmodel.QuotaGrant{memberGrantFixture(1, 100, 0), memberGrantFixture(2, 100, 0)},
wantID: 2,
},
{
name: "无会员包凭证时返回 nil",
grants: []mysqlmodel.QuotaGrant{otherGrantFixture(1, QuotaGrantSourceAddon, 1000, 0), otherGrantFixture(2, QuotaGrantSourceTrial, 100, 0)},
wantID: 0,
},
{
name: "会员包全部用尽时返回 nil",
grants: []mysqlmodel.QuotaGrant{memberGrantFixture(1, 100, 100), memberGrantFixture(2, 50, 50)},
wantID: 0,
},
} {
t.Run(tc.name, func(t *testing.T) {
got := pickEffectiveMemberGrant(tc.grants)
if tc.wantID == 0 {
if got != nil {
t.Fatalf("want nil, got id=%d", got.ID)
}
return
}
if got == nil || got.ID != tc.wantID {
t.Fatalf("want id=%d, got %+v", tc.wantID, got)
}
})
}
}
func TestPickMemberGrantForRefund(t *testing.T) {
for _, tc := range []struct {
name string
grants []mysqlmodel.QuotaGrant
handled map[int64]bool
wantID int64
}{
{
name: "优先回退当前生效份",
grants: []mysqlmodel.QuotaGrant{memberGrantFixture(1, 100, 40), memberGrantFixture(2, 100, 10)},
wantID: 1,
},
{
name: "生效份未消耗时复活刚被扣尽的份",
grants: []mysqlmodel.QuotaGrant{memberGrantFixture(1, 100, 100), memberGrantFixture(2, 100, 0)},
wantID: 1,
},
{
name: "已处理的份不重复回退",
grants: []mysqlmodel.QuotaGrant{memberGrantFixture(1, 100, 40), memberGrantFixture(2, 100, 10)},
handled: map[int64]bool{1: true},
wantID: 2,
},
{
name: "无可退额度时返回 nil",
grants: []mysqlmodel.QuotaGrant{memberGrantFixture(1, 100, 0)},
wantID: 0,
},
} {
t.Run(tc.name, func(t *testing.T) {
got := pickMemberGrantForRefund(tc.grants, tc.handled)
if tc.wantID == 0 {
if got != nil {
t.Fatalf("want nil, got id=%d", got.ID)
}
return
}
if got == nil || got.ID != tc.wantID {
t.Fatalf("want id=%d, got %+v", tc.wantID, got)
}
})
}
}
+96
View File
@@ -0,0 +1,96 @@
package service
import (
"context"
"testing"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
"github.com/DATA-DOG/go-sqlmock"
)
// TestRefundApplyMarksOrderRefunding 申请退款受理成功:返回待处理退款流水信息,订单 2 -> 6 由 DAO 事务内完成。
func TestRefundApplyMarksOrderRefunding(t *testing.T) {
mock := setupOrderQuotaTest(t)
mock.ExpectQuery("SELECT .* FROM `orders` WHERE order_no = \\?").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status", "pay_amount", "pay_channel"}).
AddRow(8, "ORDER-1", 7, 2, 990, 1))
mock.ExpectBegin()
mock.ExpectQuery("SELECT .* FROM `orders` .*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status", "pay_amount"}).
AddRow(8, "ORDER-1", 7, 2, 990))
mock.ExpectQuery("SELECT .* FROM `refund_flow_logs`").
WillReturnRows(sqlmock.NewRows([]string{"id"}))
mock.ExpectExec("INSERT INTO `refund_flow_logs`").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("UPDATE `orders` SET").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
resp, e := NewOrderService().RefundApply(context.Background(), 7,
&dto.RefundApplyRequest{OrderNo: "ORDER-1", Reason: "不想要了"})
if e != nil {
t.Fatalf("unexpected error: %v", e)
}
if resp == nil || resp.RefundAmount != 990 || resp.Status != RefundStatusPending || resp.RefundNo == "" {
t.Fatalf("unexpected response: %+v", resp)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// TestRefundApplyRejectsRepeatApply 重复/并发申请被 DAO 拒绝时映射为「当前状态不允许退款」。
func TestRefundApplyRejectsRepeatApply(t *testing.T) {
mock := setupOrderQuotaTest(t)
mock.ExpectQuery("SELECT .* FROM `orders` WHERE order_no = \\?").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status", "pay_amount", "pay_channel"}).
AddRow(8, "ORDER-1", 7, 2, 990, 1))
// 行锁内读到订单已被并发请求置为 6 申请退款中:拒绝且不产生任何写入。
mock.ExpectBegin()
mock.ExpectQuery("SELECT .* FROM `orders` .*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status"}).
AddRow(8, "ORDER-1", 7, OrderStatusRefunding))
mock.ExpectCommit()
resp, e := NewOrderService().RefundApply(context.Background(), 7,
&dto.RefundApplyRequest{OrderNo: "ORDER-1", Reason: "重复申请"})
if resp != nil || e != errcode.RefundNotAllowed {
t.Fatalf("resp=%v err=%v, want RefundNotAllowed", resp, e)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// TestRefundApplyRejectsIOSVirtualOrder iOS 虚拟支付订单不走本接口,引导用户走 App Store。
func TestRefundApplyRejectsIOSVirtualOrder(t *testing.T) {
mock := setupOrderQuotaTest(t)
mock.ExpectQuery("SELECT .* FROM `orders` WHERE order_no = \\?").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status", "pay_amount", "pay_channel", "xpay_platform"}).
AddRow(8, "ORDER-1", 7, 2, 990, 2, "ios"))
resp, e := NewOrderService().RefundApply(context.Background(), 7,
&dto.RefundApplyRequest{OrderNo: "ORDER-1", Reason: "不想要了"})
if resp != nil || e != errcode.IOSRefundViaAppStore {
t.Fatalf("resp=%v err=%v, want IOSRefundViaAppStore", resp, e)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// TestRefundApplyRejectsOtherUserOrder 非本人订单不允许申请退款(归属校验先于状态校验)。
func TestRefundApplyRejectsOtherUserOrder(t *testing.T) {
mock := setupOrderQuotaTest(t)
mock.ExpectQuery("SELECT .* FROM `orders` WHERE order_no = \\?").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status", "pay_amount", "pay_channel"}).
AddRow(8, "ORDER-1", 9, 2, 990, 1))
resp, e := NewOrderService().RefundApply(context.Background(), 7,
&dto.RefundApplyRequest{OrderNo: "ORDER-1", Reason: "越权申请"})
if resp != nil || e != errcode.Forbidden {
t.Fatalf("resp=%v err=%v, want Forbidden", resp, e)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
+192 -27
View File
@@ -135,12 +135,73 @@ func listValidGrants(ctx context.Context, userID int64, quotaType int8, vipExpir
return valid, nil
}
// pickEffectiveMemberGrant 在有效凭证中选出会员包来源(source=1)的生效份。
// 会员包额度不叠加:月度包与年度包同时持有时只取一份计入有效额度,避免额度翻倍。
//
// 规则:排除已用尽(Amount-Used<=0)的份,保证旧包用完后新购包能自动接续、不浪费已购额度;
// 候选中按 Amount 降序(取额度最大的一份)→ Used 降序(保留正在使用的一份,避免购买新包后
// 剩余额度抖动)→ ID 降序(新购优先)取第一条;无候选返回 nil,表示会员包贡献为 0。
//
// 纯函数、无副作用,调用方需保证 grants 为「当前有效」的凭证集合;
// getEffectiveQuota / consumeCosQuota / cosRemainTx / refundCosQuotaTx 共用该规则,保证展示、扣减、回退口径一致。
func pickEffectiveMemberGrant(grants []mysqlmodel.QuotaGrant) *mysqlmodel.QuotaGrant {
var picked *mysqlmodel.QuotaGrant
for i := range grants {
g := &grants[i]
if g.Source != QuotaGrantSourceMember || g.Amount-g.Used <= 0 {
continue
}
if picked == nil || memberGrantBetter(g, picked) {
picked = g
}
}
return picked
}
// memberGrantBetter 报告 a 是否应优先于 b:Amount 降序 → Used 降序 → ID 降序。
func memberGrantBetter(a, b *mysqlmodel.QuotaGrant) bool {
switch {
case a.Amount != b.Amount:
return a.Amount > b.Amount
case a.Used != b.Used:
return a.Used > b.Used
default:
return a.ID > b.ID
}
}
// pickMemberGrantForRefund 选择会员包来源(source=1)的回退目标份(跳过 handled 中已处理的份)。
// 会员包额度不叠加,回退需与展示口径一致:
// 1. 存在「当前生效份」(未用尽且 used>0)时优先回退它,保证剩余额度即时回升、账实一致;
// 2. 生效份无可退额度(used=0)时,退而选择 used>0 的份中 memberGrantBetter 最优的一条,
// 用于复活刚被扣至用尽的一份(例如消费恰好把生效份用尽后回退)。
func pickMemberGrantForRefund(grants []mysqlmodel.QuotaGrant, handled map[int64]bool) *mysqlmodel.QuotaGrant {
candidates := make([]mysqlmodel.QuotaGrant, 0, len(grants))
for i := range grants {
g := grants[i]
if g.Source != QuotaGrantSourceMember || g.Used <= 0 || handled[g.ID] {
continue
}
candidates = append(candidates, g)
}
if len(candidates) == 0 {
return nil
}
if effective := pickEffectiveMemberGrant(candidates); effective != nil {
return effective
}
picked := &candidates[0]
for i := 1; i < len(candidates); i++ {
if memberGrantBetter(&candidates[i], picked) {
picked = &candidates[i]
}
}
return picked
}
// getEffectiveQuota 返回某类额度的有效额度概览(Cos 永久 / 每日额度)。
// 有效总额度 = 免费配置值 + SUM(有效凭证 amount);已用按类型分别取永久累计或当日计数。
func getEffectiveQuota(ctx context.Context, userID int64, quotaType int8) (*EffectiveQuota, *errcode.Error) {
now := nowUnix()
freeTotal := defaultQuotaValue(quotaType)
vipExpire := getUserVIPExpire(ctx, userID)
grants, err := listValidGrants(ctx, userID, quotaType, vipExpire, now)
if err != nil {
@@ -148,12 +209,28 @@ func getEffectiveQuota(ctx context.Context, userID int64, quotaType int8) (*Effe
"user_id", userID, "quota_type", quotaType, "error", err)
return nil, errcode.DatabaseError
}
return effectiveQuotaFromGrants(ctx, userID, quotaType, grants), nil
}
// effectiveQuotaFromGrants 基于有效凭证快照聚合某类额度的有效额度概览(免费部分从库读取)。
// 有效总额度 = 免费配置值 + 会员包生效份(source=1,不叠加只取一份)+ 其他来源凭证 SUM;
// 已用按类型分别取永久累计或当日计数。
func effectiveQuotaFromGrants(ctx context.Context, userID int64, quotaType int8, grants []mysqlmodel.QuotaGrant) *EffectiveQuota {
freeTotal := defaultQuotaValue(quotaType)
// 会员包来源不叠加:只取生效的一份;加量包/广告/体验等其他来源仍叠加求和。
var grantTotal, grantUsed int64
for i := range grants {
if grants[i].Source == QuotaGrantSourceMember {
continue
}
grantTotal += grants[i].Amount
grantUsed += grants[i].Used
}
if memberGrant := pickEffectiveMemberGrant(grants); memberGrant != nil {
grantTotal += memberGrant.Amount
grantUsed += memberGrant.Used
}
if quotaType == QuotaTypeCos {
// 永久额度:免费已用来自 user_quotas.used(永久累计),赠送已用来自凭证 used。
@@ -170,7 +247,7 @@ func getEffectiveQuota(ctx context.Context, userID int64, quotaType int8) (*Effe
if remain < 0 {
remain = 0
}
return &EffectiveQuota{Total: total, Used: used, Remain: remain}, nil
return &EffectiveQuota{Total: total, Used: used, Remain: remain}
}
// 每日额度:免费与赠送合并为每日总额度,已用为当日计数。
@@ -180,7 +257,27 @@ func getEffectiveQuota(ctx context.Context, userID int64, quotaType int8) (*Effe
if remain < 0 {
remain = 0
}
return &EffectiveQuota{Total: total, Used: used, Remain: remain}, nil
return &EffectiveQuota{Total: total, Used: used, Remain: remain}
}
// effectiveQuotaRemainExcluding 计算「排除指定凭证(按 ID)」后的某类有效剩余,供退款回收差值记账使用。
// 聚合口径与 getEffectiveQuota 完全一致(会员包不叠加只取一份,其他来源叠加),
// 保证「退款回收」流水的变更量等于用户真实可感知的剩余变化。
func effectiveQuotaRemainExcluding(ctx context.Context, userID int64, quotaType int8, excluded map[int64]bool) (int64, error) {
now := nowUnix()
vipExpire := getUserVIPExpire(ctx, userID)
grants, err := listValidGrants(ctx, userID, quotaType, vipExpire, now)
if err != nil {
return 0, err
}
kept := make([]mysqlmodel.QuotaGrant, 0, len(grants))
for i := range grants {
if excluded[grants[i].ID] {
continue
}
kept = append(kept, grants[i])
}
return effectiveQuotaFromGrants(ctx, userID, quotaType, kept).Remain, nil
}
// dailyQuotaKey 按额度类型构造当日计数 Redis key。
@@ -275,7 +372,8 @@ func refundDailyQuota(ctx context.Context, userID int64, quotaType int8, n int64
}
}
// consumeCosQuota 消费 Cos 永久额度(先到期凭证先用,再扣免费额度),事务内保证数值与流水一致。
// consumeCosQuota 消费 Cos 永久额度(先到期凭证先用,再扣会员包生效份,最后扣免费额度),事务内保证数值与流水一致。
// 会员包来源(source=1)不叠加:多份会员包凭证中只扣生效的一份,未生效的份不参与扣减。
func consumeCosQuota(ctx context.Context, userID int64, n int64, relatedID int64, reason string) *errcode.Error {
now := nowUnix()
vipExpire := getUserVIPExpire(ctx, userID)
@@ -296,32 +394,54 @@ func consumeCosQuota(ctx context.Context, userID int64, n int64, relatedID int64
return err
}
remain := n
for i := range grants {
if remain <= 0 {
break
// consumeGrant 扣减单份凭证的已用(条件更新防并发超扣,扣不动则整单回滚)。
consumeGrant := func(g *mysqlmodel.QuotaGrant, want int64) (int64, error) {
delta := g.Amount - g.Used
if delta <= 0 {
return 0, nil
}
avail := grants[i].Amount - grants[i].Used
if avail <= 0 {
continue
}
delta := avail
if delta > remain {
delta = remain
if delta > want {
delta = want
}
res := tx.Model(&mysqlmodel.QuotaGrant{}).
Where("id = ? AND used + ? <= amount", grants[i].ID, delta).
Where("id = ? AND used + ? <= amount", g.ID, delta).
UpdateColumn("used", gorm.Expr("used + ?", delta))
if res.Error != nil {
return res.Error
return 0, res.Error
}
if res.RowsAffected == 0 {
// 凭证在行锁下被并发改写(理论上不应发生)。此时若继续累加会少扣额度,
// 直接回滚整个事务并报额度不足,宁可让用户重试也不超额放行。
return errcode.QuotaInsufficient
return 0, errcode.QuotaInsufficient
}
return delta, nil
}
remain := n
// 1.1 先扣非会员包凭证(加量包/广告/体验,先到期先用)。
for i := range grants {
if remain <= 0 {
break
}
if grants[i].Source == QuotaGrantSourceMember {
continue
}
delta, cerr := consumeGrant(&grants[i], remain)
if cerr != nil {
return cerr
}
remain -= delta
}
// 1.2 会员包额度不叠加:只扣生效的一份,未生效的份不参与扣减。
if remain > 0 {
if memberGrant := pickEffectiveMemberGrant(grants); memberGrant != nil {
delta, cerr := consumeGrant(memberGrant, remain)
if cerr != nil {
return cerr
}
remain -= delta
}
}
// 2. 凭证不够再扣免费额度(免费上限以配置值为准,避免历史 user_quotas.total 污染)。
if remain > 0 {
@@ -368,6 +488,7 @@ func consumeCosQuota(ctx context.Context, userID int64, n int64, relatedID int64
}
// cosRemainTx 在事务内计算 Cos 有效剩余(免费剩余 + 有效凭证剩余)。
// 会员包来源不叠加:只计生效的一份,与 getEffectiveQuota 展示口径保持一致。
func cosRemainTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire, now int64) int64 {
freeRemain := int64(0)
var freeQ mysqlmodel.Quota
@@ -375,16 +496,31 @@ func cosRemainTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire, now
freeRemain = defaultQuotaValue(QuotaTypeCos) - freeQ.Used
}
var grantRemain int64
q := tx.Model(&mysqlmodel.QuotaGrant{}).Where("user_id = ? AND quota_type = ?", userID, QuotaTypeCos)
var grants []mysqlmodel.QuotaGrant
q := tx.Where("user_id = ? AND quota_type = ?", userID, QuotaTypeCos)
if vipExpire > now {
q = q.Where("(expire_time > ? OR expire_time = 0)", now)
} else {
q = q.Where("expire_time > ?", now)
}
if err := q.Select("COALESCE(SUM(amount - used), 0)").Scan(&grantRemain).Error; err != nil {
if err := q.Find(&grants).Error; err != nil {
slog.WarnContext(ctx, "calc cos grant remain failed", "user_id", userID, "error", err)
}
var grantRemain int64
for i := range grants {
if grants[i].Source == QuotaGrantSourceMember {
continue
}
if r := grants[i].Amount - grants[i].Used; r > 0 {
grantRemain += r
}
}
if memberGrant := pickEffectiveMemberGrant(grants); memberGrant != nil {
if r := memberGrant.Amount - memberGrant.Used; r > 0 {
grantRemain += r
}
}
if freeRemain < 0 {
freeRemain = 0
}
@@ -392,7 +528,8 @@ func cosRemainTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire, now
}
// refundCosQuotaTx 在事务内回退 Cos 额度并写流水。
// 回退策略(后进先出):优先回退免费额度(最后扣减的),不足再回退凭证(最后到期优先)。
// 回退策略(后进先出):优先回退免费额度(最后扣减的),再回退非会员包凭证(最后到期优先),
// 最后回退会员包凭证(不叠加口径:优先当前生效份,生效份无可退额度时复活刚被扣尽的份)。
func refundCosQuotaTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire int64, n int64, relatedID int64, reason string) error {
now := nowUnix()
@@ -408,10 +545,10 @@ func refundCosQuotaTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire
remain = 0
}
// 2. 免费额度不足,回退凭证(按到期时间倒序,最后到期的优先回退)。
// 2. 免费额度不足,先回退非会员包凭证(加量包/广告/体验,按到期时间倒序,最后到期的优先回退)。
if remain > 0 {
var grants []mysqlmodel.QuotaGrant
if err := tx.Where("user_id = ? AND quota_type = ? AND used > 0", userID, QuotaTypeCos).
if err := tx.Where("user_id = ? AND quota_type = ? AND used > 0 AND source <> ?", userID, QuotaTypeCos, QuotaGrantSourceMember).
Order("expire_time DESC").
Clauses(clause.Locking{Strength: "UPDATE"}).
Find(&grants).Error; err != nil {
@@ -434,7 +571,35 @@ func refundCosQuotaTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire
}
}
// 3. 写回退流水:变更量取「实际回退量」。
// 3. 再回退会员包凭证(不叠加口径:按回退选择规则逐份回退,未生效份不作为回退目标)。
if remain > 0 {
var grants []mysqlmodel.QuotaGrant
if err := tx.Where("user_id = ? AND quota_type = ? AND used > 0 AND source = ?", userID, QuotaTypeCos, QuotaGrantSourceMember).
Clauses(clause.Locking{Strength: "UPDATE"}).
Find(&grants).Error; err != nil {
return err
}
handled := make(map[int64]bool, len(grants))
for remain > 0 {
target := pickMemberGrantForRefund(grants, handled)
if target == nil {
break
}
delta := target.Used
if delta > remain {
delta = remain
}
if err := tx.Model(&mysqlmodel.QuotaGrant{}).
Where("id = ? AND used >= ?", target.ID, delta).
UpdateColumn("used", gorm.Expr("used - ?", delta)).Error; err != nil {
return err
}
remain -= delta
handled[target.ID] = true
}
}
// 4. 写回退流水:变更量取「实际回退量」。
// 此前 `remain > 0` 时直接 return,导致额度已被部分改回却没有流水(账实不一致);
// 同时 ChangeNum 写的是请求量 n,退不满时数字虚高。现统一按实际回退量记账,
// 退不满/退不动时在原因里标注并告警(已用额度不足通常意味着重复回退或数据异常,必须留痕)。
+239
View File
@@ -0,0 +1,239 @@
package service
import (
"context"
"log/slog"
"sync"
"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 (
// virtualRefundConfirmGraceSeconds 退款申请的宽限期:只处理停留超过该时长的「申请退款中」订单,
// 给退款推送留出处理时间,避免与推送路径抢跑。
virtualRefundConfirmGraceSeconds int64 = 10 * 60
// virtualRefundConfirmBatchSize 单轮最多扫描的候选订单数。
virtualRefundConfirmBatchSize = 100
// virtualRefundConfirmWorkers 单轮并发反查的 worker 数(query_order 为 HTTP 调用,串行会在积压时撞上超时)。
virtualRefundConfirmWorkers = 5
// virtualRefundConfirmRecheckTTL 单个订单的查单频率下限:命中「已核对」标记则跳过本轮,
// 把单个订单的微信接口调用频率压到每 TTL 一次(退款为人工审核,长时间停留在 6 属正常)。
virtualRefundConfirmRecheckTTL = 10 * time.Minute
// virtualRefundConfirmStuckSeconds 停留超过该时长仍未见微信侧退款 -> Error 告警交人工介入。
virtualRefundConfirmStuckSeconds int64 = 24 * 60 * 60
// virtualRefundConfirmStuckAlertTTL 「长期未完成」告警去重 TTL,避免每分钟刷屏。
virtualRefundConfirmStuckAlertTTL = 6 * time.Hour
// virtualRefundConfirmLockTTL 单轮任务的分布式锁 TTL,必须大于单轮最大耗时,否则锁提前过期会让多实例并发执行。
virtualRefundConfirmLockTTL = 5 * time.Minute
// virtualRefundConfirmTimeout 单轮任务的上下文超时,需给「批量查询 + 并发反查」留足余量。
virtualRefundConfirmTimeout = 5 * time.Minute
)
// StartVirtualRefundConfirmScheduler 启动虚拟支付退款结果查单兜底任务。
//
// 与发货链路的「推送 + 轮询」双保险同理:退款推送可能丢失、字段版式变化或应答失败重试耗尽,
// 一旦发生订单会永久停留在「6 申请退款中」(用户看不到退款结果、运营无法按状态收口)。
// 本任务对停留超过宽限期的「申请退款中」订单主动反查 query_order:
// - 微信侧已退款 -> 复用推送同一结算链路置「5 已退款」、回收权益并同步退款流水;
// - 微信侧仍未退款 -> 保持订单状态不变(避免运营处理中的退款被误判回退),超过阈值输出 Error 告警交人工。
func StartVirtualRefundConfirmScheduler() {
go func() {
slog.Info("virtual refund confirm scheduler started")
for {
func() {
defer func() {
if r := recover(); r != nil {
slog.Error("virtual refund confirm scheduler panic", "recover", r)
}
}()
runVirtualRefundConfirm()
}()
time.Sleep(time.Minute)
}
}()
}
// runVirtualRefundConfirm 扫描「申请退款中」的虚拟支付订单并反查微信侧退款结果,单实例通过 Redis 分布式锁防重。
func runVirtualRefundConfirm() {
// 虚拟支付未开通时直接返回,避免无谓的数据库扫描。
if !wechat.IsVirtualPayEnabled() {
return
}
ctx, cancel := context.WithTimeout(context.Background(), virtualRefundConfirmTimeout)
defer cancel()
ctx, span := global.Tracer.Start(ctx, "scheduler.VirtualRefundConfirm")
defer span.End()
lockKey := redismodel.KeyLockVirtualRefundConfirm
lockVal := genUUID()
ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, virtualRefundConfirmLockTTL)
if lerr != nil {
slog.ErrorContext(ctx, "virtual refund confirm: acquire lock failed", "error", lerr)
return
}
if !ok {
slog.DebugContext(ctx, "virtual refund confirm: another instance running, skip")
return
}
defer func() {
if uerr := redisdao.Unlock(context.Background(), lockKey, lockVal); uerr != nil {
slog.WarnContext(ctx, "virtual refund confirm: release lock failed", "error", uerr)
}
}()
now := nowUnix()
orders, err := mysqldao.NewOrderDao().
OrderListVirtualRefunding(ctx, now-virtualRefundConfirmGraceSeconds, virtualRefundConfirmBatchSize)
if err != nil {
slog.ErrorContext(ctx, "virtual refund confirm: list refunding orders failed", "error", err)
return
}
if len(orders) == 0 {
slog.DebugContext(ctx, "virtual refund confirm: no refunding orders in window")
return
}
if len(orders) >= virtualRefundConfirmBatchSize {
slog.WarnContext(ctx, "virtual refund confirm backlog detected, candidates reached limit",
"candidates", len(orders), "limit", virtualRefundConfirmBatchSize)
}
s := NewOrderService()
var (
mu sync.Mutex
wg sync.WaitGroup
queried int
reconciled int
stuck int
)
sem := make(chan struct{}, virtualRefundConfirmWorkers)
for i := range orders {
order := &orders[i]
// 频率限制:TTL 内已核对过的订单让出名额;标记不可用时不阻断本轮(退化为重复查询)。
first, serr := redisdao.VirtualRefundCheckedSetNX(ctx, order.OrderNo, virtualRefundConfirmRecheckTTL)
if serr != nil {
slog.WarnContext(ctx, "virtual refund confirm: set recheck mark failed",
"order_no", order.OrderNo, "error", serr)
} else if !first {
continue
}
queried++
wg.Add(1)
sem <- struct{}{}
go func(o *mysqlmodel.Order) {
defer wg.Done()
defer func() { <-sem }()
defer func() {
if r := recover(); r != nil {
slog.ErrorContext(ctx, "virtual refund confirm worker panic", "order_no", o.OrderNo, "recover", r)
}
}()
done, alerted, qok := s.reconcileVirtualRefund(ctx, o, now)
if !qok {
// 查单失败:撤销「已核对」标记,让下一轮立即重试(撤销失败不阻断,退化为 TTL 节奏)。
if derr := redisdao.VirtualRefundCheckedDel(ctx, o.OrderNo); derr != nil {
slog.WarnContext(ctx, "virtual refund confirm: clear recheck mark failed",
"order_no", o.OrderNo, "error", derr)
}
}
if done {
mu.Lock()
reconciled++
mu.Unlock()
}
if alerted {
mu.Lock()
stuck++
mu.Unlock()
}
}(order)
}
wg.Wait()
if reconciled > 0 || stuck > 0 {
slog.InfoContext(ctx, "virtual refund confirm finished",
"scanned", len(orders), "queried", queried, "reconciled", reconciled, "stuck_alerted", stuck)
return
}
slog.DebugContext(ctx, "virtual refund confirm finished without change",
"scanned", len(orders), "queried", queried)
}
// reconcileVirtualRefund 反查单笔「申请退款中」订单在微信侧的退款结果。
// - 微信侧已退款:置「已退款」、回收权益并同步退款流水(与推送路径同一结算链路,条件流转天然幂等);
// - 微信侧仍未退款:保持订单状态不变,停留超过阈值时输出 Error 告警交人工(不自动回退,避免运营处理中的退款被误判);
// - 查询失败/用户信息缺失:仅记日志并返回 queried=false,交由下一轮立即重试。
//
// 返回值:reconciled=已按查单结果订正为已退款;alerted=本次输出了人工介入告警;queried=查单是否成功执行。
func (s *OrderService) reconcileVirtualRefund(ctx context.Context, order *mysqlmodel.Order, now int64) (reconciled bool, alerted bool, queried bool) {
// query_order 需要 openid,订单表不冗余该字段,按 user_id 取。
user, err := s.userDao.UserGetByID(ctx, order.UserID)
if err != nil {
slog.ErrorContext(ctx, "virtual refund confirm: get user failed",
"order_no", order.OrderNo, "user_id", order.UserID, "error", err)
return false, false, false
}
if user.WxOpenID == "" {
slog.WarnContext(ctx, "virtual refund confirm: user openid unavailable",
"order_no", order.OrderNo, "user_id", order.UserID)
return false, false, false
}
info, qerr := wechat.QueryOrder(ctx, user.WxOpenID, order.OrderNo, "")
if qerr != nil {
slog.ErrorContext(ctx, "virtual refund confirm: query order failed",
"order_no", order.OrderNo, "error", qerr)
return false, false, false
}
if wechat.IsOrderRefundedStatus(info.Order.Status) {
// 退款金额优先取退款单金额,其次按支付单「实付 - 剩余」推算,最后回退订单实付金额。
refundFee := info.Order.RefundFee
if refundFee <= 0 {
refundFee = info.Order.PaidFee - info.Order.LeftFee
}
if refundFee <= 0 {
refundFee = order.PayAmount
}
if serr := s.settleRefundSuccess(ctx, order, refundFee); serr != nil {
slog.ErrorContext(ctx, "virtual refund confirm: settle refund success failed",
"order_no", order.OrderNo, "refund_fee", refundFee, "error", serr)
return false, false, true // 查单成功但结算失败:保持 TTL 节奏重试。
}
// 查单分支没有推送报文与微信退款单号,仅同步流水终态(有本地申请流水时)。
s.markRefundFlowSuccess(ctx, order, "", "")
slog.InfoContext(ctx, "virtual refund confirm: refund reconciled by query_order",
"order_no", order.OrderNo, "user_id", order.UserID,
"wx_status", info.Order.Status, "refund_fee", refundFee)
return true, false, true
}
// 微信侧仍未退款:订单保持「申请退款中」(运营可能正在处理),仅在长期无进展时告警人工介入。
stuckSeconds := now - order.UpdatedAt
if stuckSeconds < virtualRefundConfirmStuckSeconds {
slog.DebugContext(ctx, "virtual refund confirm: refund still pending in wechat side",
"order_no", order.OrderNo, "wx_status", info.Order.Status, "stuck_seconds", stuckSeconds)
return false, false, true
}
first, serr := redisdao.VirtualRefundStuckAlertSetNX(ctx, order.OrderNo, virtualRefundConfirmStuckAlertTTL)
if serr != nil {
slog.WarnContext(ctx, "virtual refund confirm: set stuck alert mark failed",
"order_no", order.OrderNo, "error", serr)
first = true // 标记不可用时仍然告警,避免静默。
}
if !first {
return false, false, true
}
slog.ErrorContext(ctx, "virtual refund confirm: refund stuck, manual intervention required",
"order_no", order.OrderNo, "user_id", order.UserID,
"stuck_seconds", stuckSeconds, "wx_status", info.Order.Status)
return false, true, true
}
+29
View File
@@ -0,0 +1,29 @@
package service
import (
"strings"
"testing"
"unicode/utf8"
)
// TestTruncateCallbackRaw 回调报文截断:短报文原样保留,长报文按上限截断且不产生非法 UTF-8
// (退款流水 callback_raw 为 utf8mb4 列,写入非法字节会直接报错)。
func TestTruncateCallbackRaw(t *testing.T) {
short := `{"Event":"xpay_refund_notify","MchOrderId":"OD1"}`
if got := truncateCallbackRaw(short); got != short {
t.Fatalf("short raw should be kept as-is, got %q", got)
}
// 构造跨越截断边界的多字节报文:4KB 边界前后都是中文字符。
long := strings.Repeat("退", 3000)
got := truncateCallbackRaw(long)
if len(got) > 4096 {
t.Fatalf("truncated length = %d, want <= 4096", len(got))
}
if !utf8.ValidString(got) {
t.Fatal("truncated raw must be valid UTF-8")
}
if got == "" || !strings.HasPrefix(long, got) {
t.Fatalf("truncated raw should be a prefix of the original, got prefix=%q", got[:min(8, len(got))])
}
}
+146 -27
View File
@@ -4,7 +4,9 @@ import (
"context"
"encoding/json"
"log/slog"
"strconv"
"time"
"unicode/utf8"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
@@ -165,7 +167,7 @@ func (s *OrderService) XPayNotify(ctx context.Context, raw []byte) []byte {
slog.ErrorContext(ctx, "xpay refund notify decode failed", "error", err)
return wechat.BuildNotifyResponse(false)
}
if e := s.handleRefundNotify(ctx, &refund); e != nil {
if e := s.handleRefundNotify(ctx, &refund, string(raw)); e != nil {
// 内部失败分支已各自记录 Error 日志,这里仅保留调用链痕迹,避免重复告警。
slog.DebugContext(ctx, "xpay refund notify handle failed", "error", e)
return wechat.BuildNotifyResponse(false)
@@ -224,12 +226,24 @@ func (s *OrderService) verifyXPayOrderPaid(ctx context.Context, order *mysqlmode
// query_order(带 HMAC 签名的服务端 API)确认订单在微信侧已真实退款、金额一致,
// 防止伪造退款推送回收权益。任一校验不通过返回错误(fail-closed,触发微信重试)。
func (s *OrderService) verifyXPayRefund(ctx context.Context, order *mysqlmodel.Order, refund *wechat.XPayRefundNotify) *errcode.Error {
if refund.OpenID == "" {
slog.ErrorContext(ctx, "xpay refund: empty openid", "order_no", order.OrderNo)
// query_order 必需 openid:推送未携带时回退用订单归属用户的 openid(订单表不冗余该字段,按 user_id 取)。
openid := refund.OpenID
if openid == "" {
user, uerr := s.userDao.UserGetByID(ctx, order.UserID)
if uerr != nil {
slog.ErrorContext(ctx, "xpay refund: get user for openid failed",
"order_no", order.OrderNo, "user_id", order.UserID, "error", uerr)
return errcode.OrderRealityCheckFailed
}
openid = user.WxOpenID
}
if openid == "" {
slog.ErrorContext(ctx, "xpay refund: empty openid", "order_no", order.OrderNo, "user_id", order.UserID)
return errcode.OrderRealityCheckFailed
}
info, err := wechat.QueryOrder(ctx, refund.OpenID, refund.OutTradeNo, "")
// 按订单号反查(而非推送中的订单号字段),保证查询目标与本地订单严格一致。
info, err := wechat.QueryOrder(ctx, openid, order.OrderNo, "")
if err != nil {
slog.ErrorContext(ctx, "xpay refund: query order failed",
"order_no", order.OrderNo, "error", err)
@@ -372,41 +386,71 @@ func (s *OrderService) handleGoodsDeliver(ctx context.Context, deliver *wechat.X
return nil
}
// handleRefundNotify 处理退款推送:复用退款成功回退权益链路(settleRefundSuccess)。
func (s *OrderService) handleRefundNotify(ctx context.Context, refund *wechat.XPayRefundNotify) *errcode.Error {
lockKey := fmtLockKey(redismodel.KeyLockRefundCallback, refund.RefundOrderID)
// handleRefundNotify 处理退款推送(xpay_refund_notify):按官方字段驱动状态流转。
//
// 官方报文用 MchOrderId 定位订单、RetCode 判定结果(0=成功,非 0=失败,原因见 RetMsg)。
// 拿不到订单号或结果不可判定时一律应答失败(ErrCode:1),由微信按 2/4/8/16… 最多重试 15 次,
// 避免「静默成功」导致订单永久卡在「申请退款中」;成功路径仍强制反查 query_order 确认真实退款
// (推送无签名),防止伪造推送回收权益。
func (s *OrderService) handleRefundNotify(ctx context.Context, refund *wechat.XPayRefundNotify, raw string) *errcode.Error {
orderNo := refund.OrderNo()
if orderNo == "" {
slog.ErrorContext(ctx, "xpay refund: empty order no in notify",
"mch_order_id", refund.MchOrderID, "out_trade_no", refund.OutTradeNo,
"mch_refund_id", refund.MchRefundID, "wx_refund_id", refund.WxRefundID,
"retry_times", refund.RetryTimes)
return errcode.OrderRealityCheckFailed // 应答失败 -> 微信重试。
}
lockKey := fmtLockKey(redismodel.KeyLockRefundCallback, orderNo)
lockVal := genUUID()
ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, 30*time.Second)
if lerr != nil {
slog.ErrorContext(ctx, "xpay refund: acquire lock failed", "out_refund_no", refund.RefundOrderID, "error", lerr)
slog.ErrorContext(ctx, "xpay refund: acquire lock failed", "order_no", orderNo, "error", lerr)
return nil // 锁不可用(Redis 故障):不重复处理,交由微信重试/持锁者。
}
if !ok {
slog.DebugContext(ctx, "xpay refund: concurrent callback, skip", "out_refund_no", refund.RefundOrderID)
slog.DebugContext(ctx, "xpay refund: concurrent callback, skip", "order_no", orderNo)
return nil
}
defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }()
// 白名单判断:仅「退款成功」才回退权益;空串/未知/中间状态仅记录,防止误回退
// (退款推送按官方文档在退款完成时触发,状态枚举联调时以官方为准)。
if refund.RefundStatus != wechat.XPayRefundStatusSuccess {
slog.WarnContext(ctx, "xpay refund notify not success, skip revoke",
"order_no", refund.OutTradeNo, "refund_order_id", refund.RefundOrderID,
"refund_status", refund.RefundStatus, "refund_fee", refund.RefundFee)
succ, determinable := refund.Succeeded()
if !determinable {
slog.ErrorContext(ctx, "xpay refund: refund result not determinable, ask wechat to retry",
"order_no", orderNo, "mch_refund_id", refund.MchRefundID, "wx_refund_id", refund.WxRefundID,
"retry_times", refund.RetryTimes)
return errcode.OrderRealityCheckFailed
}
order, gerr := s.orderDao.OrderGetByOrderNo(ctx, orderNo)
if gerr != nil {
if isNotFound(gerr) {
slog.ErrorContext(ctx, "xpay refund: order not found", "order_no", orderNo)
return errcode.OrderRealityCheckFailed
}
slog.ErrorContext(ctx, "xpay refund: query order failed", "order_no", orderNo, "error", gerr)
return errcode.DatabaseError
}
if !succ {
// 退款失败:订单条件回退「6 申请退款中 -> 2 已支付」,允许用户重新申请;
// 本地流水置失败并记录微信失败原因(无本地流水如 iOS 走 App Store 退款时跳过)。
reverted, rerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, order.OrderNo, OrderStatusRefunding, OrderStatusPaid)
if rerr != nil {
slog.ErrorContext(ctx, "xpay refund: revert order status failed",
"order_no", order.OrderNo, "mch_refund_id", refund.MchRefundID, "error", rerr)
}
s.markRefundFlowFailed(ctx, order, refund, raw)
slog.WarnContext(ctx, "xpay refund failed, order reverted to paid",
"order_no", order.OrderNo, "mch_refund_id", refund.MchRefundID, "ret_code", refundRetCodeText(refund),
"ret_msg", refund.RetMsg, "refund_fee", refund.RefundFee, "order_reverted", reverted)
return nil
}
order, gerr := s.orderDao.OrderGetByOrderNo(ctx, refund.OutTradeNo)
if gerr != nil {
if isNotFound(gerr) {
slog.ErrorContext(ctx, "xpay refund: order not found", "out_trade_no", refund.OutTradeNo)
return nil
}
slog.ErrorContext(ctx, "xpay refund: query order failed", "out_trade_no", refund.OutTradeNo, "error", gerr)
return errcode.DatabaseError
}
// 已终态则幂等返回。
// 已终态则幂等返回;同时补一次流水回写,覆盖此前「订单已置 5 但流水未同步」的中途失败。
if order.OrderStatus == OrderStatusRefunded {
s.markRefundFlowSuccess(ctx, order, refund.WxRefundID, raw)
slog.DebugContext(ctx, "xpay refund: order already refunded, skip", "order_no", order.OrderNo)
return nil
}
@@ -417,15 +461,90 @@ func (s *OrderService) handleRefundNotify(ctx context.Context, refund *wechat.XP
return verr
}
// settleRefundSuccess 内部含条件流转(Paid -> Refunded),重复推送不会重复回退。
// settleRefundSuccess 内部含条件流转(Paid/Refunding -> Refunded),重复推送不会重复回退。
// 内部失败已由 settleRefundSuccess 的使用方日志覆盖:此处接收返回值并记录,避免静默。
if serr := s.settleRefundSuccess(ctx, order, refund.RefundFee); serr != nil {
slog.ErrorContext(ctx, "xpay refund: settle refund success failed",
"order_no", order.OrderNo, "refund_order_id", refund.RefundOrderID, "error", serr)
"order_no", order.OrderNo, "mch_refund_id", refund.MchRefundID, "error", serr)
}
// 订单已置 5:同步本地申请流水为成功,并回写微信退款单号与推送原始报文便于对账。
s.markRefundFlowSuccess(ctx, order, refund.WxRefundID, raw)
return nil
}
// refundRetCodeText 退款结果文本(日志与失败原因用):优先 RetCode,旧版式回退 RefundStatus。
func refundRetCodeText(refund *wechat.XPayRefundNotify) string {
if refund.RetCode != nil {
return "RetCode=" + strconv.Itoa(*refund.RetCode)
}
return "RefundStatus=" + refund.RefundStatus
}
// markRefundFlowSuccess 尽力把订单关联的退款流水置为成功(best-effort,仅记日志不返回错误),
// 同时回写微信退款单号与推送原始报文(对账/审计用)。无本地流水(如 iOS 走 App Store 退款)时静默跳过。
func (s *OrderService) markRefundFlowSuccess(ctx context.Context, order *mysqlmodel.Order, wechatRefundNo, raw string) {
flow, ferr := s.refundDao.RefundFlowLogGetByOrderNo(ctx, order.OrderNo)
if ferr != nil {
if !isNotFound(ferr) {
slog.ErrorContext(ctx, "xpay refund: query refund flow failed", "order_no", order.OrderNo, "error", ferr)
}
return
}
updates := map[string]any{"status": RefundStatusSuccess, "success_time": nowUnix()}
if wechatRefundNo != "" {
updates["wechat_refund_no"] = wechatRefundNo
}
if raw != "" {
updates["callback_raw"] = truncateCallbackRaw(raw)
}
if _, uerr := s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, flow.RefundNo, flow.Status, updates); uerr != nil {
slog.ErrorContext(ctx, "xpay refund: sync refund flow success failed",
"order_no", order.OrderNo, "refund_no", flow.RefundNo, "error", uerr)
}
}
// markRefundFlowFailed 尽力把订单关联的退款流水置为失败并记录微信失败原因(best-effort),无本地流水时跳过。
func (s *OrderService) markRefundFlowFailed(ctx context.Context, order *mysqlmodel.Order, refund *wechat.XPayRefundNotify, raw string) {
flow, ferr := s.refundDao.RefundFlowLogGetByOrderNo(ctx, order.OrderNo)
if ferr != nil {
if !isNotFound(ferr) {
slog.ErrorContext(ctx, "xpay refund: query refund flow failed", "order_no", order.OrderNo, "error", ferr)
}
return
}
if flow.Status == RefundStatusSuccess {
return // 已成功,不用失败态覆盖。
}
failReason := "虚拟支付退款失败:" + refundRetCodeText(refund)
if refund.RetMsg != "" {
failReason += "-" + refund.RetMsg
}
updates := map[string]any{"status": RefundStatusFailed, "fail_reason": failReason}
if refund.WxRefundID != "" {
updates["wechat_refund_no"] = refund.WxRefundID
}
if raw != "" {
updates["callback_raw"] = truncateCallbackRaw(raw)
}
if _, uerr := s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, flow.RefundNo, flow.Status, updates); uerr != nil {
slog.ErrorContext(ctx, "xpay refund: mark refund flow failed",
"order_no", order.OrderNo, "refund_no", flow.RefundNo, "error", uerr)
}
}
// truncateCallbackRaw 截断回调原始报文,避免超长报文写入退款流水(按 rune 安全截断,防止产生非法 UTF-8)。
func truncateCallbackRaw(raw string) string {
const maxCallbackRawLen = 4096
if len(raw) <= maxCallbackRawLen {
return raw
}
cut := raw[:maxCallbackRawLen]
for len(cut) > 0 && !utf8.ValidString(cut) {
cut = cut[:len(cut)-1]
}
return cut
}
// handleIOSRefundQuery 处理 iOS 退款问询(3 秒内应答,仅本地查询)。
// 按权益使用情况细化:权益已消耗则拒绝退款(result_code=1 + evidence),未消耗则建议退款;
// 建议退款后权益由后续退款推送 settleRefundSuccess 回收(回收链路保证「已消耗部分不回退」)。