Files
qitongxue-api/internal/logic/promo.go
2026-09-29 10:57:01 +08:00

624 lines
20 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 logic
import (
"context"
"encoding/json"
"strconv"
"strings"
"sync"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
adminv1 "tool-api/api/admin/v1"
userv1 "tool-api/api/user/v1"
"tool-api/internal/consts"
"tool-api/internal/model/entity"
)
// ============================================================================
// 优惠凭证域 · 优惠码(迭代-2026-09-20,T04.3)
//
// 覆盖:
// - 管理端 CRUD:list / save / toggle / delete / usages(A1–A5)
// - 用户端只读校验:POST /promo/validate(P1,分类报错 + 错误限流 4013)
// - 下单事务内的原子占用原语 occupyPromo(已在 order_pay.go 的 applyVoucherToOrder 内接线)
// ============================================================================
// Voucher 统一优惠凭证抽象(约定 S3)。
//
// 由优惠码 / 免单券 / 无凭证三条路径统一产出,供下单链路的订单价格快照与支付签名复用。
type Voucher struct {
ProductId string // 用券后实际走支付的道具
OriginPriceCents int64 // 商品原价(分)
PaidPriceCents int64 // 实付(分)
DiscountCents int64 // 优惠金额(分)= origin - paid
PromoKind int // 0无 1优惠码 2免单券
PromoCodeId int64
PromoCode string
UserCouponId int64
}
// ===== 错误码构造(架构 §4.5 统一裁定)=====
func gcodeOf(code int) gcode.Code { return gcode.New(code, "", nil) }
// errPromoInvalid 4010:优惠码无效/过期/达上限/超单人限用/不适用;或凭证互斥
func errPromoInvalid(msg string) error {
return gerror.NewCode(gcodeOf(consts.CodePromoInvalid), msg)
}
// errCouponUnavailable 4011:券不可用/已用/过期/作废;免单券并发占用失败
func errCouponUnavailable(msg string) error {
return gerror.NewCode(gcodeOf(consts.CodeCouponUnavailable), msg)
}
// errLevelInvalid 4012:等级不存在或已停用
func errLevelInvalid(msg string) error {
return gerror.NewCode(gcodeOf(consts.CodeLevelInvalid), msg)
}
// errPromoRateLimited 4013:同一用户 1 分钟内优惠码错误次数超限
func errPromoRateLimited(msg string) error {
return gerror.NewCode(gcodeOf(consts.CodePromoRateLimited), msg)
}
// errProductPriceMismatch 4014:服务端自检(道具价格与预期不符),不下发用户
func errProductPriceMismatch(msg string) error {
return gerror.NewCode(gcodeOf(consts.CodeProductPriceMismatch), msg)
}
// ===== settings 读取(约定 S9:配置一律走 settings 表,不写 config.yaml)=====
// freebieProductId 免单道具 ID(后台可配,默认 admin_1)
func freebieProductId(ctx context.Context) string {
if v := strings.TrimSpace(SettingValue(ctx, consts.SettingFreebieProductId)); v != "" {
return v
}
return consts.DefaultFreebieProductId
}
// freebiePriceCents 免单道具价格(分,默认 1)
func freebiePriceCents(ctx context.Context) int64 {
raw := strings.TrimSpace(SettingValue(ctx, consts.SettingFreebiePriceCents))
if n, err := strconv.ParseInt(raw, 10, 64); err == nil && n > 0 {
return n
}
return consts.DefaultFreebiePriceCents
}
// promoFailLimitPerMin 同一用户 1 分钟内优惠码错误次数上限(默认 6)
func promoFailLimitPerMin(ctx context.Context) int {
raw := strings.TrimSpace(SettingValue(ctx, consts.SettingPromoFailLimitPerMin))
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
return n
}
return consts.DefaultPromoFailLimitPerMin
}
// couponLockTtlSeconds 免单券下单占用自动释放时长(秒,默认 900)
func couponLockTtlSeconds(ctx context.Context) int {
raw := strings.TrimSpace(SettingValue(ctx, consts.SettingCouponLockTtlSeconds))
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
return n
}
return consts.DefaultCouponLockTtlSeconds
}
// ===== 纯函数(可单测)=====
// normalizePromoCode 归一化优惠码:去空白 + 转大写。
func normalizePromoCode(code string) string {
return strings.ToUpper(strings.TrimSpace(code))
}
// voucherConflictErr 凭证互斥校验(纯函数,约定 S3):
// promo_code 与 user_coupon_id **同时传** → 4010(后端校验,不靠前端隐藏)。
func voucherConflictErr(promoCode string, userCouponId int64) error {
if strings.TrimSpace(promoCode) != "" && userCouponId > 0 {
return errPromoInvalid("优惠码与优惠券不能同时使用")
}
return nil
}
// parseScopeKeys 解析适用范围 JSON 数组;解析失败返回空切片(等价于「不限制具体 key」)。
func parseScopeKeys(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return []string{}
}
keys := []string{}
if err := json.Unmarshal([]byte(raw), &keys); err != nil {
return []string{}
}
return keys
}
// scopeMatches 判断 scope + scopeKeys 是否覆盖 orderType/targetKey。
// - scope 0(全部)→ 恒真
// - scope 1(指定会员套餐)→ orderType 必须为 1 且 targetKey 命中 scopeKeys
// - scope 2(指定次数包)→ orderType 必须为 2 且 targetKey 命中 scopeKeys
//
// scopeKeys 为空视为「该 scope 下无任何适用对象」→ false(fail-closed)。
func scopeMatches(scope int, scopeKeys []string, orderType int, targetKey string) bool {
switch scope {
case consts.PromoScopeAll:
return true
case consts.PromoScopePlan:
if orderType != consts.OrderTypeMember {
return false
}
case consts.PromoScopePack:
if orderType != consts.OrderTypeQuota {
return false
}
default:
return false
}
for _, k := range scopeKeys {
if k == targetKey {
return true
}
}
return false
}
// promoPriceError 优惠码可用性判定(纯函数,不碰 DB)。
// usedByUser 由调用方查库传入(「该用户已用该码的下单数」)。
// 返回 nil 表示可用;否则返回 4010 分类错误。
func promoPriceError(p *entity.PromoCodes, now *gtime.Time, orderType int, targetKey string, usedByUser int) error {
if p == nil {
return errPromoInvalid("优惠码无效")
}
if p.Status != 1 {
return errPromoInvalid("优惠码已停用")
}
if p.ValidFrom != nil && !p.ValidFrom.IsZero() && now.Before(p.ValidFrom) {
return errPromoInvalid("优惠码尚未生效")
}
if p.ValidTo != nil && !p.ValidTo.IsZero() && now.After(p.ValidTo) {
return errPromoInvalid("优惠码已过期")
}
if p.MaxUses > 0 && p.UsedCount >= p.MaxUses {
return errPromoInvalid("优惠码已达使用上限")
}
if p.PerUserLimit > 0 && usedByUser >= p.PerUserLimit {
return errPromoInvalid("你已达到该优惠码的限用次数")
}
if !scopeMatches(p.Scope, parseScopeKeys(p.ScopeKeys), orderType, targetKey) {
return errPromoInvalid("优惠码不适用于当前商品")
}
return nil
}
// ===== 优惠码错误限流(无 Redis,进程内滑窗)=====
type promoFailWindow struct {
mu sync.Mutex
m map[int64][]time.Time
}
var promoFails = &promoFailWindow{m: map[int64][]time.Time{}}
// promoFailCount 返回该用户最近 1 分钟内的错误次数。
func promoFailCount(userId int64) int {
if userId <= 0 {
return 0
}
cutoff := time.Now().Add(-time.Minute)
promoFails.mu.Lock()
defer promoFails.mu.Unlock()
kept := make([]time.Time, 0, len(promoFails.m[userId]))
for _, t := range promoFails.m[userId] {
if t.After(cutoff) {
kept = append(kept, t)
}
}
promoFails.m[userId] = kept
return len(kept)
}
// recordPromoFailure 记一次优惠码错误。
func recordPromoFailure(userId int64) {
if userId <= 0 {
return
}
promoFails.mu.Lock()
defer promoFails.mu.Unlock()
promoFails.m[userId] = append(promoFails.m[userId], time.Now())
}
// clearPromoFailures 校验成功后清空该用户的错误窗口。
func clearPromoFailures(userId int64) {
if userId <= 0 {
return
}
promoFails.mu.Lock()
defer promoFails.mu.Unlock()
delete(promoFails.m, userId)
}
// ===== DB 辅助 =====
// orderTargetProduct 返回目标商品的道具 ID 与价格(分)。
// orderType 1=会员套餐(plan_key)/ 2=次数包(pack_key)。
func orderTargetProduct(ctx context.Context, orderType int, targetKey string) (productId string, priceCents int64, err error) {
switch orderType {
case consts.OrderTypeMember:
record, qErr := g.Model(consts.TableMemberPlans).
Where("plan_key", targetKey).Where("is_enabled", 1).One()
if qErr != nil {
return "", 0, qErr
}
if record.IsEmpty() {
return "", 0, gerror.New("会员套餐不存在或已下架")
}
return record["product_id"].String(), record["price_cents"].Int64(), nil
case consts.OrderTypeQuota:
record, qErr := g.Model(consts.TableQuotaPacks).
Where("pack_key", targetKey).Where("is_enabled", 1).One()
if qErr != nil {
return "", 0, qErr
}
if record.IsEmpty() {
return "", 0, gerror.New("次数包不存在或已下架")
}
return record["product_id"].String(), record["price_cents"].Int64(), nil
default:
return "", 0, gerror.New("订单类型非法")
}
}
func promoByCode(ctx context.Context, code string) (*entity.PromoCodes, error) {
record, err := g.Model(consts.TablePromoCodes).Where("code", code).One()
if err != nil {
return nil, err
}
if record.IsEmpty() {
return nil, nil
}
promo := &entity.PromoCodes{}
if err = record.Struct(promo); err != nil {
return nil, err
}
return promo, nil
}
// promoUsedCountByUser 该用户已用该优惠码下单的次数(仅统计未关闭的订单)。
func promoUsedCountByUser(ctx context.Context, promoId, userId int64) (int, error) {
if promoId <= 0 || userId <= 0 {
return 0, nil
}
return g.Model(consts.TableMemberOrders).
Where("promo_code_id", promoId).
Where("user_id", userId).
WhereNot("status", consts.OrderStatusClosed).
Count()
}
// ===== 用户端:/promo/validate(P1,只读,绝不占用)=====
// PromoValidate 校验优惠码:服务端为准,前端只展示。
//
// 分类报错(4010):优惠码无效 / 已停用 / 未生效 / 已过期 / 达总上限 / 超单人限用 / 不适用当前商品;
// 错误次数超过 promo.fail.limitPerMin(默认 6/分钟)→ 4013。
func PromoValidate(ctx context.Context, code string, orderType int, targetKey string) (*userv1.PromoValidateRes, error) {
userId := CtxUserId(ctx)
res := &userv1.PromoValidateRes{}
// 先判限流:已达上限直接拒绝,避免继续爆破
if promoFailCount(userId) >= promoFailLimitPerMin(ctx) {
return nil, errPromoRateLimited("优惠码尝试过于频繁,请稍后再试")
}
_, originPrice, oErr := orderTargetProduct(ctx, orderType, targetKey)
if oErr != nil {
res.Valid = false
res.Message = oErr.Error()
return res, nil
}
res.OriginPriceCents = originPrice
code = normalizePromoCode(code)
if code == "" {
recordPromoFailure(userId)
return nil, errPromoInvalid("请输入优惠码")
}
promo, err := promoByCode(ctx, code)
if err != nil {
return nil, err
}
if promo == nil {
recordPromoFailure(userId)
return nil, errPromoInvalid("优惠码无效")
}
usedByUser, err := promoUsedCountByUser(ctx, promo.Id, userId)
if err != nil {
return nil, err
}
if err = promoPriceError(promo, gtime.Now(), orderType, targetKey, usedByUser); err != nil {
recordPromoFailure(userId)
return nil, err
}
clearPromoFailures(userId)
discount := originPrice - promo.PriceCents
if discount < 0 {
discount = 0
}
res.Valid = true
res.ProductId = promo.ProductId
res.PaidPriceCents = promo.PriceCents
res.DiscountCents = discount
res.Message = "优惠码可用"
return res, nil
}
// ===== 下单事务内的原子占用原语(已在 order_pay.go 的 applyVoucherToOrder 内接线)=====
// occupyPromo 在事务内锁定并占用一个优惠码:行锁 → 校验 → used_count+1。
//
// 与订单插入同一事务(约定 S5):任一步失败整体回滚。并发同码下单时,
// 后到者会阻塞在 LockUpdate 上,拿到已自增的 used_count 后校验失败(达上限)→ 4010。
func occupyPromo(
ctx context.Context, tx gdb.TX, userId int64, orderType int, targetKey, code string, originPriceCents int64,
) (*Voucher, error) {
code = normalizePromoCode(code)
row, err := tx.Model(consts.TablePromoCodes).Where("code", code).LockUpdate().One()
if err != nil {
return nil, err
}
if row.IsEmpty() {
return nil, errPromoInvalid("优惠码无效")
}
promo := &entity.PromoCodes{}
if err = row.Struct(promo); err != nil {
return nil, err
}
usedByUser, err := promoUsedCountByUserTx(ctx, tx, promo.Id, userId)
if err != nil {
return nil, err
}
if err = promoPriceError(promo, gtime.Now(), orderType, targetKey, usedByUser); err != nil {
return nil, err
}
if _, err = tx.Model(consts.TablePromoCodes).Where("id", promo.Id).
Increment("used_count", 1); err != nil {
return nil, err
}
discount := originPriceCents - promo.PriceCents
if discount < 0 {
discount = 0
}
return &Voucher{
ProductId: promo.ProductId,
OriginPriceCents: originPriceCents,
PaidPriceCents: promo.PriceCents,
DiscountCents: discount,
PromoKind: consts.PromoKindCode,
PromoCodeId: promo.Id,
PromoCode: promo.Code,
}, nil
}
func promoUsedCountByUserTx(ctx context.Context, tx gdb.TX, promoId, userId int64) (int, error) {
if promoId <= 0 || userId <= 0 {
return 0, nil
}
return tx.Model(consts.TableMemberOrders).
Where("promo_code_id", promoId).
Where("user_id", userId).
WhereNot("status", consts.OrderStatusClosed).
Count()
}
// releasePromoOnOrder 释放订单占用的优惠码(换码 / 改回无券 / 关单时调用):used_count-1。
//
// 守卫:仅当 used_count > 0 才自减,避免脏数据把计数减成负数。与 occupyPromo 同处 order_pay.go
// 的 applyVoucherToOrder 事务内调用 → 释放旧 + 占用新原子完成(约定 S5)。
func releasePromoOnOrder(ctx context.Context, tx gdb.TX, promoCodeId int64) error {
if promoCodeId <= 0 {
return nil
}
_, err := tx.Model(consts.TablePromoCodes).
Where("id", promoCodeId).WhereGT("used_count", 0).
Decrement("used_count", 1)
return err
}
// ===== 管理端:优惠码 CRUD(A1–A5)=====
// AdminPromoList 优惠码列表
func AdminPromoList(ctx context.Context, req *adminv1.PromoListReq) (*adminv1.PromoListRes, error) {
page, pageSize := normalizePage(req.Page, req.PageSize, 20)
m := g.Model(consts.TablePromoCodes)
if kw := strings.TrimSpace(req.Keyword); kw != "" {
m = m.Where("code LIKE ?", "%"+strings.ToUpper(kw)+"%")
}
if req.Status != nil {
m = m.Where("status", *req.Status)
}
total, err := m.Count()
if err != nil {
return nil, err
}
records, err := m.Page(page, pageSize).OrderDesc("id").All()
if err != nil {
return nil, err
}
list := make([]adminv1.PromoItem, 0, len(records))
for _, r := range records {
list = append(list, adminv1.PromoItem{
Id: r["id"].Int64(),
Code: r["code"].String(),
ProductId: r["product_id"].String(),
PriceCents: r["price_cents"].Int64(),
Scope: r["scope"].Int(),
ScopeKeys: parseScopeKeys(r["scope_keys"].String()),
MaxUses: r["max_uses"].Int(),
UsedCount: r["used_count"].Int(),
PerUserLimit: r["per_user_limit"].Int(),
ValidFrom: timeStr(r["valid_from"].GTime()),
ValidTo: timeStr(r["valid_to"].GTime()),
Status: r["status"].Int(),
Remark: r["remark"].String(),
})
}
return &adminv1.PromoListRes{List: list, Total: total}, nil
}
// AdminPromoSave 新增/编辑优惠码。
// 编辑(id>0)时 code 不可变更(跨端契约:码值可能已被用户输入/被订单快照)。
func AdminPromoSave(ctx context.Context, req *adminv1.PromoSaveReq) (*adminv1.PromoSaveRes, error) {
code := normalizePromoCode(req.Code)
if code == "" {
return nil, errPromoInvalid("优惠码不能为空")
}
scopeKeysJSON, _ := json.Marshal(req.ScopeKeys)
validFrom := parseOptionalTime(req.ValidFrom)
validTo := parseOptionalTime(req.ValidTo)
if validFrom != nil && validTo != nil && validTo.Before(validFrom) {
return nil, gerror.New("结束时间不能早于开始时间")
}
data := g.Map{
"product_id": strings.TrimSpace(req.ProductId),
"price_cents": req.PriceCents,
"scope": req.Scope,
"scope_keys": string(scopeKeysJSON),
"max_uses": req.MaxUses,
"per_user_limit": req.PerUserLimit,
"valid_from": validFrom,
"valid_to": validTo,
"status": req.Status,
"remark": req.Remark,
"updated_at": gtime.Now(),
}
if req.Id > 0 {
exist, err := g.Model(consts.TablePromoCodes).Where("id", req.Id).One()
if err != nil {
return nil, err
}
if exist.IsEmpty() {
return nil, gerror.New("优惠码不存在")
}
if exist["code"].String() != code {
return nil, gerror.New("优惠码值不可修改")
}
if _, err = g.Model(consts.TablePromoCodes).Where("id", req.Id).Data(data).Update(); err != nil {
return nil, err
}
return &adminv1.PromoSaveRes{Id: req.Id}, nil
}
count, err := g.Model(consts.TablePromoCodes).Where("code", code).Count()
if err != nil {
return nil, err
}
if count > 0 {
return nil, gerror.New("优惠码值已存在")
}
data["code"] = code
data["created_by"] = CtxAdminAccount(ctx)
data["created_at"] = gtime.Now()
id, err := g.Model(consts.TablePromoCodes).Data(data).InsertAndGetId()
if err != nil {
return nil, err
}
return &adminv1.PromoSaveRes{Id: id}, nil
}
// AdminPromoToggle 启停优惠码
func AdminPromoToggle(ctx context.Context, id int64, status int) (*adminv1.PromoToggleRes, error) {
if _, err := g.Model(consts.TablePromoCodes).Where("id", id).
Data(g.Map{"status": status, "updated_at": gtime.Now()}).Update(); err != nil {
return nil, err
}
return &adminv1.PromoToggleRes{}, nil
}
// AdminPromoDelete 删除优惠码
func AdminPromoDelete(ctx context.Context, id int64) (*adminv1.PromoDeleteRes, error) {
if _, err := g.Model(consts.TablePromoCodes).Where("id", id).Delete(); err != nil {
return nil, err
}
return &adminv1.PromoDeleteRes{}, nil
}
// AdminPromoUsages 优惠码使用明细
func AdminPromoUsages(ctx context.Context, req *adminv1.PromoUsagesReq) (*adminv1.PromoUsagesRes, error) {
page, pageSize := normalizePage(req.Page, req.PageSize, 20)
m := g.Model(consts.TableMemberOrders).Where("promo_code_id", req.PromoId)
total, err := m.Count()
if err != nil {
return nil, err
}
records, err := m.Page(page, pageSize).OrderDesc("id").All()
if err != nil {
return nil, err
}
list := make([]adminv1.PromoUsageItem, 0, len(records))
for _, r := range records {
at := r["paid_at"].GTime()
if at == nil || at.IsZero() {
at = r["created_at"].GTime()
}
list = append(list, adminv1.PromoUsageItem{
At: timeStr(at),
User: userDisplayOf(ctx, r["user_id"].Int64()),
OutTradeNo: r["out_trade_no"].String(),
OriginPriceCents: r["origin_price_cents"].Int64(),
PaidPriceCents: r["paid_price_cents"].Int64(),
})
}
return &adminv1.PromoUsagesRes{List: list, Total: total}, nil
}
// ===== 通用小工具 =====
// normalizePage 归一化分页参数(page>=1;pageSize 落在 (0, 200])。
func normalizePage(page, pageSize, def int) (int, int) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = def
}
if pageSize > 200 {
pageSize = 200
}
return page, pageSize
}
// timeStr 统一时间输出("Y-m-d H:i:s");空/零值返回空串。
func timeStr(t *gtime.Time) string {
if t == nil || t.IsZero() {
return ""
}
return t.Format("Y-m-d H:i:s")
}
// parseOptionalTime 解析可选时间(空串 → nil)。
func parseOptionalTime(s string) *gtime.Time {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
return gtime.New(s)
}
// userDisplayOf 用户展示名(昵称优先,回落 "用户#id")。
func userDisplayOf(ctx context.Context, userId int64) string {
if userId <= 0 {
return ""
}
v, err := g.Model(consts.TableUsers).Where("id", userId).Value("nickname")
if err != nil || v == nil || v.String() == "" {
return "用户#" + strconv.FormatInt(userId, 10)
}
return v.String()
}