624 lines
20 KiB
Go
624 lines
20 KiB
Go
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()
|
||
}
|