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