小程序一些功能更新
This commit is contained in:
679
internal/logic/order_pay.go
Normal file
679
internal/logic/order_pay.go
Normal file
@@ -0,0 +1,679 @@
|
||||
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
|
||||
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(),
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user