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

814 lines
29 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"
"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
}