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
+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")