feat(order): 重构退款全流程并新增申请退款中状态
- 订单状态新增「6 申请退款中」,用户申请退款原子流转并落待审核退款流水 - 退款失败回退订单至已支付并恢复被取消的权益发放任务 - 新增虚拟支付退款查单兜底任务,反查超时订单并自动订正 - 会员包来源额度不叠加,扣减/回退/回收口径统一为只取生效一份 - 退款回收流水按吊销前后有效剩余差值记账,避免无效回收记录 - 同步更新 README 与 Stitch 设计文档
This commit is contained in:
+76
-52
@@ -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: "退款回收未使用的赠送额度",
|
||||
|
||||
Reference in New Issue
Block a user