bindbox-game/internal/api/activity/lottery_helper.go

362 lines
12 KiB
Go
Executable File
Raw Permalink 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 app
import (
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/util/remark"
"bindbox-game/internal/repository/mysql/model"
"fmt"
"time"
)
// couponJoinResult 优惠券联合查询结果
type couponJoinResult struct {
// UserCoupon fields
UserCouponID int64 `gorm:"column:uc_id"`
CouponID int64 `gorm:"column:coupon_id"`
ValidStart time.Time `gorm:"column:valid_start"`
ValidEnd time.Time `gorm:"column:valid_end"`
BalanceAmount int64 `gorm:"column:balance_amount"`
// SystemCoupon fields
ScopeType int32 `gorm:"column:scope_type"`
ActivityID int64 `gorm:"column:sc_activity_id"`
MinSpend int64 `gorm:"column:min_spend"`
DiscountType int32 `gorm:"column:discount_type"`
DiscountValue int64 `gorm:"column:discount_value"`
}
// applyCouponWithCap 优惠券抵扣含50%封顶与金额券部分使用)
// 功能在订单上应用一张用户券实施总价50%封顶;金额券支持"部分使用",在 remark 记录明细
// 参数:
// - ctx请求上下文
// - userID用户ID
// - order待更新的订单对象入参引用被本函数更新 discount_amount/actual_amount/remark
// - activityID活动ID用于范围校验
// - userCouponID用户持券ID
//
// 返回:本次实际应用的抵扣金额(分);如果不可用则返回错误
func (h *handler) applyCouponWithCap(ctx core.Context, userID int64, order *model.Orders, activityID int64, userCouponID int64) (int64, error) {
// 使用单次 JOIN 查询替代 3 次分离查询,减少数据库往返
var result couponJoinResult
err := h.repo.GetDbR().Raw(`
SELECT
uc.id as uc_id,
uc.coupon_id,
uc.valid_start,
uc.valid_end,
COALESCE(uc.balance_amount, 0) as balance_amount,
sc.scope_type,
sc.activity_id as sc_activity_id,
sc.min_spend,
sc.discount_type,
sc.discount_value
FROM user_coupons uc
INNER JOIN system_coupons sc ON uc.coupon_id = sc.id AND sc.status = 1
WHERE uc.id = ? AND uc.user_id = ? AND uc.status IN (1, 4)
LIMIT 1
`, userCouponID, userID).Scan(&result).Error
if err != nil {
return 0, fmt.Errorf("查询优惠券失败: %v", err)
}
if result.UserCouponID == 0 {
// 诊断原因:为什么没查到?
var statusCheck struct {
Status int32
}
if err := h.repo.GetDbR().Raw("SELECT status FROM user_coupons WHERE id = ? AND user_id = ?", userCouponID, userID).Scan(&statusCheck).Error; err == nil {
if statusCheck.Status == 2 {
return 0, fmt.Errorf("该优惠券已用完或已核销")
}
if statusCheck.Status == 3 {
return 0, fmt.Errorf("该优惠券已过期")
}
}
// 查 system_coupons
var scStatus int32
if err := h.repo.GetDbR().Raw("SELECT sc.status FROM user_coupons uc JOIN system_coupons sc ON uc.coupon_id = sc.id WHERE uc.id = ?", userCouponID).Scan(&scStatus).Error; err == nil {
if scStatus != 1 {
return 0, fmt.Errorf("该类优惠券已在系统下架")
}
}
return 0, fmt.Errorf("优惠券不存在或状态异常")
}
now := time.Now()
if result.ValidStart.After(now) {
return 0, fmt.Errorf("优惠券尚未到生效时间 (开始时间: %s)", result.ValidStart.Format("2006-01-02 15:04:05"))
}
if !result.ValidEnd.IsZero() && result.ValidEnd.Before(now) {
return 0, fmt.Errorf("优惠券已过期 (过期时间: %s)", result.ValidEnd.Format("2006-01-02 15:04:05"))
}
scopeOK := (result.ScopeType == 1) || (result.ScopeType == 2 && result.ActivityID == activityID)
if !scopeOK {
limitActivityName := ""
if result.ActivityID > 0 {
var act model.Activities
if err := h.repo.GetDbR().Where("id = ?", result.ActivityID).First(&act).Error; err == nil {
limitActivityName = act.Name
}
}
if limitActivityName != "" {
return 0, fmt.Errorf("该优惠券仅限活动【%s】使用", limitActivityName)
}
return 0, fmt.Errorf("优惠券不适用于当前活动")
}
if order.TotalAmount < result.MinSpend {
return 0, fmt.Errorf("未达到优惠券最小使用门槛 (订单金额: %d, 起用金额: %d)", order.TotalAmount, result.MinSpend)
}
// 计算封顶
capLimit := order.TotalAmount / 2 // 最高抵扣 50%
remainingCap := capLimit - order.DiscountAmount
if remainingCap <= 0 {
return 0, fmt.Errorf("该订单已达到优惠金额封顶限制 (最高优惠订单额的 50%%)")
}
applied := int64(0)
switch result.DiscountType {
case 1: // 金额券 (支持部分使用)
bal := result.BalanceAmount
if bal > 0 {
if bal > remainingCap {
applied = remainingCap // 余额够 50%,则按 50% 抵扣
} else {
applied = bal // 余额不足 50%,则全额用掉余额
}
}
case 2: // 满减券 (一次性)
applied = result.DiscountValue
if applied > remainingCap {
applied = remainingCap
}
case 3: // 折扣券 (一次性)
rate := result.DiscountValue
if rate < 0 {
rate = 0
}
if rate > 1000 {
rate = 1000
}
// 计算折扣掉的金额
d := order.ActualAmount - (order.ActualAmount * rate / 1000)
if d > remainingCap {
applied = remainingCap
} else {
applied = d
}
}
if applied > order.ActualAmount {
applied = order.ActualAmount
}
if applied <= 0 {
return 0, fmt.Errorf("当前订单状态无法再应用更多优惠 (封顶或金额不足)")
}
order.DiscountAmount += applied
order.ActualAmount -= applied
order.Remark = order.Remark + fmt.Sprintf("|c:%d:%d", userCouponID, applied)
return applied, nil
}
// preDeductCouponInTx 在事务中预扣优惠券余额
// 功能:原子性地扣减余额并设置 status=4预扣中防止并发超额使用
// 参数:
// - ctx请求上下文
// - tx数据库事务必须在事务中调用
// - userID用户ID
// - userCouponID用户持券ID
// - appliedAmount要预扣的金额
// - orderID关联的订单ID
//
// 返回:是否成功预扣
func (h *handler) preDeductCouponInTx(ctx core.Context, txDB interface {
Exec(sql string, values ...interface{}) interface {
RowsAffected() int64
Error() error
}
}, userID int64, userCouponID int64, appliedAmount int64, orderID int64) bool {
if appliedAmount <= 0 || userCouponID <= 0 {
return false
}
now := time.Now()
// 原子更新:扣减余额 + 设置状态为预扣中(4) + 关联订单
// 条件:余额足够 且 状态为未使用(1)或使用中(4支持同一券多订单分批扣减场景但需余额足够)
result := txDB.Exec(`
UPDATE user_coupons
SET balance_amount = balance_amount - ?,
status = CASE WHEN balance_amount - ? <= 0 THEN 4 ELSE 4 END,
used_order_id = ?,
used_at = ?
WHERE id = ? AND user_id = ? AND balance_amount >= ? AND status IN (1, 4)
`, appliedAmount, appliedAmount, orderID, now, userCouponID, userID, appliedAmount)
if result.Error() != nil {
return false
}
return result.RowsAffected() > 0
}
// updateUserCouponAfterApply 应用后更新用户券(扣减余额或核销)
// 功能:根据订单 remark 中记录的 applied_amount
//
// 对直金额券扣减余额并在余额为0时核销满减/折扣券一次性核销
//
// 参数:
// - ctx请求上下文
// - userID用户ID
// - order订单用于读取 remark 和写入 used_order_id
// - userCouponID用户持券ID
//
// 返回:无
func (h *handler) updateUserCouponAfterApply(ctx core.Context, userID int64, order *model.Orders, userCouponID int64) {
// 使用单次 JOIN 查询替代 3 次分离查询
var result couponJoinResult
err := h.repo.GetDbR().Raw(`
SELECT
uc.id as uc_id,
uc.coupon_id,
uc.valid_start,
uc.valid_end,
COALESCE(uc.balance_amount, 0) as balance_amount,
sc.scope_type,
sc.activity_id as sc_activity_id,
sc.min_spend,
sc.discount_type,
sc.discount_value
FROM user_coupons uc
INNER JOIN system_coupons sc ON uc.coupon_id = sc.id AND sc.status = 1
WHERE uc.id = ? AND uc.user_id = ? AND uc.status IN (1, 4)
LIMIT 1
`, userCouponID, userID).Scan(&result).Error
if err != nil || result.UserCouponID == 0 {
return
}
// 从 remark 中解析 applied amount
applied := int64(0)
remark := order.Remark
p := 0
for i := 0; i < len(remark); i++ {
if remark[i] == '|' {
seg := remark[p:i]
if len(seg) > 2 && seg[:2] == "c:" {
j := 2
var id int64
for j < len(seg) && seg[j] >= '0' && seg[j] <= '9' {
id = id*10 + int64(seg[j]-'0')
j++
}
if j < len(seg) && seg[j] == ':' {
j++
var amt int64
for j < len(seg) && seg[j] >= '0' && seg[j] <= '9' {
amt = amt*10 + int64(seg[j]-'0')
j++
}
if id == userCouponID {
applied = amt
}
}
}
p = i + 1
}
}
if result.DiscountType == 1 { // 金额券
newBal := result.BalanceAmount - applied
if newBal < 0 {
newBal = 0
}
if newBal == 0 {
_, _ = h.writeDB.UserCoupons.WithContext(ctx.RequestContext()).Where(h.writeDB.UserCoupons.ID.Eq(userCouponID), h.writeDB.UserCoupons.UserID.Eq(userID)).Updates(map[string]any{"balance_amount": newBal, h.writeDB.UserCoupons.Status.ColumnName().String(): 2, h.writeDB.UserCoupons.UsedOrderID.ColumnName().String(): order.ID, h.writeDB.UserCoupons.UsedAt.ColumnName().String(): time.Now()})
} else {
_, _ = h.writeDB.UserCoupons.WithContext(ctx.RequestContext()).Where(h.writeDB.UserCoupons.ID.Eq(userCouponID), h.writeDB.UserCoupons.UserID.Eq(userID)).Updates(map[string]any{"balance_amount": newBal, h.writeDB.UserCoupons.UsedOrderID.ColumnName().String(): order.ID, h.writeDB.UserCoupons.UsedAt.ColumnName().String(): time.Now()})
}
} else { // 满减/折扣券
_, _ = h.writeDB.UserCoupons.WithContext(ctx.RequestContext()).Where(h.writeDB.UserCoupons.ID.Eq(userCouponID), h.writeDB.UserCoupons.UserID.Eq(userID)).Updates(map[string]any{h.writeDB.UserCoupons.Status.ColumnName().String(): 2, h.writeDB.UserCoupons.UsedOrderID.ColumnName().String(): order.ID, h.writeDB.UserCoupons.UsedAt.ColumnName().String(): time.Now()})
}
}
// markOrderPaid 将订单标记为已支付
// 功能用于0元订单直接置为已支付并写入支付时间
// 参数:
// - ctx请求上下文
// - orderNo订单号
//
// 返回:无
func (h *handler) markOrderPaid(ctx core.Context, orderNo string) {
now := time.Now()
_, _ = h.writeDB.Orders.WithContext(ctx.RequestContext()).Where(h.writeDB.Orders.OrderNo.Eq(orderNo)).Updates(map[string]any{h.writeDB.Orders.Status.ColumnName().String(): 2, h.writeDB.Orders.PaidAt.ColumnName().String(): now})
}
func (h *handler) randomID(prefix string) string {
now := time.Now()
// 增加随机数防止同1秒内并发生成相同的ID
return fmt.Sprintf("%s%s%03d", prefix, now.Format("20060102150405"), time.Now().UnixNano()%1000)
}
func (h *handler) orderModel(userID int64, orderNo string, amount int64, activityID int64, issueID int64, count int64) *model.Orders {
return &model.Orders{UserID: userID, OrderNo: orderNo, SourceType: 2, TotalAmount: amount, DiscountAmount: 0, PointsAmount: 0, ActualAmount: amount, Status: 1, IsConsumed: 0, Remark: fmt.Sprintf("lottery:activity:%d|issue:%d|count:%d", activityID, issueID, count)}
}
func parseSlotFromRemark(remarkStr string) int64 {
rmk := remark.Parse(remarkStr)
if len(rmk.Slots) > 0 {
return rmk.Slots[0].SlotIndex
}
return -1
}
func parseItemCardIDFromRemark(remarkStr string) int64 {
return remark.Parse(remarkStr).ItemCardID
}
func (h *handler) joinSigPayload(userID int64, issueID int64, ts int64, nonce int64) string {
return fmt.Sprintf("%d|%d|%d|%d", userID, issueID, ts, nonce)
}
func buildSlotsRemarkWithScalarCount(slots []int64) string {
s := ""
for i := range slots {
if i > 0 {
s += ","
}
s += fmt.Sprintf("%d:%d", slots[i]-1, 1)
}
return s
}
func parseSlotsCountsFromRemark(remarkStr string) ([]int64, []int64) {
rmk := remark.Parse(remarkStr)
idxs := make([]int64, 0, len(rmk.Slots))
cnts := make([]int64, 0, len(rmk.Slots))
for _, s := range rmk.Slots {
idxs = append(idxs, s.SlotIndex+1) // 补偿前端展示逻辑,保持原样
cnts = append(cnts, s.Count)
}
return idxs, cnts
}
func parseIssueIDFromRemark(remarkStr string) int64 {
return remark.Parse(remarkStr).IssueID
}
func parseCountFromRemark(remarkStr string) int64 {
return remark.Parse(remarkStr).Count
}
// shouldTriggerInstantDraw 判断是否应该触发即时开奖
// 功能:封装即时开奖触发条件判断,避免条件重复
// 参数:
// - orderStatus订单状态2=已支付)
// - drawMode开奖模式"instant"=即时开奖)
//
// 返回:是否应该触发即时开奖
func shouldTriggerInstantDraw(orderStatus int32, drawMode string) bool {
return orderStatus == 2 && drawMode == "instant"
}