bindbox-game/internal/service/activity/activity_order_service.go
2026-01-27 01:33:32 +08:00

414 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
})
}
}