package activity import ( "bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/logger" "bindbox-game/internal/repository/mysql" "bindbox-game/internal/repository/mysql/dao" "bindbox-game/internal/repository/mysql/model" titlesvc "bindbox-game/internal/service/title" usersvc "bindbox-game/internal/service/user" "context" "encoding/json" "fmt" "time" "gorm.io/gorm/clause" ) // ActivityOrderService 活动订单创建服务 // 统一处理一番赏和对对碰的订单创建逻辑 type ActivityOrderService interface { // CreateActivityOrder 创建活动订单 // 统一处理优惠券、称号折扣、道具卡记录等 CreateActivityOrder(ctx core.Context, req CreateActivityOrderRequest) (*CreateActivityOrderResult, error) } // CreateActivityOrderRequest 订单创建请求 type CreateActivityOrderRequest struct { UserID int64 // 用户ID ActivityID int64 // 活动ID IssueID int64 // 期ID Count int64 // 数量 UnitPrice int64 // 单价(分) SourceType int32 // 订单来源类型: 2=抽奖, 3=对对碰 CouponID *int64 // 优惠券ID(可选) ItemCardID *int64 // 道具卡ID(可选) ExtraRemark string // 额外备注信息 } // CreateActivityOrderResult 订单创建结果 type CreateActivityOrderResult struct { Order *model.Orders // 创建的订单 AppliedCouponVal int64 // 应用的优惠券抵扣金额 } type activityOrderService struct { logger logger.CustomLogger readDB *dao.Query writeDB *dao.Query repo mysql.Repo title titlesvc.Service user usersvc.Service } // NewActivityOrderService 创建活动订单服务 func NewActivityOrderService(l logger.CustomLogger, db mysql.Repo) ActivityOrderService { return &activityOrderService{ logger: l, readDB: dao.Use(db.GetDbR()), writeDB: dao.Use(db.GetDbW()), repo: db, title: titlesvc.New(l, db), user: usersvc.New(l, db), } } // CreateActivityOrder 创建活动订单 func (s *activityOrderService) CreateActivityOrder(ctx core.Context, req CreateActivityOrderRequest) (*CreateActivityOrderResult, error) { userID := req.UserID count := req.Count if count <= 0 { count = 1 } total := req.UnitPrice * count // 1. 创建订单基础信息 orderNo := fmt.Sprintf("O%s%03d", time.Now().Format("20060102150405"), time.Now().UnixNano()%1000) order := &model.Orders{ UserID: userID, OrderNo: orderNo, SourceType: req.SourceType, TotalAmount: total, ActualAmount: total, Status: 1, // Pending CreatedAt: time.Now(), UpdatedAt: time.Now(), } // 设置备注 if req.ExtraRemark != "" { order.Remark = req.ExtraRemark } else { order.Remark = fmt.Sprintf("activity:%d|issue:%d|count:%d", req.ActivityID, req.IssueID, count) } // 记录优惠券和道具卡信息(显式字段 + 备注追加) if req.CouponID != nil && *req.CouponID > 0 { order.CouponID = *req.CouponID order.Remark += fmt.Sprintf("|coupon:%d", *req.CouponID) } if req.ItemCardID != nil && *req.ItemCardID > 0 { order.ItemCardID = *req.ItemCardID order.Remark += fmt.Sprintf("|itemcard:%d", *req.ItemCardID) } // 2. 应用称号折扣 (Title Discount) // Title effects logic usually doesn't involve race conditions on balance, so we keep it outside/before critical section if possible, // or inside. Since it's read-only mostly, good to keep. // NOTE: If title service needs transaction, we might need to refactor it. For now assuming it's safe. titleEffects, _ := s.title.ResolveActiveEffects(ctx.RequestContext(), userID, titlesvc.EffectScope{ ActivityID: &req.ActivityID, IssueID: &req.IssueID, }) for _, ef := range titleEffects { if ef.EffectType == 2 { // Discount effect var p struct { DiscountType string `json:"discount_type"` ValueX1000 int64 `json:"value_x1000"` MaxDiscountX1000 int64 `json:"max_discount_x1000"` } if jsonErr := json.Unmarshal([]byte(ef.ParamsJSON), &p); jsonErr == nil { var discount int64 if p.DiscountType == "percentage" { discount = order.ActualAmount * p.ValueX1000 / 1000 } else if p.DiscountType == "fixed" { discount = p.ValueX1000 } if p.MaxDiscountX1000 > 0 && discount > p.MaxDiscountX1000 { discount = p.MaxDiscountX1000 } if discount > order.ActualAmount { discount = order.ActualAmount } if discount > 0 { order.ActualAmount -= discount order.Remark += fmt.Sprintf("|title_discount:%d:%d", ef.ID, discount) } } } } var appliedCouponVal int64 // 开启事务处理订单创建与优惠券扣减 err := s.writeDB.Transaction(func(tx *dao.Query) error { var deductionOp func(int64) error // 3. 应用优惠券 (Lock & Calculate) if req.CouponID != nil && *req.CouponID > 0 { fmt.Printf("[订单服务] 尝试优惠券 用户券ID=%d 应用前实付(分)=%d\n", *req.CouponID, order.ActualAmount) val, op, err := s.applyCouponWithLock(ctx.RequestContext(), tx, userID, order, req.ActivityID, *req.CouponID) if err != nil { return err } appliedCouponVal = val deductionOp = op fmt.Printf("[订单服务] 优惠后 实付(分)=%d 累计优惠(分)=%d\n", order.ActualAmount, order.DiscountAmount) } // 4. 记录道具卡到备注 if req.ItemCardID != nil && *req.ItemCardID > 0 { fmt.Printf("[订单服务] 尝试道具卡 用户卡ID=%d 订单号=%s\n", *req.ItemCardID, order.OrderNo) } // 5. 保存订单 if err := tx.Orders.WithContext(ctx.RequestContext()).Omit(tx.Orders.PaidAt, tx.Orders.CancelledAt).Create(order); err != nil { return err } // Execute deferred deduction now that we have Order ID if deductionOp != nil { if err := deductionOp(order.ID); err != nil { return err } } // 6. 记录优惠券使用明细 if appliedCouponVal > 0 && req.CouponID != nil && *req.CouponID > 0 { err := tx.OrderCoupons.WithContext(ctx.RequestContext()).UnderlyingDB().Exec( "INSERT IGNORE INTO order_coupons (order_id, user_coupon_id, applied_amount, created_at) VALUES (?,?,?,NOW(3))", order.ID, *req.CouponID, appliedCouponVal).Error if err != nil { return err } } // 7. 处理0元订单自动支付 if order.ActualAmount == 0 { now := time.Now() _, _ = tx.Orders.WithContext(ctx.RequestContext()).Where(tx.Orders.OrderNo.Eq(orderNo)).Updates(map[string]any{ tx.Orders.Status.ColumnName().String(): 2, tx.Orders.PaidAt.ColumnName().String(): now, }) order.Status = 2 s.consumeCouponOnZeroPayTx(ctx.RequestContext(), tx, userID, order.ID, *req.CouponID, appliedCouponVal, now) } return nil }) if err != nil { fmt.Printf("[订单服务] 创建订单失败: %v\n", err) return nil, err } fmt.Printf("[订单服务] 订单创建完成 订单号=%s 总额(分)=%d 优惠(分)=%d 实付(分)=%d 状态=%d\n", order.OrderNo, order.TotalAmount, order.DiscountAmount, order.ActualAmount, order.Status) return &CreateActivityOrderResult{ Order: order, AppliedCouponVal: appliedCouponVal, }, nil } // applyCouponWithLock 锁定计算并返回扣减操作闭包 // 逻辑:锁定行 -> 计算优惠 -> 返回闭包(闭包内执行 UPDATE Balance + Insert Ledger) func (s *activityOrderService) applyCouponWithLock(ctx context.Context, tx *dao.Query, userID int64, order *model.Orders, activityID int64, userCouponID int64) (int64, func(int64) error, error) { // 使用 SELECT ... FOR UPDATE 锁定行 uc, _ := tx.UserCoupons.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where( tx.UserCoupons.ID.Eq(userCouponID), tx.UserCoupons.UserID.Eq(userID), ).First() if uc == nil { return 0, nil, nil } // 重新检查状态 (status must be 1=Active, or maybe 4 if we allow concurrent usage but that's complex. Let's strict to 1 for new orders) // 如果是金额券,status=1。 // 如果是满减券,status=1。 if uc.Status != 1 { fmt.Printf("[优惠券] 状态不可用 id=%d status=%d\n", userCouponID, uc.Status) return 0, nil, nil } fmt.Printf("[优惠券] 用户券ID=%d 开始=%s 结束=%s 余额=%d\n", userCouponID, uc.ValidStart.Format(time.RFC3339), func() string { if uc.ValidEnd.IsZero() { return "无截止" } return uc.ValidEnd.Format(time.RFC3339) }(), uc.BalanceAmount) sc, _ := tx.SystemCoupons.WithContext(ctx).Where(tx.SystemCoupons.ID.Eq(uc.CouponID), tx.SystemCoupons.Status.Eq(1)).First() now := time.Now() if sc == nil { fmt.Printf("[优惠券] 模板不存在 用户券ID=%d\n", userCouponID) return 0, nil, nil } if uc.ValidStart.After(now) { fmt.Printf("[优惠券] 未到开始时间 用户券ID=%d\n", userCouponID) return 0, nil, nil } if !uc.ValidEnd.IsZero() && uc.ValidEnd.Before(now) { fmt.Printf("[优惠券] 已过期 用户券ID=%d\n", userCouponID) return 0, nil, nil } scopeOK := (sc.ScopeType == 1) || (sc.ScopeType == 2 && sc.ActivityID == activityID) if !scopeOK { fmt.Printf("[优惠券] 范围不匹配 用户券ID=%d scope_type=%d\n", userCouponID, sc.ScopeType) return 0, nil, nil } if order.TotalAmount < sc.MinSpend { fmt.Printf("[优惠券] 未达使用门槛 用户券ID=%d min_spend(分)=%d 订单总额(分)=%d\n", userCouponID, sc.MinSpend, order.TotalAmount) return 0, nil, nil } // 50% 封顶 cap := order.TotalAmount / 2 remainingCap := cap - order.DiscountAmount if remainingCap <= 0 { fmt.Printf("[优惠券] 已达封顶\n") return 0, nil, nil } applied := int64(0) switch sc.DiscountType { case 1: // 金额券 (Atomic Deduction) var bal = uc.BalanceAmount if bal > 0 { if bal > remainingCap { applied = remainingCap } else { applied = bal } } case 2: // 满减券 applied = sc.DiscountValue if applied > remainingCap { applied = remainingCap } case 3: // 折扣券 rate := sc.DiscountValue if rate < 0 { rate = 0 } if rate > 1000 { rate = 1000 } newAmt := order.ActualAmount * rate / 1000 d := order.ActualAmount - newAmt if d > remainingCap { applied = remainingCap } else { applied = d } } if applied > order.ActualAmount { applied = order.ActualAmount } if applied <= 0 { return 0, nil, nil } // Update order struct order.ActualAmount -= applied order.DiscountAmount += applied order.Remark += fmt.Sprintf("|c:%d:%d", userCouponID, applied) fmt.Printf("[优惠券] 本次抵扣(分)=%d 余额更新扣减(Defer)\n", applied) // Construct deferred operation op := func(orderID int64) error { if sc.DiscountType == 1 { // 金额券:扣余额 newBal := uc.BalanceAmount - applied newStatus := int32(1) if newBal <= 0 { newBal = 0 newStatus = 2 // Used/Exhausted } // 使用乐观锁或直接 Update,因为我们已经加了行锁 (FOR UPDATE) res := tx.UserCoupons.WithContext(ctx).UnderlyingDB().Exec( "UPDATE user_coupons SET balance_amount = ?, status = ? WHERE id = ?", newBal, newStatus, userCouponID) if res.Error != nil { return res.Error } // 记录扣减流水 _ = tx.UserCouponLedger.WithContext(ctx).Create(&model.UserCouponLedger{ UserID: userID, UserCouponID: userCouponID, ChangeAmount: -applied, // Negative for deduction BalanceAfter: newBal, OrderID: orderID, Action: "usage", CreatedAt: time.Now(), }) } else { // 满减/折扣券:标记为冻结 (4) 以防止并在使用 // 支付成功后 -> 2 // 超时/取消 -> 1 res := tx.UserCoupons.WithContext(ctx).UnderlyingDB().Exec( "UPDATE user_coupons SET status = 4 WHERE id = ? AND status = 1", userCouponID) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return fmt.Errorf("coupon conflict for id %d", userCouponID) } // 满减券流水 _ = tx.UserCouponLedger.WithContext(ctx).Create(&model.UserCouponLedger{ UserID: userID, UserCouponID: userCouponID, ChangeAmount: 0, BalanceAfter: 0, OrderID: orderID, Action: "usage", CreatedAt: time.Now(), }) } return nil } return applied, op, nil } // consumeCouponOnZeroPayTx 0元支付时核销优惠券 (With Tx) func (s *activityOrderService) consumeCouponOnZeroPayTx(ctx context.Context, tx *dao.Query, userID int64, orderID int64, userCouponID int64, applied int64, now time.Time) { uc, _ := tx.UserCoupons.WithContext(ctx).Where(tx.UserCoupons.ID.Eq(userCouponID), tx.UserCoupons.UserID.Eq(userID)).First() if uc == nil { return } sc, _ := tx.SystemCoupons.WithContext(ctx).Where(tx.SystemCoupons.ID.Eq(uc.CouponID)).First() if sc == nil { return } // 如果是金额券,余额已经在 applyCouponWithCap 中扣减过了。 // 这里的逻辑主要是为了记录 used_order_id 等 meta 信息。 if sc.DiscountType == 1 { // 金额券 // 状态: // 如果余额 > 0 -> 状态 1 // 如果余额 = 0 -> 状态 2 // 不需要 status=4。 // 我们只需要记录用于统计的 used_order_id, used_at // 注意:amounts update has been done. _, _ = tx.UserCoupons.WithContext(ctx).Where(tx.UserCoupons.ID.Eq(userCouponID)).Updates(map[string]any{ tx.UserCoupons.UsedOrderID.ColumnName().String(): orderID, tx.UserCoupons.UsedAt.ColumnName().String(): now, }) } else { // 满减/折扣券 // Apply 时设置为 4 (Frozen) // 此时需要确认为 2 (Used) _, _ = tx.UserCoupons.WithContext(ctx).Where(tx.UserCoupons.ID.Eq(userCouponID)).Updates(map[string]any{ tx.UserCoupons.Status.ColumnName().String(): 2, tx.UserCoupons.UsedOrderID.ColumnName().String(): orderID, tx.UserCoupons.UsedAt.ColumnName().String(): now, }) } }