package order import ( "context" "fmt" "strings" "time" "github.com/perfect-panel/server/internal/model/log" modelorder "github.com/perfect-panel/server/internal/model/order" modeluser "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/pkg/constant" "github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/xerr" "github.com/pkg/errors" "gorm.io/gorm" "gorm.io/gorm/clause" ) const ( orderStatusRefunded = 6 ) type RefundOrderLogic struct { logger.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewRefundOrderLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RefundOrderLogic { return &RefundOrderLogic{ Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx, } } func (l *RefundOrderLogic) RefundOrder(req *types.RefundOrderRequest) error { operator, ok := l.ctx.Value(constant.CtxKeyUser).(*modeluser.User) if !ok { return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") } reason := strings.TrimSpace(req.Reason) var cachesToClear []*modeluser.Subscribe var planCacheIDs []int64 var userCacheTargets []*modeluser.User err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error { var orderInfo modelorder.Order if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Model(&modelorder.Order{}). Where("id = ?", req.Id). First(&orderInfo).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.Wrapf(xerr.NewErrCode(xerr.OrderNotExist), "order %d not found", req.Id) } return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query order failed: %v", err) } if orderInfo.Status == orderStatusRefunded { return errors.Wrapf(xerr.NewErrCode(xerr.OrderAlreadyRefunded), "order %d already refunded", orderInfo.Id) } if orderInfo.Status != 2 && orderInfo.Status != 5 { return errors.Wrapf(xerr.NewErrCode(xerr.OrderStatusError), "order %d status %d is not refundable", orderInfo.Id, orderInfo.Status) } // 幂等校验:若该 order_no 已存在 333 退款日志,拒绝再次退款。 // HIF-131 案例:订单状态被外部入口(stuckOrderRecovery 把 6 视为卡住的 claim)回退到 5, // 让 lockCommissionSource 误抓到原始 331/332 amount 再次扣减佣金。 refunded, err := l.hasRefundLog(tx, orderInfo.OrderNo) if err != nil { return err } if refunded { return errors.Wrapf(xerr.NewErrCode(xerr.OrderAlreadyRefunded), "order %d already has refund commission log", orderInfo.Id) } userSub, err := l.lockRefundTargetSubscription(tx, &orderInfo) if err != nil { return err } orderStatusBefore := orderInfo.Status subStatusBefore := userSub.Status subExpireBefore := userSub.ExpireTime now := time.Now() referer, commissionAmount, err := l.lockCommissionSource(tx, orderInfo.OrderNo, orderInfo.Commission) if err != nil { return err } var commissionBefore int64 var commissionAfter int64 if referer != nil { commissionBefore = referer.Commission commissionAfter = referer.Commission - commissionAmount if err := tx.Model(&modeluser.User{}). Where("id = ?", referer.Id). UpdateColumn("commission", gorm.Expr("commission - ?", commissionAmount)).Error; err != nil { return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "refund commission failed: %v", err) } commissionLog := log.Commission{ Type: log.CommissionTypeRefund, Amount: -commissionAmount, OrderNo: orderInfo.OrderNo, Timestamp: now.UnixMilli(), } content, marshalErr := commissionLog.Marshal() if marshalErr != nil { return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "marshal commission refund log failed: %v", marshalErr) } if err := tx.Model(&log.SystemLog{}).Create(&log.SystemLog{ Type: log.TypeCommission.Uint8(), Date: now.Format(time.DateOnly), ObjectID: referer.Id, Content: string(content), CreatedAt: now, }).Error; err != nil { return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "insert commission refund log failed: %v", err) } referer.Commission = commissionAfter userCacheTargets = append(userCacheTargets, referer) } if err := tx.Model(&modelorder.Order{}). Where("id = ? AND status IN ?", orderInfo.Id, []int{2, 5}). Update("status", orderStatusRefunded).Error; err != nil { return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "refund order status update failed: %v", err) } orderInfo.Status = orderStatusRefunded userSub.Status = 3 userSub.ExpireTime = now.Add(-time.Second) userSub.FinishedAt = &now if err := tx.Model(&modeluser.Subscribe{}). Where("id = ?", userSub.Id). Updates(map[string]interface{}{ "status": userSub.Status, "expire_time": userSub.ExpireTime, "finished_at": userSub.FinishedAt, }).Error; err != nil { return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update subscription failed: %v", err) } refundLog, err := l.buildRefundAuditLog(operator, &orderInfo, userSub, referer, commissionAmount, reason, orderStatusBefore, subStatusBefore, subExpireBefore, commissionBefore, commissionAfter, now) if err != nil { return err } logContent, err := refundLog.Marshal() if err != nil { return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "marshal refund audit log failed: %v", err) } if err := tx.Model(&log.SystemLog{}).Create(&log.SystemLog{ Type: log.TypeOrderRefund.Uint8(), Date: now.Format(time.DateOnly), ObjectID: orderInfo.Id, Content: string(logContent), CreatedAt: now, }).Error; err != nil { return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "insert refund audit log failed: %v", err) } cachesToClear = append(cachesToClear, userSub) if userSub.SubscribeId > 0 { planCacheIDs = append(planCacheIDs, userSub.SubscribeId) } if orderInfo.UserId > 0 { orderUser := &modeluser.User{Id: orderInfo.UserId} userCacheTargets = append(userCacheTargets, orderUser) } return nil }) if err != nil { l.Errorw("[RefundOrder] refund failed", logger.Field("error", err.Error()), logger.Field("order_id", req.Id)) return err } if len(cachesToClear) > 0 { if clearErr := l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, cachesToClear...); clearErr != nil { l.Errorw("[RefundOrder] clear subscribe cache failed", logger.Field("error", clearErr.Error()), logger.Field("order_id", req.Id)) } } for _, subscribeID := range planCacheIDs { if clearErr := l.svcCtx.SubscribeModel.ClearCache(l.ctx, subscribeID); clearErr != nil { l.Errorw("[RefundOrder] clear subscribe cache failed", logger.Field("error", clearErr.Error()), logger.Field("subscribe_id", subscribeID)) } } if len(userCacheTargets) > 0 { if clearErr := l.svcCtx.UserModel.ClearUserCache(l.ctx, userCacheTargets...); clearErr != nil { l.Errorw("[RefundOrder] clear user cache failed", logger.Field("error", clearErr.Error())) } } return nil } func (l *RefundOrderLogic) lockRefundTargetSubscription(tx *gorm.DB, orderInfo *modelorder.Order) (*modeluser.Subscribe, error) { var userSub modeluser.Subscribe err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Model(&modeluser.Subscribe{}). Where("order_id = ?", orderInfo.Id). First(&userSub).Error if err == nil { return &userSub, nil } if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query refund subscription failed: %v", err) } if orderInfo.Type != 2 { return nil, errors.Wrapf(xerr.NewErrCode(xerr.OrderRefundNoSubscription), "order %d has no linked subscription", orderInfo.Id) } if orderInfo.ParentId == 0 { return nil, errors.Wrapf(xerr.NewErrCode(xerr.OrderRefundNoSubscription), "renewal order %d has no parent order", orderInfo.Id) } err = tx.Clauses(clause.Locking{Strength: "UPDATE"}). Model(&modeluser.Subscribe{}). Where("order_id = ?", orderInfo.ParentId). First(&userSub).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.Wrapf(xerr.NewErrCode(xerr.OrderRefundNoSubscription), "renewal order %d parent subscription not found", orderInfo.Id) } return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query renewal subscription failed: %v", err) } return &userSub, nil } func (l *RefundOrderLogic) lockCommissionSource(tx *gorm.DB, orderNo string, orderCommission int64) (*modeluser.User, int64, error) { var commissionLogs []log.SystemLog if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Model(&log.SystemLog{}). Where("type = ? AND content LIKE ?", log.TypeCommission.Uint8(), fmt.Sprintf("%%\"order_no\":\"%s\"%%", orderNo)). Order("id DESC"). Find(&commissionLogs).Error; err != nil { return nil, 0, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query commission log failed: %v", err) } for _, item := range commissionLogs { var content log.Commission if err := content.Unmarshal([]byte(item.Content)); err != nil { continue } if content.Type != log.CommissionTypePurchase && content.Type != log.CommissionTypeRenewal { continue } var referer modeluser.User if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Model(&modeluser.User{}). Where("id = ?", item.ObjectID). First(&referer).Error; err != nil { return nil, 0, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query commission owner failed: %v", err) } return &referer, content.Amount, nil } if orderCommission > 0 { return nil, 0, errors.Wrapf(xerr.NewErrCode(xerr.OrderRefundCommissionMismatch), "order %s commission owner not found", orderNo) } return nil, 0, nil } // hasRefundLog 检查指定 order_no 是否已有 333 (CommissionTypeRefund) 退款佣金日志。 // 仅扫 type=33 + 内容含 order_no 的命中项,再用 JSON 二次确认 content.type==333, // 防止 content.order_no 子串误判。 func (l *RefundOrderLogic) hasRefundLog(tx *gorm.DB, orderNo string) (bool, error) { return log.HasRefundCommissionLog(tx, orderNo) } func (l *RefundOrderLogic) buildRefundAuditLog( operator *modeluser.User, orderInfo *modelorder.Order, userSub *modeluser.Subscribe, referer *modeluser.User, commissionAmount int64, reason string, orderStatusBefore uint8, subStatusBefore uint8, subExpireBefore time.Time, commissionBefore int64, commissionAfter int64, now time.Time, ) (*log.OrderRefund, error) { refundLog := &log.OrderRefund{ OrderId: orderInfo.Id, OrderNo: orderInfo.OrderNo, OperatorUserId: operator.Id, TargetUserId: userSub.UserId, UserSubscribeId: userSub.Id, CommissionAmount: commissionAmount, Reason: reason, OrderStatusBefore: orderStatusBefore, OrderStatusAfter: orderInfo.Status, SubscribeStatusBefore: subStatusBefore, SubscribeStatusAfter: userSub.Status, SubscribeExpireBefore: subExpireBefore.UnixMilli(), SubscribeExpireAfter: userSub.ExpireTime.UnixMilli(), CommissionBefore: commissionBefore, CommissionAfter: commissionAfter, Timestamp: now.UnixMilli(), } if referer != nil { refundLog.RefererUserId = referer.Id } if len(operator.AuthMethods) > 0 { refundLog.OperatorAuthIdentifier = operator.AuthMethods[0].AuthIdentifier } return refundLog, nil }