704 lines
25 KiB
Go
704 lines
25 KiB
Go
package logic
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"strings"
|
||
"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"
|
||
|
||
v1 "tool-api/api/user/v1"
|
||
"tool-api/internal/consts"
|
||
"tool-api/internal/model/entity"
|
||
)
|
||
|
||
// ============================================================================
|
||
// 年会 / 会员订单域 · 两步式下单(N1)
|
||
//
|
||
// 依据:增量架构 §2.1(C-1~C-6)、§2.5(D2)、§5.1 图①、§5.3 图③。
|
||
//
|
||
// 拆链路:
|
||
// createOrder —— 只建单(/member/order、/quota/order),同商品待支付单复用(N1-10)
|
||
// buildPayParams —— 只建签名(/member/order/pay、/quota/order/pay),此处才校验「支付人=登录人」
|
||
// applyVoucherToOrder —— 应用/更换/清除优惠(裁定 D3:应用时在事务内占用)
|
||
// ClosePendingOrders —— 超时待支付单关闭(gcron),与券占用释放 TTL 同源(D2)
|
||
//
|
||
// 发货链路(deliverOrder / PayNotifyHandle)**一行不动**:发货只认本地订单行(S4)。
|
||
// ============================================================================
|
||
|
||
// orderStatusCode 4015:订单不存在 / 不属于当前用户 / 状态不允许该操作。
|
||
func gcodeOrderInvalid() gcode.Code {
|
||
return gcode.New(consts.CodeOrderInvalid, "", nil)
|
||
}
|
||
|
||
func errOrderInvalid(msg string) error {
|
||
return gerror.NewCode(gcodeOrderInvalid(), msg)
|
||
}
|
||
|
||
// orderPendingTtlSeconds 待支付订单关闭时限(秒)。
|
||
//
|
||
// 裁定 D2:与免单券占用释放 TTL **同源**(统一读 settings 键 coupon.lock.ttlSeconds,默认 900)——
|
||
// 单一来源、物理上不可不一致;本轮不新增 settings 键。
|
||
func orderPendingTtlSeconds(ctx context.Context) int {
|
||
return couponLockTtlSeconds(ctx)
|
||
}
|
||
|
||
// orderSnapshot 订单行快照(含优惠凭证 / 价格列)。
|
||
//
|
||
// 说明:entity.MemberOrders 未承载本轮新增的 promo_* / *_price_cents 列,且这些列在老订单上
|
||
// 可能为默认 0/空 → 统一在这里按「读回 0 / 空串」处理,展示层再按「原价 = 实付」兜底(S14)。
|
||
type orderSnapshot struct {
|
||
Id int64
|
||
OutTradeNo string
|
||
UserId int64
|
||
Openid string
|
||
OrderType int
|
||
PlanKey string
|
||
PackKey string
|
||
ToolKey string
|
||
Times int
|
||
ProductId string
|
||
PriceCents int64
|
||
OriginPriceCents int64
|
||
PaidPriceCents int64
|
||
DiscountCents int64
|
||
PromoKind int
|
||
PromoCodeId int64
|
||
PromoCode string
|
||
UserCouponId int64
|
||
LevelKey string
|
||
DurationDays int
|
||
Status int
|
||
PayChannel string
|
||
Attach string
|
||
CreatedAt *gtime.Time
|
||
PaidAt *gtime.Time
|
||
DeliveredAt *gtime.Time
|
||
}
|
||
|
||
func orderSnapshotFromRecord(r gdb.Record) *orderSnapshot {
|
||
return &orderSnapshot{
|
||
Id: r["id"].Int64(),
|
||
OutTradeNo: r["out_trade_no"].String(),
|
||
UserId: r["user_id"].Int64(),
|
||
Openid: r["openid"].String(),
|
||
OrderType: r["order_type"].Int(),
|
||
PlanKey: r["plan_key"].String(),
|
||
PackKey: r["pack_key"].String(),
|
||
ToolKey: r["tool_key"].String(),
|
||
Times: r["times"].Int(),
|
||
ProductId: r["product_id"].String(),
|
||
PriceCents: r["price_cents"].Int64(),
|
||
OriginPriceCents: r["origin_price_cents"].Int64(),
|
||
PaidPriceCents: r["paid_price_cents"].Int64(),
|
||
DiscountCents: r["discount_cents"].Int64(),
|
||
PromoKind: r["promo_kind"].Int(),
|
||
PromoCodeId: r["promo_code_id"].Int64(),
|
||
PromoCode: r["promo_code"].String(),
|
||
UserCouponId: r["user_coupon_id"].Int64(),
|
||
LevelKey: r["level_key"].String(),
|
||
DurationDays: r["duration_days"].Int(),
|
||
Status: r["status"].Int(),
|
||
PayChannel: r["pay_channel"].String(),
|
||
Attach: r["attach"].String(),
|
||
CreatedAt: r["created_at"].GTime(),
|
||
PaidAt: r["paid_at"].GTime(),
|
||
DeliveredAt: r["delivered_at"].GTime(),
|
||
}
|
||
}
|
||
|
||
// mustOwnOrder 按单号取「属于当前用户」的订单;不存在 / 非本人 → 4015(防枚举,两者文案统一)。
|
||
func mustOwnOrder(ctx context.Context, userId int64, outTradeNo string) (*orderSnapshot, error) {
|
||
record, err := g.Model(consts.TableMemberOrders).Where("out_trade_no", outTradeNo).One()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if record.IsEmpty() {
|
||
return nil, errOrderInvalid("订单不存在")
|
||
}
|
||
o := orderSnapshotFromRecord(record)
|
||
if o.UserId != userId {
|
||
return nil, errOrderInvalid("订单不存在或不属于当前用户")
|
||
}
|
||
return o, nil
|
||
}
|
||
|
||
// orderTargetKey 订单的目标 key:会员 = plan_key;次数包 = pack_key。
|
||
func orderTargetKey(o *orderSnapshot) string {
|
||
if o.OrderType == consts.OrderTypeQuota {
|
||
return o.PackKey
|
||
}
|
||
return o.PlanKey
|
||
}
|
||
|
||
// ============================================================================
|
||
// 建单(只建单 + 复用)
|
||
// ============================================================================
|
||
|
||
// createOrderResult 建单结果(复用标记 + 价格快照)。
|
||
type createOrderResult struct {
|
||
OutTradeNo string
|
||
Reused bool
|
||
OriginPriceCents int64
|
||
PaidPriceCents int64
|
||
Status int
|
||
}
|
||
|
||
// payChannelFromRequest 判定支付渠道:客户端显式 platform 优先(uni.getSystemInfoSync().platform),
|
||
// User-Agent 兜底(ipad/ipod/iphone → apple,注意 iPad 的 UA 亦含 iphone 字样,先判 ipad)。
|
||
// iOS 虚拟支付不可用(走 App Store IAP),单独标记渠道供后台退款时禁用 API 退款。
|
||
func payChannelFromRequest(ctx context.Context, platform string) string {
|
||
p := strings.ToLower(strings.TrimSpace(platform))
|
||
if p == "" {
|
||
if r := g.RequestFromCtx(ctx); r != nil {
|
||
ua := strings.ToLower(r.Header.Get("User-Agent"))
|
||
switch {
|
||
case strings.Contains(ua, "ipad"), strings.Contains(ua, "ipod"):
|
||
p = "ios"
|
||
case strings.Contains(ua, "iphone"):
|
||
p = "ios"
|
||
}
|
||
}
|
||
}
|
||
if p == "ios" {
|
||
return consts.PayChannelIOS
|
||
}
|
||
return consts.PayChannelWx
|
||
}
|
||
|
||
// createOrder 建单 / 复用(N1-1/2/10)。
|
||
//
|
||
// 复用规则:同一用户 + 同商品(reuseWhere 指定,会员=plan_key;次数包=pack_key+tool_key)+
|
||
// 状态 = 待支付 的订单存在 → 直接复用(reused=true),保证「连续 POST N 次待支付单恒为 1 条」。
|
||
//
|
||
// 若命中的待支付单已超时(created_at < now - ttl,ttl 与订单关闭同源 D2)→ 先关闭再新建,
|
||
// 避免复用一笔「即将被 gcron 关闭」的僵尸单。
|
||
func createOrder(
|
||
ctx context.Context, userId int64, orderType int, productId string, priceCents int64,
|
||
reuseWhere, orderFields g.Map,
|
||
) (*createOrderResult, error) {
|
||
user, err := getUserById(ctx, userId)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
deadline := gtime.Now().Add(-time.Duration(orderPendingTtlSeconds(ctx)) * time.Second)
|
||
|
||
base := g.Model(consts.TableMemberOrders).
|
||
Where("user_id", userId).
|
||
Where("order_type", orderType).
|
||
Where("status", consts.OrderStatusPending)
|
||
for k, v := range reuseWhere {
|
||
base = base.Where(k, v)
|
||
}
|
||
|
||
// 复用:同键的待支付单仍「新鲜」(created_at 在 TTL 内)。
|
||
//
|
||
// ⚠️ 新鲜度必须在 **SQL 侧** 判定,不能在 Go 侧用 readback.After(now):
|
||
// 本库 DSN 未指定 loc → GoFrame 写入 time.Time 时按驱动默认(UTC)落库,
|
||
// 而 **读回** 的时间串被 gvar.GTime() 按 **本地** 重新解释,导致 readback 比
|
||
// 真实时刻早一个时区偏移(本机 +08 → 8 小时)。实测刚建的单 readback=02:06,
|
||
// 而 deadline=09:51 → After() 恒为 false,会把新单误判为超时并重复建单。
|
||
// WHERE 绑定的 gtime 与 INSERT 走 **同一** 时区转换,故 SQL 侧比较是自洽的
|
||
// (与 ReleaseExpiredCouponLocks 的 `lock_at < ?` 判定同源)。
|
||
reusable, err := base.Clone().WhereGT("created_at", deadline).OrderDesc("id").One()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !reusable.IsEmpty() {
|
||
return &createOrderResult{
|
||
OutTradeNo: reusable["out_trade_no"].String(),
|
||
Reused: true,
|
||
OriginPriceCents: reusable["origin_price_cents"].Int64(),
|
||
PaidPriceCents: reusable["paid_price_cents"].Int64(),
|
||
Status: reusable["status"].Int(),
|
||
}, nil
|
||
}
|
||
|
||
// 关闭同键的所有非新鲜待支付单(created_at IS NULL 亦视为过期),保持「恒为 1 条」。
|
||
stale, err := base.Clone().Where("created_at IS NULL OR created_at < ?", deadline).All()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, r := range stale {
|
||
if err = closeOrder(ctx, r["out_trade_no"].String()); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
outTradeNo, err := newOutTradeNo()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
attach := g.Map{"userId": userId}
|
||
for k, v := range orderFields {
|
||
attach[k] = v
|
||
}
|
||
attachBytes, _ := json.Marshal(attach)
|
||
|
||
now := gtime.Now()
|
||
order := g.Map{
|
||
"out_trade_no": outTradeNo,
|
||
"wx_order_id": nil,
|
||
"user_id": userId,
|
||
"openid": user.Openid, // 建单时尽力写入;支付时会刷新为最新
|
||
"product_id": productId,
|
||
"price_cents": priceCents,
|
||
"origin_price_cents": priceCents,
|
||
"paid_price_cents": priceCents,
|
||
"discount_cents": 0,
|
||
"promo_kind": consts.PromoKindNone,
|
||
"status": consts.OrderStatusPending,
|
||
"pay_channel": consts.PayChannelWx,
|
||
"attach": string(attachBytes),
|
||
"created_at": now,
|
||
"updated_at": now,
|
||
}
|
||
for k, v := range orderFields {
|
||
order[k] = v
|
||
}
|
||
// plan_key / level_key 是会员专有列且 NOT NULL 无默认值(次数包订单无此语义)→ 补齐空串,
|
||
// 避免 Error 1364;不把库列改成 DEFAULT ''(level_key 被静默填空会导致发货时无声降级)。
|
||
for _, k := range []string{"plan_key", "level_key"} {
|
||
if _, ok := order[k]; !ok {
|
||
order[k] = ""
|
||
}
|
||
}
|
||
if _, err = g.Model(consts.TableMemberOrders).Data(order).Insert(); err != nil {
|
||
return nil, err
|
||
}
|
||
g.Log().Infof(ctx, "[order] 建单 outTradeNo=%s userId=%d type=%d productId=%s origin=%d",
|
||
outTradeNo, userId, orderType, productId, priceCents)
|
||
return &createOrderResult{
|
||
OutTradeNo: outTradeNo,
|
||
Reused: false,
|
||
OriginPriceCents: priceCents,
|
||
PaidPriceCents: priceCents,
|
||
Status: consts.OrderStatusPending,
|
||
}, nil
|
||
}
|
||
|
||
// ============================================================================
|
||
// 建签名(只建签名)
|
||
// ============================================================================
|
||
|
||
// buildPayParams 对已建待支付订单构建支付参数(N1-5 / O2 / O6)。
|
||
//
|
||
// 步骤:状态校验(已发货/已关闭/已退款 → 4015)→ 复核订单占用凭证仍有效(失效则回退原价并返回 4011/4010)
|
||
// → 刷新 session_key 并校验「支付人 = 登录人」→ 持久化 openid → 以「订单行的 product_id + paid_price_cents」签名。
|
||
func buildPayParams(ctx context.Context, userId int64, outTradeNo, code string) (*v1.MemberOrderPayRes, error) {
|
||
cfg := xpayConfig(ctx)
|
||
if !cfg.Configured() {
|
||
return nil, gerror.NewCode(gcodePayFail(),
|
||
"虚拟支付尚未配置,请在 MP 后台【虚拟支付 → 基本配置】获取 OfferID 与现网 AppKey 后填入 config.yaml")
|
||
}
|
||
|
||
order, err := mustOwnOrder(ctx, userId, outTradeNo)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if order.Status != consts.OrderStatusPending {
|
||
return nil, errOrderInvalid("订单状态不允许支付")
|
||
}
|
||
|
||
// 复核凭证:失效 → 回退订单到原价并释放占用(在独立事务内提交),随后返回 4011 / 4010。
|
||
if err = recheckVoucherOnPay(ctx, userId, outTradeNo); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 换 session_key(signature 的密钥)并校验支付人与登录人一致。
|
||
openid, err := RefreshSessionKey(ctx, userId, code)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
user, err := getUserById(ctx, userId)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if user.Openid != "" && openid != "" && openid != user.Openid {
|
||
return nil, gerror.NewCode(gcodePayFail(), "登录态与支付账号不一致,请重新进入小程序后再试")
|
||
}
|
||
if openid == "" {
|
||
return nil, gerror.NewCode(gcodePayFail(), "无法确定支付账号,请重新进入小程序后再试")
|
||
}
|
||
latest, err := getUserById(ctx, userId)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if latest.SessionKey == "" {
|
||
return nil, gerror.NewCode(gcodePayFail(), "登录态已失效,请重新进入小程序后再试")
|
||
}
|
||
|
||
// 复核可能已回退价格 → 重新读订单取最新 product_id / paid_price_cents。
|
||
order, err = mustOwnOrder(ctx, userId, outTradeNo)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 持久化 openid:发货推送 / 主动查单都需要 openid(建单时可能为空)。
|
||
if _, err = g.Model(consts.TableMemberOrders).Where("out_trade_no", outTradeNo).
|
||
Data(g.Map{"openid": openid, "updated_at": gtime.Now()}).Update(); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
signData, err := BuildSignData(cfg.OfferId, order.ProductId, order.PaidPriceCents, outTradeNo, order.Attach, cfg.Env)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
g.Log().Infof(ctx, "[xpay] 建签名 outTradeNo=%s productId=%s paid=%d offerId=%s env=%d",
|
||
outTradeNo, order.ProductId, order.PaidPriceCents, cfg.OfferId, cfg.Env)
|
||
return &v1.MemberOrderPayRes{
|
||
OutTradeNo: outTradeNo,
|
||
Mode: consts.XPayMode,
|
||
SignData: signData,
|
||
PaySig: CalcPaySig(xpayURIPayRequest, signData, cfg.AppKey),
|
||
Signature: CalcSignature(signData, latest.SessionKey),
|
||
Env: cfg.Env,
|
||
}, nil
|
||
}
|
||
|
||
// MemberOrderPay 会员订单建签名(O2)
|
||
func MemberOrderPay(ctx context.Context, outTradeNo, code string) (*v1.MemberOrderPayRes, error) {
|
||
return buildPayParams(ctx, CtxUserId(ctx), outTradeNo, code)
|
||
}
|
||
|
||
// QuotaOrderPay 次数包订单建签名(O6,与会员共用同一下签名链路)
|
||
func QuotaOrderPay(ctx context.Context, outTradeNo, code string) (*v1.QuotaOrderPayRes, error) {
|
||
return buildPayParams(ctx, CtxUserId(ctx), outTradeNo, code)
|
||
}
|
||
|
||
// ============================================================================
|
||
// 支付前凭证复核
|
||
// ============================================================================
|
||
|
||
// recheckVoucherOnPay 支付前复核订单占用凭证仍有效(N1-5 / §5.1 ③)。
|
||
//
|
||
// 若失效:在**独立事务内**释放占用并把订单回退到原价(必须提交,否则等于没回退),
|
||
// 事务成功后返回 4011(券)/ 4010(码),要求用户重新应用。
|
||
func recheckVoucherOnPay(ctx context.Context, userId int64, outTradeNo string) error {
|
||
var fail error
|
||
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||
row, err := tx.Model(consts.TableMemberOrders).
|
||
Where("out_trade_no", outTradeNo).Where("user_id", userId).LockUpdate().One()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if row.IsEmpty() {
|
||
return errOrderInvalid("订单不存在或不属于当前用户")
|
||
}
|
||
o := orderSnapshotFromRecord(row)
|
||
if o.Status != consts.OrderStatusPending {
|
||
return errOrderInvalid("订单状态不允许支付")
|
||
}
|
||
if o.PromoKind == consts.PromoKindNone {
|
||
return nil
|
||
}
|
||
reason, rerr := voucherStillValidTx(ctx, tx, o)
|
||
if rerr != nil {
|
||
return rerr
|
||
}
|
||
if reason == "" {
|
||
return nil
|
||
}
|
||
// 失效 → 释放 + 回退原价(本事务内完成并提交)
|
||
if err = rollbackVoucherTx(ctx, tx, o); err != nil {
|
||
return err
|
||
}
|
||
if o.PromoKind == consts.PromoKindCoupon {
|
||
fail = errCouponUnavailable(reason)
|
||
} else {
|
||
fail = errPromoInvalid(reason)
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return fail
|
||
}
|
||
|
||
// voucherStillValidTx 复核订单占用凭证是否仍有效;有效返回空串,失效返回可读原因。
|
||
//
|
||
// 过期判定一律在 **SQL 侧**(绑定 gtime 参数,与写入同源),不在 Go 侧比较读回时间(TZ-01)。
|
||
func voucherStillValidTx(ctx context.Context, tx gdb.TX, o *orderSnapshot) (string, error) {
|
||
switch o.PromoKind {
|
||
case consts.PromoKindCoupon:
|
||
row, err := tx.Model(consts.TableUserCoupons).Where("id", o.UserCouponId).One()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if row.IsEmpty() {
|
||
return "优惠券已失效,请重新选择", nil
|
||
}
|
||
c := &entity.UserCoupons{}
|
||
if err = row.Struct(c); err != nil {
|
||
return "", err
|
||
}
|
||
if c.UserId != o.UserId || c.Status != consts.UserCouponStatusUsed || c.UsedOrderNo != o.OutTradeNo {
|
||
return "优惠券已失效,请重新选择", nil
|
||
}
|
||
expired, err := couponExpiredAt(ctx, tx.Model(consts.TableUserCoupons), c.Id, gtime.Now())
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if expired {
|
||
return "优惠券已过期,请重新选择", nil
|
||
}
|
||
return "", nil
|
||
case consts.PromoKindCode:
|
||
row, err := tx.Model(consts.TablePromoCodes).Where("id", o.PromoCodeId).One()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if row.IsEmpty() {
|
||
return "优惠码已失效,请重新应用", nil
|
||
}
|
||
p := &entity.PromoCodes{}
|
||
if err = row.Struct(p); err != nil {
|
||
return "", err
|
||
}
|
||
if p.Status != 1 {
|
||
return "优惠码已失效,请重新应用", nil
|
||
}
|
||
expiredCnt, err := tx.Model(consts.TablePromoCodes).
|
||
Where("id", p.Id).
|
||
Where("valid_to IS NOT NULL AND valid_to < ?", gtime.Now()).
|
||
Count()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if expiredCnt > 0 {
|
||
return "优惠码已过期,请重新应用", nil
|
||
}
|
||
return "", nil
|
||
default:
|
||
return "优惠凭证异常,请重新应用", nil
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// 应用 / 更换 / 清除优惠(裁定 D3:应用时占用)
|
||
// ============================================================================
|
||
|
||
// MemberOrderApplyVoucher 应用/更换/清除订单优惠(O4 / N1-4 / D3)。
|
||
func MemberOrderApplyVoucher(
|
||
ctx context.Context, outTradeNo, promoCode string, userCouponId int64,
|
||
) (*v1.MemberOrderApplyVoucherRes, error) {
|
||
userId := CtxUserId(ctx)
|
||
// 码 / 券互斥:同时传 → 4010(后端校验,不靠前端隐藏)
|
||
if err := voucherConflictErr(promoCode, userCouponId); err != nil {
|
||
return nil, err
|
||
}
|
||
var voucher *Voucher
|
||
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||
row, err := tx.Model(consts.TableMemberOrders).
|
||
Where("out_trade_no", outTradeNo).Where("user_id", userId).LockUpdate().One()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if row.IsEmpty() {
|
||
return errOrderInvalid("订单不存在或不属于当前用户")
|
||
}
|
||
o := orderSnapshotFromRecord(row)
|
||
if o.Status != consts.OrderStatusPending {
|
||
return errOrderInvalid("订单状态不允许修改优惠")
|
||
}
|
||
v, err := applyVoucherToOrder(ctx, tx, userId, o, promoCode, userCouponId)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
voucher = v
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &v1.MemberOrderApplyVoucherRes{
|
||
Valid: true,
|
||
OriginPriceCents: voucher.OriginPriceCents,
|
||
PaidPriceCents: voucher.PaidPriceCents,
|
||
DiscountCents: voucher.DiscountCents,
|
||
PromoKind: voucher.PromoKind,
|
||
PromoCode: voucher.PromoCode,
|
||
UserCouponId: voucher.UserCouponId,
|
||
Message: voucherAppliedMessage(voucher.PromoKind),
|
||
}, nil
|
||
}
|
||
|
||
// applyVoucherToOrder 统一凭证应用抽象(约定 S3):释放旧占用 → 占用新凭证(或清除)→ 写价格快照。
|
||
//
|
||
// 全程在调用方事务内;「券A换券B」= 释放 A + 占用 B,**同一事务**原子完成。
|
||
// 产出 Voucher{productId,originPriceCents,paidPriceCents,discountCents,promoKind,userCouponId}。
|
||
//
|
||
// 幂等:重复应用同一凭证 = 先释放再占用,净效果不变(券 status 1→0→1;码 used_count -1→+1)。
|
||
func applyVoucherToOrder(
|
||
ctx context.Context, tx gdb.TX, userId int64, o *orderSnapshot, newPromoCode string, newCouponId int64,
|
||
) (*Voucher, error) {
|
||
targetKey := orderTargetKey(o)
|
||
originProductId, originPrice, err := orderTargetProduct(ctx, o.OrderType, targetKey)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// 1) 释放订单当前占用的凭证
|
||
if err = releaseVoucherForOrderTx(ctx, tx, o); err != nil {
|
||
return nil, err
|
||
}
|
||
// 2) 两空 = 清除(价格回原价、清凭证列)
|
||
newPromoCode = strings.TrimSpace(newPromoCode)
|
||
if newPromoCode == "" && newCouponId <= 0 {
|
||
v := &Voucher{
|
||
ProductId: originProductId,
|
||
OriginPriceCents: originPrice,
|
||
PaidPriceCents: originPrice,
|
||
DiscountCents: 0,
|
||
PromoKind: consts.PromoKindNone,
|
||
}
|
||
if err = writeOrderVoucherTx(ctx, tx, o.OutTradeNo, v); err != nil {
|
||
return nil, err
|
||
}
|
||
return v, nil
|
||
}
|
||
// 3) 占用新凭证(码 / 券互斥由调用方 voucherConflictErr 保证)
|
||
var v *Voucher
|
||
if newCouponId > 0 {
|
||
v, err = occupyFreebie(ctx, tx, userId, newCouponId, o.OrderType, targetKey, o.OutTradeNo, originPrice)
|
||
} else {
|
||
v, err = occupyPromo(ctx, tx, userId, o.OrderType, targetKey, newPromoCode, originPrice)
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err = writeOrderVoucherTx(ctx, tx, o.OutTradeNo, v); err != nil {
|
||
return nil, err
|
||
}
|
||
return v, nil
|
||
}
|
||
|
||
// releaseVoucherForOrderTx 释放订单当前占用的凭证(码 → used_count-1;券 → 释放回未使用)。
|
||
func releaseVoucherForOrderTx(ctx context.Context, tx gdb.TX, o *orderSnapshot) error {
|
||
switch o.PromoKind {
|
||
case consts.PromoKindCode:
|
||
if err := releasePromoOnOrder(ctx, tx, o.PromoCodeId); err != nil {
|
||
return err
|
||
}
|
||
case consts.PromoKindCoupon:
|
||
if o.UserCouponId > 0 {
|
||
if err := releaseCouponOnOrder(ctx, tx, o.UserCouponId, o.OutTradeNo); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// rollbackVoucherTx 释放订单占用的凭证并把订单回退到原价(无凭证态)。
|
||
func rollbackVoucherTx(ctx context.Context, tx gdb.TX, o *orderSnapshot) error {
|
||
originProductId, originPrice, err := orderTargetProduct(ctx, o.OrderType, orderTargetKey(o))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err = releaseVoucherForOrderTx(ctx, tx, o); err != nil {
|
||
return err
|
||
}
|
||
return writeOrderVoucherTx(ctx, tx, o.OutTradeNo, &Voucher{
|
||
ProductId: originProductId,
|
||
OriginPriceCents: originPrice,
|
||
PaidPriceCents: originPrice,
|
||
DiscountCents: 0,
|
||
PromoKind: consts.PromoKindNone,
|
||
})
|
||
}
|
||
|
||
// writeOrderVoucherTx 把价格 + 凭证快照写入订单行。
|
||
func writeOrderVoucherTx(ctx context.Context, tx gdb.TX, outTradeNo string, v *Voucher) error {
|
||
_, err := tx.Model(consts.TableMemberOrders).Where("out_trade_no", outTradeNo).Data(g.Map{
|
||
"product_id": v.ProductId,
|
||
"origin_price_cents": v.OriginPriceCents,
|
||
"paid_price_cents": v.PaidPriceCents,
|
||
"discount_cents": v.DiscountCents,
|
||
"promo_kind": v.PromoKind,
|
||
"promo_code_id": v.PromoCodeId,
|
||
"promo_code": v.PromoCode,
|
||
"user_coupon_id": v.UserCouponId,
|
||
"updated_at": gtime.Now(),
|
||
}).Update()
|
||
return err
|
||
}
|
||
|
||
// voucherAppliedMessage 应用优惠后的可读提示。
|
||
func voucherAppliedMessage(kind int) string {
|
||
switch kind {
|
||
case consts.PromoKindCode:
|
||
return "优惠码已应用"
|
||
case consts.PromoKindCoupon:
|
||
return "优惠券已应用"
|
||
default:
|
||
return "已清除优惠"
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// 超时关闭(gcron,D2 同源)
|
||
// ============================================================================
|
||
|
||
// ClosePendingOrders 关闭超时未支付的待支付订单,并释放其占用中的免单券(gcron 兜底,N1)。
|
||
//
|
||
// 幂等:并发 / 重复执行对同一单只会生效一次(事务内按 status 判定)。
|
||
func ClosePendingOrders(ctx context.Context) {
|
||
ttl := orderPendingTtlSeconds(ctx)
|
||
if ttl <= 0 {
|
||
return
|
||
}
|
||
deadline := gtime.Now().Add(-time.Duration(ttl) * time.Second)
|
||
records, err := g.Model(consts.TableMemberOrders).
|
||
Where("status", consts.OrderStatusPending).
|
||
WhereLT("created_at", deadline).
|
||
OrderAsc("id").Limit(200).All()
|
||
if err != nil {
|
||
g.Log().Warningf(ctx, "[order] 扫描超时待支付订单失败: %v", err)
|
||
return
|
||
}
|
||
closed := 0
|
||
for _, r := range records {
|
||
outTradeNo := r["out_trade_no"].String()
|
||
if err = closeOrder(ctx, outTradeNo); err != nil {
|
||
g.Log().Warningf(ctx, "[order] 关闭超时订单失败 outTradeNo=%s: %v", outTradeNo, err)
|
||
continue
|
||
}
|
||
closed++
|
||
}
|
||
if closed > 0 {
|
||
g.Log().Infof(ctx, "[order] 已关闭超时未支付订单 %d 笔(ttl=%ds)", closed, ttl)
|
||
}
|
||
}
|
||
|
||
// closeOrder 关闭单笔待支付订单:释放占用凭证 → status=3(已关闭)。幂等。
|
||
func closeOrder(ctx context.Context, outTradeNo string) error {
|
||
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||
row, err := tx.Model(consts.TableMemberOrders).Where("out_trade_no", outTradeNo).LockUpdate().One()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if row.IsEmpty() {
|
||
return nil // 单不存在:幂等返回
|
||
}
|
||
o := orderSnapshotFromRecord(row)
|
||
if o.Status != consts.OrderStatusPending {
|
||
return nil // 已发货 / 已关闭 / 已退款:幂等返回
|
||
}
|
||
if err = releaseVoucherForOrderTx(ctx, tx, o); err != nil {
|
||
return err
|
||
}
|
||
_, err = tx.Model(consts.TableMemberOrders).Where("out_trade_no", outTradeNo).Data(g.Map{
|
||
"status": consts.OrderStatusClosed,
|
||
"updated_at": gtime.Now(),
|
||
}).Update()
|
||
return err
|
||
})
|
||
}
|