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 }) }