814 lines
29 KiB
Go
814 lines
29 KiB
Go
package logic
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gogf/gf/v2/database/gdb"
|
||
"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.4)
|
||
//
|
||
// 覆盖:
|
||
// - 管理端:券模板 CRUD(A6–A8)+ 发放/记录/核销(A9–A11)
|
||
// - 用户端:/coupon/my(P5)、/coupon/validate(P6,只读)、/coupon/usable(P7,只返免单券)
|
||
// - 下单事务内的免单券原子占用原语 occupyFreebie(已在 order_pay.go 的 applyVoucherToOrder 内接线)
|
||
// - 超时释放:ReleaseExpiredCouponLocks(gcron)
|
||
//
|
||
// 硬约束(约定 S7/S8):
|
||
// - 券型隔离:1 免单券(走支付)/ 2 展示券(仅展示);**新建默认 2**(fail-closed);
|
||
// /coupon/usable 只返免单券;展示券不出现在任何「去使用/抵扣」语义里。
|
||
// - 已发券用快照:coupon_title/coupon_type/kind/value/threshold_cents 在发放时复制,
|
||
// 模板改名/改型/改面额**不影响已发券**。
|
||
// - 「已过期」在**列表读取时**按 expire_at < now 判定(不落库,P0)。
|
||
// ============================================================================
|
||
|
||
// couponStatusReason 券状态的可读原因。
|
||
func couponStatusReason(status int) string {
|
||
switch status {
|
||
case consts.UserCouponStatusUsed:
|
||
return "该券已被使用"
|
||
case consts.UserCouponStatusExpired:
|
||
return "优惠券已过期"
|
||
case consts.UserCouponStatusVoid:
|
||
return "优惠券已作废"
|
||
default:
|
||
return "优惠券不可用"
|
||
}
|
||
}
|
||
|
||
// couponExpirePassedExpr 判定「expire_at 早于绑定时刻(真=已过期)」的 SQL 片段。
|
||
//
|
||
// expire_at 的过期与否一律由**数据库**按墙钟值比较:绑定的 gtime 参数与写入(同样以 gtime
|
||
// 参数落库)同源,规避「读侧恒定走 gtime.StrToTime(恒 Local)导致 Go 读回比真实早 8h」的
|
||
// 时区错位(TZ-01)。语义:expire_at 非空且早于 now 即过期(NULL = 永久)。
|
||
//
|
||
// 注:旧 Go 判定里还有「零值(0000-00-00)不算过期」的分支,但 MySQL(strict / NO_ZERO_DATE)
|
||
// 根本不允许写入零日期(比较零日期字面量还会直接报 1525),故该分支在库内无对应数据,删去后行为等价。
|
||
const couponExpirePassedExpr = "(expire_at IS NOT NULL AND expire_at < ?)"
|
||
|
||
// couponExpiredAt 由数据库判定「该券在 now 时刻是否已过期」(SQL 侧绑定 gtime 参数)。
|
||
// m 可为 g.Model(...) 或 tx.Model(...):同一张表、同一连接语义。
|
||
func couponExpiredAt(ctx context.Context, m *gdb.Model, id int64, now *gtime.Time) (bool, error) {
|
||
cnt, err := m.Where("id", id).Where(couponExpirePassedExpr, now).Count()
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
return cnt > 0, nil
|
||
}
|
||
|
||
// effectiveCouponStatus 读取态券状态:未使用但已过 expire_at → 视为「已过期」(不落库)。
|
||
//
|
||
// expirePassed 由 **SQL 侧**判定后传入(TZ-01),本函数保持纯函数、不再自行比较时间。
|
||
func effectiveCouponStatus(c *entity.UserCoupons, expirePassed bool) int {
|
||
if c.Status == consts.UserCouponStatusUnused && expirePassed {
|
||
return consts.UserCouponStatusExpired
|
||
}
|
||
return c.Status
|
||
}
|
||
|
||
// couponUsableErr 免单券「支付可用性」判定(纯函数,不碰 DB)。
|
||
//
|
||
// 仅 coupon_type=1(免单券)可走支付;展示券(2)一律不可用于抵扣(fail-closed)。
|
||
// expired 由 **SQL 侧**判定后传入(TZ-01),本函数不再自行比较时间。
|
||
func couponUsableErr(c *entity.UserCoupons, expired bool) error {
|
||
if c == nil {
|
||
return errCouponUnavailable("优惠券不存在")
|
||
}
|
||
if c.CouponType != consts.CouponTypeFreebie {
|
||
return errCouponUnavailable("该券为展示券,不可用于抵扣")
|
||
}
|
||
if c.Status != consts.UserCouponStatusUnused {
|
||
return errCouponUnavailable(couponStatusReason(c.Status))
|
||
}
|
||
if expired {
|
||
return errCouponUnavailable("优惠券已过期")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// buildUserCouponRow 由模板生成一条「已发券」行(含券面快照)。
|
||
//
|
||
// 纯函数:快照字段在发放时从模板**取值复制**,之后模板改名/改型/改面额不影响这条记录(约定 S8)。
|
||
func buildUserCouponRow(
|
||
c *entity.Coupons, userId int64, grantedBy, remark string, now *gtime.Time,
|
||
) g.Map {
|
||
return g.Map{
|
||
"coupon_id": c.Id,
|
||
"user_id": userId,
|
||
"coupon_title": c.Title,
|
||
"coupon_type": c.CouponType,
|
||
"kind": c.Kind,
|
||
"value": c.Value,
|
||
"threshold_cents": c.ThresholdCents,
|
||
"status": consts.UserCouponStatusUnused,
|
||
"source": consts.UserCouponSourceAdmin,
|
||
"granted_by": grantedBy,
|
||
"granted_at": now,
|
||
"expire_at": c.ValidTo,
|
||
"remark": remark,
|
||
"created_at": now,
|
||
"updated_at": now,
|
||
}
|
||
}
|
||
|
||
// couponTemplateScopeOK 券模板适用范围是否覆盖当前订单。
|
||
// 模板缺失时视为「全范围」(fail-open 到「可用」,避免因模板被删而让用户手里的券变砖)。
|
||
func couponTemplateScopeOK(ctx context.Context, couponId int64, orderType int, targetKey string) (bool, error) {
|
||
record, err := g.Model(consts.TableCoupons).Where("id", couponId).One()
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if record.IsEmpty() {
|
||
return true, nil
|
||
}
|
||
return scopeMatches(record["scope"].Int(), parseScopeKeys(record["scope_keys"].String()), orderType, targetKey), nil
|
||
}
|
||
|
||
// ===== 用户端:/coupon/my(P5)=====
|
||
|
||
// CouponMy 我的优惠券(默认全量;status 可筛 0 可用 / 1 已用 / 2 过期)。
|
||
func CouponMy(ctx context.Context, status *int) (*userv1.CouponMyRes, error) {
|
||
now := gtime.Now()
|
||
endOfMonth := now.EndOfMonth()
|
||
// expire_at 的「是否过期 / 是否临期本月」一律在 **SQL 侧**判定(绑定 gtime 参数,与写入同源),
|
||
// 规避 gtime 读回恒 Local 造成的 8h 偏移(TZ-01)。
|
||
records, err := g.DB().GetAll(ctx,
|
||
"SELECT uc.*, "+
|
||
couponExpirePassedExpr+" AS expire_passed, "+
|
||
"(uc.expire_at IS NOT NULL AND uc.expire_at > ? AND uc.expire_at <= ?) AS expiring_soon "+
|
||
"FROM "+consts.TableUserCoupons+" uc WHERE uc.user_id = ? "+
|
||
"ORDER BY uc.status ASC, uc.expire_at ASC, uc.id DESC",
|
||
now, now, endOfMonth, CtxUserId(ctx))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
summary := userv1.CouponMySummary{}
|
||
list := make([]userv1.UserCouponItem, 0, len(records))
|
||
for _, r := range records {
|
||
c := &entity.UserCoupons{}
|
||
if err = r.Struct(c); err != nil {
|
||
continue
|
||
}
|
||
eff := effectiveCouponStatus(c, r["expire_passed"].Bool())
|
||
// 汇总口径
|
||
switch eff {
|
||
case consts.UserCouponStatusUnused:
|
||
summary.Usable++
|
||
if c.CouponType == consts.CouponTypeFreebie {
|
||
summary.Freebie++
|
||
}
|
||
if r["expiring_soon"].Bool() {
|
||
summary.ExpiringSoon++
|
||
}
|
||
case consts.UserCouponStatusUsed:
|
||
summary.Used++
|
||
case consts.UserCouponStatusExpired:
|
||
summary.Expired++
|
||
}
|
||
if status != nil && eff != *status {
|
||
continue
|
||
}
|
||
list = append(list, userv1.UserCouponItem{
|
||
Id: c.Id,
|
||
CouponId: c.CouponId,
|
||
Title: c.CouponTitle,
|
||
SubTitle: couponSubTitleOf(ctx, c.CouponId),
|
||
CouponType: c.CouponType,
|
||
Kind: c.Kind,
|
||
Value: c.Value,
|
||
ThresholdCents: c.ThresholdCents,
|
||
Status: eff,
|
||
ExpireAt: timeStr(c.ExpireAt),
|
||
GrantedAt: timeStr(c.GrantedAt),
|
||
UsedAt: timeStr(c.UsedAt),
|
||
})
|
||
}
|
||
return &userv1.CouponMyRes{Summary: summary, List: list}, nil
|
||
}
|
||
|
||
// couponSubTitleOf 副标题取自模板(模板缺失返回空串)。
|
||
func couponSubTitleOf(ctx context.Context, couponId int64) string {
|
||
v, err := g.Model(consts.TableCoupons).Where("id", couponId).Value("sub_title")
|
||
if err != nil || v == nil {
|
||
return ""
|
||
}
|
||
return v.String()
|
||
}
|
||
|
||
// ===== 用户端:/coupon/validate(P6,只读,绝不占用)=====
|
||
|
||
// CouponValidate 下单前校验免单券可用性(只读)。
|
||
func CouponValidate(ctx context.Context, userCouponId int64, orderType int, targetKey string) (*userv1.CouponValidateRes, error) {
|
||
userId := CtxUserId(ctx)
|
||
c, err := userCouponById(ctx, userCouponId)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if c == nil || c.UserId != userId {
|
||
return nil, errCouponUnavailable("优惠券不存在")
|
||
}
|
||
expired, err := couponExpiredAt(ctx, g.Model(consts.TableUserCoupons), c.Id, gtime.Now())
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err = couponUsableErr(c, expired); err != nil {
|
||
return nil, err
|
||
}
|
||
scopeOK, err := couponTemplateScopeOK(ctx, c.CouponId, orderType, targetKey)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !scopeOK {
|
||
return nil, errCouponUnavailable("该券不适用于当前商品")
|
||
}
|
||
_, originPrice, err := orderTargetProduct(ctx, orderType, targetKey)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
paid := freebiePriceCents(ctx)
|
||
if paid <= 0 {
|
||
// 服务端自检:免单道具价格异常(不下发用户,日志告警 + 拒单)
|
||
g.Log().Errorf(ctx, "[coupon] 免单道具价格自检失败 priceCents=%d productId=%s", paid, freebieProductId(ctx))
|
||
return nil, errProductPriceMismatch("免单配置异常,请稍后再试")
|
||
}
|
||
return &userv1.CouponValidateRes{
|
||
Valid: true,
|
||
CouponType: c.CouponType,
|
||
IsFreebie: c.CouponType == consts.CouponTypeFreebie,
|
||
ProductId: freebieProductId(ctx),
|
||
PriceCents: paid,
|
||
OriginPriceCents: originPrice,
|
||
PaidPriceCents: paid,
|
||
Message: "优惠券可用",
|
||
}, nil
|
||
}
|
||
|
||
// ===== 用户端:/coupon/usable(P7,只返免单券)=====
|
||
|
||
// CouponUsable 当前订单可选用的券(**只返免单券**,约定 S7)。
|
||
//
|
||
// outTradeNo **可选**(缺省 = 既有行为,完全不破坏其它调用方):
|
||
//
|
||
// 传入本人订单号后,会读取该订单当前「已选中」的免单券(promo_kind=2 且 user_coupon_id>0)。
|
||
// 该券在下单占用后 status 已变 1(used),会被下面 status=0 的过滤排除 → 用户只有一张券时
|
||
// 列表必然为空(既看不到也换不了,P0 真事故)。故此处**保底**把它补回列表并标记 applied=true,
|
||
// 使前端抽屉既能展示「当前已选」打勾,也允许换成别的券 / 取消。
|
||
//
|
||
// 语义保证:
|
||
// - applied 标记只对「确属本人、且确由本单选中」的那张券为 true;
|
||
// - 未传 outTradeNo / 订单不存在 / 非本人 / 未选券 / 选的是优惠码 → 全部 applied=false,
|
||
// 结果与旧版逐字节一致(仅多了恒为 false 的 applied 字段)。
|
||
func CouponUsable(ctx context.Context, orderType int, targetKey, outTradeNo string) (*userv1.CouponUsableRes, error) {
|
||
userId := CtxUserId(ctx)
|
||
now := gtime.Now()
|
||
paid := freebiePriceCents(ctx)
|
||
// 过期券在 **SQL 侧**过滤(绑定 gtime 参数,与写入同源;TZ-01),读侧不再比较时间。
|
||
records, err := g.Model(consts.TableUserCoupons).
|
||
Where("user_id", userId).
|
||
Where("coupon_type", consts.CouponTypeFreebie).
|
||
Where("status", consts.UserCouponStatusUnused).
|
||
Where("(expire_at IS NULL OR expire_at >= ?)", now).
|
||
OrderAsc("expire_at").OrderDesc("id").
|
||
All()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 当前订单已选中的券 id(无则 0)。仅当传入单号且确为本人订单时才有值。
|
||
appliedId := int64(0)
|
||
if strings.TrimSpace(outTradeNo) != "" {
|
||
appliedId = appliedCouponIdOfOrder(ctx, userId, outTradeNo)
|
||
}
|
||
|
||
list := make([]userv1.UsableCouponItem, 0, len(records)+1)
|
||
seen := make(map[int64]bool, len(records))
|
||
for _, r := range records {
|
||
c := &entity.UserCoupons{}
|
||
if err = r.Struct(c); err != nil {
|
||
continue
|
||
}
|
||
scopeOK, err := couponTemplateScopeOK(ctx, c.CouponId, orderType, targetKey)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if !scopeOK {
|
||
continue
|
||
}
|
||
seen[c.Id] = true
|
||
list = append(list, userv1.UsableCouponItem{
|
||
UserCouponId: c.Id,
|
||
Title: c.CouponTitle,
|
||
CouponType: c.CouponType,
|
||
IsFreebie: true,
|
||
ExpireAt: timeStr(c.ExpireAt),
|
||
EstimatedPaidPriceCents: paid,
|
||
Applied: c.Id == appliedId,
|
||
})
|
||
}
|
||
|
||
// 保底:把「已选中但被 status 过滤掉」的那张券补回列表(置顶,标记 applied),
|
||
// 使前端抽屉始终能看到并更换当前选中的券(仅有 1 张券时为唯一一条)。
|
||
if appliedId > 0 && !seen[appliedId] {
|
||
if c, e := userCouponById(ctx, appliedId); e == nil && c != nil && c.UserId == userId {
|
||
list = append([]userv1.UsableCouponItem{{
|
||
UserCouponId: c.Id,
|
||
Title: c.CouponTitle,
|
||
CouponType: c.CouponType,
|
||
IsFreebie: true,
|
||
ExpireAt: timeStr(c.ExpireAt),
|
||
EstimatedPaidPriceCents: paid,
|
||
Applied: true,
|
||
}}, list...)
|
||
}
|
||
}
|
||
|
||
return &userv1.CouponUsableRes{List: list}, nil
|
||
}
|
||
|
||
// appliedCouponIdOfOrder 读取「属于 userId 的 outTradeNo 订单」当前已选中的免单券 id。
|
||
//
|
||
// 仅当订单确为本人(Where user_id 一并过滤,防越权枚举)且 promo_kind=2(免单券)时返回其
|
||
// user_coupon_id;其余(订单不存在 / 非本人 / 未选券 / 选的是优惠码)一律返回 0(静默,不报错)。
|
||
func appliedCouponIdOfOrder(ctx context.Context, userId int64, outTradeNo string) int64 {
|
||
record, err := g.Model(consts.TableMemberOrders).
|
||
Where("out_trade_no", outTradeNo).
|
||
Where("user_id", userId).
|
||
One()
|
||
if err != nil || record.IsEmpty() {
|
||
return 0
|
||
}
|
||
if record["promo_kind"].Int() != consts.PromoKindCoupon {
|
||
return 0
|
||
}
|
||
return record["user_coupon_id"].Int64()
|
||
}
|
||
|
||
// ===== 管理端:券模板(A6–A8)=====
|
||
|
||
// AdminCouponTemplateList 券模板列表
|
||
func AdminCouponTemplateList(ctx context.Context, req *adminv1.CouponTemplateListReq) (*adminv1.CouponTemplateListRes, error) {
|
||
page, pageSize := normalizePage(req.Page, req.PageSize, 20)
|
||
m := g.Model(consts.TableCoupons)
|
||
if kw := strings.TrimSpace(req.Keyword); kw != "" {
|
||
m = m.Where("title LIKE ?", "%"+kw+"%")
|
||
}
|
||
if req.Status != nil {
|
||
m = m.Where("status", *req.Status)
|
||
}
|
||
if req.CouponType != nil {
|
||
m = m.Where("coupon_type", *req.CouponType)
|
||
}
|
||
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
|
||
}
|
||
// 已发 / 已用统计(一次聚合,避免 N+1)
|
||
granted, _ := g.Model(consts.TableUserCoupons).Fields("coupon_id, COUNT(*) AS c").Group("coupon_id").All()
|
||
used, _ := g.Model(consts.TableUserCoupons).
|
||
Fields("coupon_id, COUNT(*) AS c").Where("status", consts.UserCouponStatusUsed).Group("coupon_id").All()
|
||
grantedMap := countByInt64(granted, "coupon_id")
|
||
usedMap := countByInt64(used, "coupon_id")
|
||
|
||
list := make([]adminv1.CouponTemplateItem, 0, len(records))
|
||
for _, r := range records {
|
||
id := r["id"].Int64()
|
||
list = append(list, adminv1.CouponTemplateItem{
|
||
Id: id,
|
||
Title: r["title"].String(),
|
||
SubTitle: r["sub_title"].String(),
|
||
CouponType: r["coupon_type"].Int(),
|
||
Kind: r["kind"].Int(),
|
||
Value: r["value"].Int(),
|
||
ThresholdCents: r["threshold_cents"].Int(),
|
||
Scope: r["scope"].Int(),
|
||
ScopeKeys: parseScopeKeys(r["scope_keys"].String()),
|
||
TotalLimit: r["total_limit"].Int(),
|
||
PerUserLimit: r["per_user_limit"].Int(),
|
||
ValidFrom: timeStr(r["valid_from"].GTime()),
|
||
ValidTo: timeStr(r["valid_to"].GTime()),
|
||
Status: r["status"].Int(),
|
||
GrantedCount: grantedMap[id],
|
||
UsedCount: usedMap[id],
|
||
})
|
||
}
|
||
return &adminv1.CouponTemplateListRes{List: list, Total: total}, nil
|
||
}
|
||
|
||
// AdminCouponTemplateSave 新增/编辑券模板。
|
||
//
|
||
// ⚠️ 券型 fail-closed:只有显式传 couponType=1 才是免单券,其余(0/2/未知)一律按展示券 2 处理。
|
||
func AdminCouponTemplateSave(ctx context.Context, req *adminv1.CouponTemplateSaveReq) (*adminv1.CouponTemplateSaveRes, error) {
|
||
couponType := consts.CouponTypeDisplay
|
||
if req.CouponType == consts.CouponTypeFreebie {
|
||
couponType = consts.CouponTypeFreebie
|
||
}
|
||
kind := req.Kind
|
||
if kind != consts.CouponKindCut && kind != consts.CouponKindFreebie {
|
||
kind = consts.CouponKindFreebie
|
||
}
|
||
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{
|
||
"title": strings.TrimSpace(req.Title),
|
||
"sub_title": req.SubTitle,
|
||
"coupon_type": couponType,
|
||
"kind": kind,
|
||
"value": req.Value,
|
||
"threshold_cents": req.ThresholdCents,
|
||
"scope": req.Scope,
|
||
"scope_keys": string(scopeKeysJSON),
|
||
"total_limit": req.TotalLimit,
|
||
"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.TableCoupons).Where("id", req.Id).One()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if exist.IsEmpty() {
|
||
return nil, gerror.New("券模板不存在")
|
||
}
|
||
if _, err = g.Model(consts.TableCoupons).Where("id", req.Id).Data(data).Update(); err != nil {
|
||
return nil, err
|
||
}
|
||
return &adminv1.CouponTemplateSaveRes{Id: req.Id}, nil
|
||
}
|
||
data["created_at"] = gtime.Now()
|
||
id, err := g.Model(consts.TableCoupons).Data(data).InsertAndGetId()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &adminv1.CouponTemplateSaveRes{Id: id}, nil
|
||
}
|
||
|
||
// AdminCouponTemplateToggle 启停券模板
|
||
func AdminCouponTemplateToggle(ctx context.Context, id int64, status int) (*adminv1.CouponTemplateToggleRes, error) {
|
||
if _, err := g.Model(consts.TableCoupons).Where("id", id).
|
||
Data(g.Map{"status": status, "updated_at": gtime.Now()}).Update(); err != nil {
|
||
return nil, err
|
||
}
|
||
return &adminv1.CouponTemplateToggleRes{}, nil
|
||
}
|
||
|
||
// ===== 管理端:发放 / 记录 / 核销(A9–A11)=====
|
||
|
||
// AdminCouponGrant 发放优惠券(单用户/批量共用)。
|
||
//
|
||
// 返回「部分成功」结果:success 成功条数、failed 失败条数、failures 逐条原因。
|
||
func AdminCouponGrant(ctx context.Context, req *adminv1.CouponGrantReq) (*adminv1.CouponGrantRes, error) {
|
||
template, err := couponById(ctx, req.CouponId)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if template == nil {
|
||
return nil, gerror.New("券模板不存在")
|
||
}
|
||
if template.Status != 1 {
|
||
return nil, gerror.New("券模板已停用,无法发放")
|
||
}
|
||
now := gtime.Now()
|
||
res := &adminv1.CouponGrantRes{Failures: []adminv1.BatchFailure{}}
|
||
|
||
grantedTotal, err := g.Model(consts.TableUserCoupons).Where("coupon_id", template.Id).Count()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
operator := CtxAdminAccount(ctx)
|
||
|
||
for _, userId := range req.UserIds {
|
||
// 总量上限(total_limit 0=不限)
|
||
if template.TotalLimit > 0 && grantedTotal >= template.TotalLimit {
|
||
res.Failed++
|
||
res.Failures = append(res.Failures, adminv1.BatchFailure{Id: userId, Reason: "已达总发行上限"})
|
||
continue
|
||
}
|
||
// 用户存在性
|
||
u, err := g.Model(consts.TableUsers).Where("id", userId).WhereNull("deleted_at").One()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if u.IsEmpty() {
|
||
res.Failed++
|
||
res.Failures = append(res.Failures, adminv1.BatchFailure{Id: userId, Reason: "用户不存在"})
|
||
continue
|
||
}
|
||
// 单人限领
|
||
if template.PerUserLimit > 0 {
|
||
held, err := g.Model(consts.TableUserCoupons).
|
||
Where("coupon_id", template.Id).Where("user_id", userId).
|
||
WhereNot("status", consts.UserCouponStatusVoid).Count()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if held >= template.PerUserLimit {
|
||
res.Failed++
|
||
res.Failures = append(res.Failures, adminv1.BatchFailure{Id: userId, Reason: "超出单人限领"})
|
||
continue
|
||
}
|
||
}
|
||
if _, err = g.Model(consts.TableUserCoupons).
|
||
Data(buildUserCouponRow(template, userId, operator, req.Remark, now)).Insert(); err != nil {
|
||
res.Failed++
|
||
res.Failures = append(res.Failures, adminv1.BatchFailure{Id: userId, Reason: "发放失败"})
|
||
continue
|
||
}
|
||
res.Success++
|
||
grantedTotal++
|
||
}
|
||
return res, nil
|
||
}
|
||
|
||
// AdminCouponRecordList 发放记录列表(可按用户/券模板/券型/状态筛)
|
||
func AdminCouponRecordList(ctx context.Context, req *adminv1.CouponRecordListReq) (*adminv1.CouponRecordListRes, error) {
|
||
page, pageSize := normalizePage(req.Page, req.PageSize, 20)
|
||
m := g.Model(consts.TableUserCoupons)
|
||
if req.UserId != nil {
|
||
m = m.Where("user_id", *req.UserId)
|
||
}
|
||
if req.CouponId != nil {
|
||
m = m.Where("coupon_id", *req.CouponId)
|
||
}
|
||
if req.CouponType != nil {
|
||
m = m.Where("coupon_type", *req.CouponType)
|
||
}
|
||
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.CouponRecordItem, 0, len(records))
|
||
for _, r := range records {
|
||
list = append(list, adminv1.CouponRecordItem{
|
||
Id: r["id"].Int64(),
|
||
UserId: r["user_id"].Int64(),
|
||
CouponId: r["coupon_id"].Int64(),
|
||
Title: r["coupon_title"].String(),
|
||
CouponType: r["coupon_type"].Int(),
|
||
Kind: r["kind"].Int(),
|
||
Value: r["value"].Int(),
|
||
Status: r["status"].Int(),
|
||
Source: r["source"].Int(),
|
||
GrantedAt: timeStr(r["granted_at"].GTime()),
|
||
UsedAt: timeStr(r["used_at"].GTime()),
|
||
ExpireAt: timeStr(r["expire_at"].GTime()),
|
||
UsedBy: r["used_by"].String(),
|
||
UsedOrderNo: r["used_order_no"].String(),
|
||
})
|
||
}
|
||
return &adminv1.CouponRecordListRes{List: list, Total: total}, nil
|
||
}
|
||
|
||
// AdminCouponRedeem 人工核销。
|
||
//
|
||
// 券型隔离(约定 S7):本接口面向展示券;对免单券且 status != 0 返回 4011
|
||
// (免单券的核销应通过下单支付链路完成)。
|
||
func AdminCouponRedeem(ctx context.Context, userCouponId int64, remark string) (*adminv1.CouponRedeemRes, error) {
|
||
c, err := userCouponById(ctx, userCouponId)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if c == nil {
|
||
return nil, errCouponUnavailable("优惠券不存在")
|
||
}
|
||
if c.CouponType == consts.CouponTypeFreebie && c.Status != consts.UserCouponStatusUnused {
|
||
return nil, errCouponUnavailable(couponStatusReason(c.Status))
|
||
}
|
||
if c.Status != consts.UserCouponStatusUnused {
|
||
return nil, errCouponUnavailable(couponStatusReason(c.Status))
|
||
}
|
||
now := gtime.Now()
|
||
if _, err = g.Model(consts.TableUserCoupons).Where("id", userCouponId).
|
||
Data(g.Map{
|
||
"status": consts.UserCouponStatusUsed,
|
||
"used_at": now,
|
||
"used_by": consts.UserCouponUsedByAdmin,
|
||
"used_order_no": "",
|
||
"remark": remark,
|
||
"updated_at": now,
|
||
}).Update(); err != nil {
|
||
return nil, err
|
||
}
|
||
return &adminv1.CouponRedeemRes{}, nil
|
||
}
|
||
|
||
// ===== 下单事务内的免单券原子占用原语(已在 order_pay.go 的 applyVoucherToOrder 内接线)=====
|
||
|
||
// occupyFreebie 在事务内锁定并占用一张免单券:
|
||
// 行锁 → 校验(券型/状态/有效期)→ 置 status=1 + used_order_no + used_by='system' + lock_at。
|
||
//
|
||
// 并发同券下单时(约定 S5/§图②):事务1 先拿到行锁并置 status=1;事务2 阻塞后读到 status=1,
|
||
// 校验失败 → 4011;恰好 1 单成功,券只对应 1 个订单号。
|
||
func occupyFreebie(
|
||
ctx context.Context, tx gdb.TX, userId, userCouponId int64,
|
||
orderType int, targetKey, outTradeNo string, originPriceCents int64,
|
||
) (*Voucher, error) {
|
||
row, err := tx.Model(consts.TableUserCoupons).Where("id", userCouponId).LockUpdate().One()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if row.IsEmpty() {
|
||
return nil, errCouponUnavailable("优惠券不存在")
|
||
}
|
||
c := &entity.UserCoupons{}
|
||
if err = row.Struct(c); err != nil {
|
||
return nil, err
|
||
}
|
||
if c.UserId != userId {
|
||
return nil, errCouponUnavailable("优惠券不存在")
|
||
}
|
||
expired, err := couponExpiredAt(ctx, tx.Model(consts.TableUserCoupons), c.Id, gtime.Now())
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err = couponUsableErr(c, expired); err != nil {
|
||
return nil, err
|
||
}
|
||
scopeOK, err := couponTemplateScopeOK(ctx, c.CouponId, orderType, targetKey)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !scopeOK {
|
||
return nil, errCouponUnavailable("该券不适用于当前商品")
|
||
}
|
||
now := gtime.Now()
|
||
if _, err = tx.Model(consts.TableUserCoupons).Where("id", userCouponId).
|
||
Data(g.Map{
|
||
"status": consts.UserCouponStatusUsed,
|
||
"used_at": now,
|
||
"used_by": consts.UserCouponUsedBySystem,
|
||
"used_order_no": outTradeNo,
|
||
"lock_at": now,
|
||
"updated_at": now,
|
||
}).Update(); err != nil {
|
||
return nil, err
|
||
}
|
||
paid := freebiePriceCents(ctx)
|
||
if paid <= 0 {
|
||
g.Log().Errorf(ctx, "[coupon] 免单道具价格自检失败 priceCents=%d productId=%s", paid, freebieProductId(ctx))
|
||
return nil, errProductPriceMismatch("免单配置异常,请稍后再试")
|
||
}
|
||
discount := originPriceCents - paid
|
||
if discount < 0 {
|
||
discount = 0
|
||
}
|
||
return &Voucher{
|
||
ProductId: freebieProductId(ctx),
|
||
OriginPriceCents: originPriceCents,
|
||
PaidPriceCents: paid,
|
||
DiscountCents: discount,
|
||
PromoKind: consts.PromoKindCoupon,
|
||
UserCouponId: userCouponId,
|
||
}, nil
|
||
}
|
||
|
||
// releaseCouponOnOrder 释放「订单占用」的免单券(换券 / 改回无券 / 关单时调用):
|
||
// status 回未使用、清 used_at / used_by / used_order_no / lock_at。
|
||
//
|
||
// 守卫:仅当该券确由本单占用(used_order_no 匹配 + used_by='system')才解除,
|
||
// 避免误释放「已被人工核销」或「隶属于其他订单」的券。与 occupyFreebie 同处
|
||
// order_pay.go 的 applyVoucherToOrder 事务内调用 → 释放旧 + 占用新原子完成(约定 S5)。
|
||
func releaseCouponOnOrder(ctx context.Context, tx gdb.TX, userCouponId int64, outTradeNo string) error {
|
||
if userCouponId <= 0 {
|
||
return nil
|
||
}
|
||
_, err := tx.Model(consts.TableUserCoupons).
|
||
Where("id", userCouponId).
|
||
Where("used_order_no", outTradeNo).
|
||
Where("used_by", consts.UserCouponUsedBySystem).
|
||
Data(g.Map{
|
||
"status": consts.UserCouponStatusUnused,
|
||
"used_at": nil,
|
||
"used_by": "",
|
||
"used_order_no": "",
|
||
"lock_at": nil,
|
||
"updated_at": gtime.Now(),
|
||
}).Update()
|
||
return err
|
||
}
|
||
|
||
// ===== 超时释放(gcron,约定 S5)=====
|
||
|
||
// ReleaseExpiredCouponLocks 释放在「下单占用」后超时仍未发货的免单券:
|
||
// 把 status 回 0、清 used_order_no / used_by / lock_at / used_at,使券重新可用。
|
||
//
|
||
// 释放条件:used_by='system'(订单占用而非人工核销)+ lock_at 早于 now - ttl
|
||
// + 对应订单不存在或仍为「待支付/已关闭」(未发货、未退款)。
|
||
// ttl 读 settings.coupon.lock.ttlSeconds(默认 900)。
|
||
func ReleaseExpiredCouponLocks(ctx context.Context) {
|
||
ttl := couponLockTtlSeconds(ctx)
|
||
deadline := gtime.Now().Add(-time.Duration(ttl) * time.Second)
|
||
rows, err := g.DB().GetAll(ctx,
|
||
"SELECT uc.id AS id, uc.used_order_no AS order_no FROM user_coupons uc "+
|
||
"LEFT JOIN member_orders o ON o.out_trade_no = uc.used_order_no "+
|
||
"WHERE uc.status = ? AND uc.used_by = ? AND uc.lock_at IS NOT NULL AND uc.lock_at < ? "+
|
||
"AND (o.id IS NULL OR o.status IN (?, ?))",
|
||
consts.UserCouponStatusUsed, consts.UserCouponUsedBySystem, deadline,
|
||
consts.OrderStatusPending, consts.OrderStatusClosed)
|
||
if err != nil {
|
||
g.Log().Warningf(ctx, "[coupon] 扫描超时占用券失败: %v", err)
|
||
return
|
||
}
|
||
released := 0
|
||
for _, r := range rows {
|
||
id := r["id"].Int64()
|
||
orderNo := r["order_no"].String()
|
||
if _, err = g.Model(consts.TableUserCoupons).
|
||
Where("id", id).
|
||
Where("status", consts.UserCouponStatusUsed).
|
||
Where("used_order_no", orderNo).
|
||
Data(g.Map{
|
||
"status": consts.UserCouponStatusUnused,
|
||
"used_at": nil,
|
||
"used_by": "",
|
||
"used_order_no": "",
|
||
"lock_at": nil,
|
||
"updated_at": gtime.Now(),
|
||
}).Update(); err != nil {
|
||
g.Log().Warningf(ctx, "[coupon] 释放券失败 id=%d: %v", id, err)
|
||
continue
|
||
}
|
||
released++
|
||
}
|
||
if released > 0 {
|
||
g.Log().Infof(ctx, "[coupon] 已释放超时占用券 %d 张(ttl=%ds)", released, ttl)
|
||
}
|
||
}
|
||
|
||
// ===== 小工具 =====
|
||
|
||
func userCouponById(ctx context.Context, id int64) (*entity.UserCoupons, error) {
|
||
if id <= 0 {
|
||
return nil, nil
|
||
}
|
||
record, err := g.Model(consts.TableUserCoupons).Where("id", id).One()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if record.IsEmpty() {
|
||
return nil, nil
|
||
}
|
||
c := &entity.UserCoupons{}
|
||
if err = record.Struct(c); err != nil {
|
||
return nil, err
|
||
}
|
||
return c, nil
|
||
}
|
||
|
||
func couponById(ctx context.Context, id int64) (*entity.Coupons, error) {
|
||
if id <= 0 {
|
||
return nil, nil
|
||
}
|
||
record, err := g.Model(consts.TableCoupons).Where("id", id).One()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if record.IsEmpty() {
|
||
return nil, nil
|
||
}
|
||
c := &entity.Coupons{}
|
||
if err = record.Struct(c); err != nil {
|
||
return nil, err
|
||
}
|
||
return c, nil
|
||
}
|
||
|
||
// countByInt64 把聚合查询结果转成 map[int64]int64(key 列 → 计数列 c)。
|
||
func countByInt64(records gdb.Result, keyField string) map[int64]int64 {
|
||
out := map[int64]int64{}
|
||
for _, r := range records {
|
||
out[r[keyField].Int64()] = r["c"].Int64()
|
||
}
|
||
return out
|
||
}
|