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
+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: "退款回收未使用的赠送额度",