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 }