This commit is contained in:
zhengchiliu
2026-09-15 00:53:04 +08:00
parent 32a0b87e9e
commit 112c5f8e46
42 changed files with 1121 additions and 304 deletions
+94 -89
View File
@@ -16,6 +16,7 @@ import (
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/wechat"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// OrderService 订单与退款业务服务。
@@ -81,6 +82,8 @@ func (s *OrderService) Create(ctx context.Context, userID int64, request *dto.Or
PayChannel: PayChannelWechat,
OrderStatus: OrderStatusPending,
MemberDays: goods.MemberDays,
GoodsType: goods.GoodsType,
QuotaValidDays: goods.ValidDays,
CosQuota: goods.CosQuota,
AIRecommendQuota: goods.AIRecommendQuota,
ClosetShareQuota: goods.ClosetShareQuota,
@@ -358,43 +361,35 @@ func (s *OrderService) RefundNotify(ctx context.Context, request *http.Request)
}
return errcode.DatabaseError
}
// 已终态则幂等返回。
if flow.Status == RefundStatusSuccess || flow.Status == RefundStatusFailed {
if flow.Status == RefundStatusFailed {
return nil
}
// 校验回调退款金额与流水一致,防止金额被篡改。
if data.Amount.Refund != flow.RefundAmount {
slog.ErrorContext(ctx, "refund notify: amount mismatch",
"out_refund_no", data.OutRefundNo,
"notify_amount", data.Amount.Refund,
"flow_amount", flow.RefundAmount,
)
slog.ErrorContext(ctx, "refund notify: amount mismatch", "out_refund_no", data.OutRefundNo, "notify_amount", data.Amount.Refund, "flow_amount", flow.RefundAmount)
return nil
}
if data.RefundStatus == "SUCCESS" {
// 条件流转,防止并发/乱序回调覆盖状态。
ok, _ := s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, data.OutRefundNo, flow.Status, map[string]any{
"status": RefundStatusSuccess,
"success_time": nowUnix(),
"callback_raw": data.Raw,
})
if !ok {
return nil // 已被并发回调处理。
if flow.Status != RefundStatusSuccess {
ok, uerr := s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, data.OutRefundNo, flow.Status, map[string]any{"status": RefundStatusSuccess, "success_time": nowUnix(), "callback_raw": data.Raw})
if uerr != nil {
return errcode.DatabaseError
}
if !ok {
return errcode.DatabaseError
}
}
order, gerr := s.orderDao.OrderGetByOrderNo(ctx, data.OutTradeNo)
if gerr == nil {
s.settleRefundSuccess(ctx, order, flow.RefundAmount)
if gerr != nil {
return errcode.DatabaseError
}
if err := s.settleRefundSuccess(ctx, order, flow.RefundAmount); err != nil {
return errcode.DatabaseError
}
} else if flow.Status != RefundStatusSuccess {
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 {
return errcode.DatabaseError
}
} else {
_, _ = s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, data.OutRefundNo, flow.Status, map[string]any{
"status": RefundStatusFailed,
"fail_reason": "微信退款未成功:" + data.RefundStatus,
"callback_raw": data.Raw,
})
}
return nil
}
@@ -538,23 +533,30 @@ func (s *OrderService) grantBenefitIdempotent(ctx context.Context, order *mysqlm
// executeGrant 认领并发放单个权益任务(claim 保证同一任务仅发放一次,防并发重复发放)。
func (s *OrderService) executeGrant(ctx context.Context, task *mysqlmodel.BenefitGrantTask) {
taskDao := mysqldao.NewBenefitGrantTaskDao()
claimed, err := taskDao.BenefitGrantTaskClaim(ctx, task.ID)
if err != nil || !claimed {
return // 已被其他实例处理。
var grantErr error
err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order mysqlmodel.Order
if err := tx.Select("id", "order_status").Clauses(clause.Locking{Strength: "UPDATE"}).Where("order_no = ?", task.OrderNo).First(&order).Error; err != nil {
return err
}
if order.OrderStatus != OrderStatusPaid {
return tx.Model(&mysqlmodel.BenefitGrantTask{}).Where("id = ? AND status IN ?", task.ID, []int8{BenefitTaskPending, BenefitTaskFailed}).Update("status", BenefitTaskCancelled).Error
}
res := tx.Model(&mysqlmodel.BenefitGrantTask{}).Where("id = ? AND status IN ?", task.ID, []int8{BenefitTaskPending, BenefitTaskFailed}).Update("status", BenefitTaskProcessing)
if res.Error != nil || res.RowsAffected == 0 {
return res.Error
}
if e := s.doGrantBenefit(ctx, task); e != nil {
grantErr = e
return tx.Model(&mysqlmodel.BenefitGrantTask{}).Where("id = ?", task.ID).Updates(map[string]any{"status": BenefitTaskPending, "retry_count": gorm.Expr("retry_count + 1"), "last_error": e.Error()}).Error
}
return tx.Model(&mysqlmodel.BenefitGrantTask{}).Where("id = ?", task.ID).Updates(map[string]any{"status": BenefitTaskSuccess, "last_error": ""}).Error
})
if err != nil {
slog.ErrorContext(ctx, "grant benefit task update failed", "order_no", task.OrderNo, "benefit_type", task.BenefitType, "error", err)
} else if grantErr != nil {
slog.ErrorContext(ctx, "grant benefit failed", "order_no", task.OrderNo, "benefit_type", task.BenefitType, "error", grantErr)
}
if e := s.doGrantBenefit(ctx, task); e != nil {
// 发放失败:回退为待发放并记录错误,交由补偿任务重试。
_ = taskDao.BenefitGrantTaskUpdateByID(ctx, task.ID, map[string]any{
"status": BenefitTaskPending,
"retry_count": gorm.Expr("retry_count + 1"),
"last_error": e.Error(),
})
slog.ErrorContext(ctx, "grant benefit failed",
"order_no", task.OrderNo, "benefit_type", task.BenefitType, "error", e)
return
}
_ = taskDao.BenefitGrantTaskUpdateByID(ctx, task.ID, map[string]any{"status": BenefitTaskSuccess})
}
// doGrantBenefit 根据补偿任务执行实际权益发放:VIP 实际发放,额度类写入有有效期凭证。
@@ -587,10 +589,22 @@ func (s *OrderService) grantQuotaGrant(ctx context.Context, task *mysqlmodel.Ben
expireTime := int64(0) // 0 表示跟随 VIP
reason := "购买会员赠送"
if goods, err := s.goodsDao.GoodsGetByID(ctx, task.GoodsID); err == nil && goods.GoodsType != GoodsTypeMember {
// 加量包:独立有效期,从发放时刻起算 valid_days 天;未配置则兜底 30 天。
order, err := s.orderDao.OrderGetByOrderNo(ctx, task.OrderNo)
if err != nil {
return errcode.DatabaseError
}
goodsType, validDays := order.GoodsType, order.QuotaValidDays
if goodsType == 0 { // 兼容迁移前创建、尚未回填的历史订单。
goods, gerr := s.goodsDao.GoodsGetByID(ctx, task.GoodsID)
if gerr != nil {
return errcode.DatabaseError
}
goodsType, validDays = goods.GoodsType, goods.ValidDays
}
if goodsType != GoodsTypeMember {
// 加量包:独立有效期,从发放时刻起算快照 valid_days 天;未配置则兜底 30 天。
source = QuotaGrantSourceAddon
days := goods.ValidDays
days := validDays
if days <= 0 {
days = 30
}
@@ -625,7 +639,14 @@ func (s *OrderService) grantQuotaGrant(ctx context.Context, task *mysqlmodel.Ben
QuotaType: quotaType,
Reason: reason,
}
if _, err := mysqldao.NewQuotaGrantDao().QuotaGrantCreateWithFlow(ctx, grant, flow); err != nil {
grantDao := mysqldao.NewQuotaGrantDao()
if source == QuotaGrantSourceMember {
// 同一用户、同一会员包只赠送一次;续费和补偿重试共用事务内检查。
_, err = grantDao.MemberQuotaGrantCreateWithFlow(ctx, task.GoodsID, grant, flow)
} else {
_, err = grantDao.QuotaGrantCreateWithFlow(ctx, grant, flow)
}
if err != nil {
return errcode.DatabaseError
}
return nil
@@ -674,46 +695,42 @@ func (s *OrderService) grantVIP(ctx context.Context, userID, memberDays int64, o
// settleRefundSuccess 退款成功后将订单置为已退款、回退已发放权益并记录退款信息,发送通知。
// 兼容两种退款来源:已支付订单(Paid->Refunded,需回收已发放权益)与已取消但已付款订单
// (Cancelled->Refunded,其权益从未发放,跳过回收避免误扣其它来源的会员天数/额度凭证)。
func (s *OrderService) settleRefundSuccess(ctx context.Context, order *mysqlmodel.Order, refundAmount int64) {
from := order.OrderStatus
if from != OrderStatusPaid && from != OrderStatusCancelled {
return
func (s *OrderService) settleRefundSuccess(ctx context.Context, order *mysqlmodel.Order, refundAmount int64) error {
settled, err := mysqldao.NewBenefitRevokeTaskDao().RefundSettleWithRevokeTask(ctx, order.OrderNo, refundAmount, nowUnix())
if err != nil {
return err
}
ok, _ := s.orderDao.OrderUpdateStatusByOrderNo(ctx, order.OrderNo, from, OrderStatusRefunded)
if !ok {
return
if settled {
sendNotification(ctx, order.UserID, 0, NotificationTypeOrder, "退款成功", "订单「"+order.GoodsName+"」已退款")
}
_ = s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{
"refund_time": nowUnix(),
"refund_amount": refundAmount,
})
// 仅已支付订单回退已发放权益;已取消订单权益从未发放,无需回收。
if from == OrderStatusPaid {
s.revokeOrderBenefits(ctx, order)
}
sendNotification(ctx, order.UserID, 0, NotificationTypeOrder, "退款成功", "订单「"+order.GoodsName+"」已退款")
// 立即尝试;失败时任务仍为待处理,由定时任务恢复。
s.executeRevokeTaskByOrder(ctx, order.OrderNo)
return nil
}
// revokeOrderBenefits 退款成功后回退该订单发放的会员天数并失效赠送额度凭证。
// 会员天数走 revokeVIP;额度类通过失效凭证回收(未用部分回收,已用部分不追回,凭证软删除后不计入有效额度),
// 并在同一事务内写「退款回收」流水,保证额度明细能看到回收记录。
func (s *OrderService) revokeOrderBenefits(ctx context.Context, order *mysqlmodel.Order) {
func (s *OrderService) revokeOrderBenefits(ctx context.Context, order *mysqlmodel.Order) error {
if order.MemberDays > 0 {
s.revokeVIP(ctx, order.UserID, order.MemberDays, order.OrderNo)
if err := s.revokeVIP(ctx, order.UserID, order.MemberDays, order.OrderNo); err != nil {
return err
}
}
flows := s.buildQuotaRevokeFlows(ctx, order)
if err := mysqldao.NewQuotaGrantDao().QuotaGrantInvalidateByRefIDWithFlows(ctx, order.OrderNo, flows); err != nil {
slog.WarnContext(ctx, "invalidate quota grants failed", "order_no", order.OrderNo, "error", err)
flows, err := s.buildQuotaRevokeFlows(ctx, order)
if err != nil {
return err
}
return mysqldao.NewQuotaGrantDao().QuotaGrantInvalidateByRefIDWithFlows(ctx, order.OrderNo, flows)
}
// buildQuotaRevokeFlows 构造退款回收流水:按该订单的额度凭证逐类记录回收量。
// 已用部分不追回,故变更量只记未用量 (amount - used);已全部用掉的凭证不写流水。
func (s *OrderService) buildQuotaRevokeFlows(ctx context.Context, order *mysqlmodel.Order) []*mysqlmodel.QuotaFlowLog {
func (s *OrderService) buildQuotaRevokeFlows(ctx context.Context, order *mysqlmodel.Order) ([]*mysqlmodel.QuotaFlowLog, error) {
grants, err := mysqldao.NewQuotaGrantDao().QuotaGrantListByRefID(ctx, order.OrderNo)
if err != nil {
slog.WarnContext(ctx, "list quota grants for revoke failed", "order_no", order.OrderNo, "error", err)
return nil
return nil, err
}
flows := make([]*mysqlmodel.QuotaFlowLog, 0, len(grants))
@@ -747,33 +764,21 @@ func (s *OrderService) buildQuotaRevokeFlows(ctx context.Context, order *mysqlmo
Reason: "退款回收未使用的赠送额度",
})
}
return flows
return flows, nil
}
// revokeVIP 幂等回退会员天数(退款回收)。
func (s *OrderService) revokeVIP(ctx context.Context, userID, memberDays int64, orderNo string) {
func (s *OrderService) revokeVIP(ctx context.Context, userID, memberDays int64, orderNo string) error {
user, err := s.userDao.UserGetByID(ctx, userID)
if err != nil {
slog.ErrorContext(ctx, "revoke vip: get user failed", "user_id", userID, "error", err)
return
return err
}
flow := &mysqlmodel.VIPFlowLog{
UserID: userID,
ChangeType: VIPChangeTypeRefundRevoke,
OldVIPType: user.VIPType,
NewVIPType: user.VIPType,
OldVIPExpire: user.VIPExpire,
OrderNo: orderNo,
Source: VIPSourceSystem,
Reason: "退款回收会员",
flow := &mysqlmodel.VIPFlowLog{UserID: userID, ChangeType: VIPChangeTypeRefundRevoke, OldVIPType: user.VIPType, NewVIPType: user.VIPType, OldVIPExpire: user.VIPExpire, OrderNo: orderNo, Source: VIPSourceSystem, Reason: "退款回收会员"}
if err := s.userDao.VIPRevokeIdempotent(ctx, userID, nowUnix(), memberDays, flow); err != nil && !isDuplicateKey(err) {
return err
}
if err := s.userDao.VIPRevokeIdempotent(ctx, userID, nowUnix(), memberDays, flow); err != nil {
if !isDuplicateKey(err) {
slog.ErrorContext(ctx, "revoke vip failed", "order_no", orderNo, "error", err)
}
}
// 会员信息已变更,失效缓存。
_ = redisdao.UserInfoDel(ctx, userID)
return nil
}
// buildOrderResponse 将订单模型映射为响应。