gpt
This commit is contained in:
+94
-89
@@ -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 将订单模型映射为响应。
|
||||
|
||||
Reference in New Issue
Block a user