Files
jiu/backend/internal/service/pay.go
T

792 lines
27 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 service
import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
)
// PayService jiu ↔ pay 收款中枢对接:下单/查单/webhook 均为 pay v2 契约。
// 四块职责:下单(CreatePurchase)、webhook 入账(HandleCallback)、续期(entitle)、查单兜底(reconcileOnce)。
type PayService struct {
db *gorm.DB
baseURL string
secret string
retURL string
client *http.Client
seenMu sync.Mutex // 保护 seen(nonce 防重放,单实例内存实现)
seen map[string]time.Time
}
var (
ErrPayNotConfigured = errors.New("在线支付未配置")
ErrUnknownPlan = errors.New("未知套餐")
ErrPaySignature = errors.New("签名校验失败")
ErrPayAmount = errors.New("回调金额与订单不符")
ErrPurchaseNotFound = errors.New("购买记录不存在")
ErrPromoUsed = errors.New("首月特惠每个门店限购一次,本店已享受过")
ErrInvalidStatus = errors.New("无效的订单状态")
)
// PromoBizCode 新店首月特惠(¥1/30 天标准版),每个门店仅可购买一次。
const PromoBizCode = "promo_first_month"
// payPlan biz_code → 权益映射(与 pay 侧 seed 的套餐一一对应,金额权威在 pay,此处 price 仅作前端展示核对)。
type payPlan struct {
Days int
Tier string
Type string // License.Type: monthly | annual
MaxDevices int
Features model.JSON
}
var payPlans = map[string]payPlan{
PromoBizCode: {Days: 30, Tier: "standard", Type: "monthly", MaxDevices: 2,
Features: model.JSON{"max_warehouses": 1, "image_quota": 1000, "ai_analysis": false}},
"monthly_standard": {Days: 30, Tier: "standard", Type: "monthly", MaxDevices: 2,
Features: model.JSON{"max_warehouses": 1, "image_quota": 1000, "ai_analysis": false}},
"annual_standard": {Days: 365, Tier: "standard", Type: "annual", MaxDevices: 2,
Features: model.JSON{"max_warehouses": 1, "image_quota": 1000, "ai_analysis": false}},
"monthly_pro": {Days: 30, Tier: "pro", Type: "monthly", MaxDevices: 5,
Features: model.JSON{"max_warehouses": 0, "image_quota": 10000, "ai_analysis": true}},
"annual_pro": {Days: 365, Tier: "pro", Type: "annual", MaxDevices: 5,
Features: model.JSON{"max_warehouses": 0, "image_quota": 10000, "ai_analysis": true}},
}
func NewPayService(db *gorm.DB, baseURL, secret, returnURL string) *PayService {
return &PayService{
db: db,
baseURL: strings.TrimRight(baseURL, "/"),
secret: secret,
retURL: returnURL,
client: &http.Client{Timeout: 10 * time.Second},
}
}
func (s *PayService) Configured() bool { return s.secret != "" }
// ---------- ① 购买下单 ----------
// PurchaseResult 下单结果。RenderType/Payload/AmountMinor/Currency/Subject 是 pay v2 契约的一手字段;
// PayURL/Amount 是给官网 checkout 与旧客户端读的兼容字段(Deprecated,观察一版后视情况收敛)。
type PurchaseResult struct {
OutTradeNo string `json:"out_trade_no"`
RenderType string `json:"render_type"`
Payload map[string]any `json:"payload"`
AmountMinor int64 `json:"amount_minor"`
Currency string `json:"currency"`
Subject string `json:"subject"`
PayURL string `json:"pay_url"` // Deprecated: render_type==redirect 时 = payload.url
Amount string `json:"amount"` // Deprecated: formatMinor(AmountMinor) 分转元字符串
}
// CreatePurchase 建购买记录并调 pay 下单,返回收银台会话(session)。
// clientType"pc"/"mobile"/""):pay v2 契约暂未透传端型决定收银台形态的参数,
// 端型透传能力欠账,pay 补契约后跟进;本参数先保留签名不 breaking 调用方。
func (s *PayService) CreatePurchase(shopID, userID uint64, bizCode, clientType string) (*PurchaseResult, error) {
if !s.Configured() {
return nil, ErrPayNotConfigured
}
if _, ok := payPlans[bizCode]; !ok {
return nil, ErrUnknownPlan
}
if bizCode == PromoBizCode {
used, err := s.PromoUsed(shopID)
if err != nil {
return nil, err
}
if used {
return nil, ErrPromoUsed
}
}
p := model.LicensePurchase{ShopID: shopID, UserID: userID, ProductBizCode: bizCode, Status: "pending"}
if err := s.db.Create(&p).Error; err != nil {
return nil, err
}
payload := map[string]any{
"sku": bizCode,
"method": "alipay",
"biz_system": "jiu",
"biz_ref": strconv.FormatUint(p.ID, 10),
"return_url": s.retURL,
}
reqBody, _ := json.Marshal(payload)
respBody, err := s.signedPost("/api/v2/orders", reqBody)
if err != nil {
return nil, fmt.Errorf("pay 下单失败: %w", err)
}
var resp struct {
Data struct {
OrderNo string `json:"order_no"`
Session struct {
RenderType string `json:"render_type"`
Payload map[string]any `json:"payload"`
} `json:"session"`
} `json:"data"`
}
if err := json.Unmarshal(respBody, &resp); err != nil || resp.Data.OrderNo == "" || resp.Data.Session.RenderType == "" {
return nil, fmt.Errorf("pay 下单响应异常")
}
result := &PurchaseResult{
OutTradeNo: resp.Data.OrderNo,
RenderType: resp.Data.Session.RenderType,
Payload: resp.Data.Session.Payload,
}
if result.RenderType == "redirect" {
if u, ok := result.Payload["url"].(string); ok {
result.PayURL = u
}
}
updates := map[string]any{
"out_trade_no": result.OutTradeNo,
"pay_url": result.PayURL,
}
// best-effort 查单回填金额:查单失败不阻断下单,金额留 0 由 D1 兜底(对账/结果页轮询会补)
if st, err := s.queryOrder(result.OutTradeNo); err == nil {
result.AmountMinor = st.AmountMinor
result.Currency = st.Currency
result.Subject = st.Subject
updates["amount_minor"] = st.AmountMinor
updates["currency"] = st.Currency
}
result.Amount = formatMinor(result.AmountMinor)
if err := s.db.Model(&model.LicensePurchase{}).Where("id = ?", p.ID).Updates(updates).Error; err != nil {
return nil, err
}
return result, nil
}
// formatMinor 分→元字符串(仅 2 位小数币种如 CNY)。minor<=0 时留空(金额未回填)。
func formatMinor(minor int64) string {
if minor <= 0 {
return ""
}
return fmt.Sprintf("%d.%02d", minor/100, minor%100)
}
// signedPost 按契约对原始 body 签名后 POST 到 pay。
func (s *PayService) signedPost(path string, rawBody []byte) ([]byte, error) {
req, err := http.NewRequest(http.MethodPost, s.baseURL+path, strings.NewReader(string(rawBody)))
if err != nil {
return nil, err
}
ts := strconv.FormatInt(time.Now().Unix(), 10)
nonce := uuid.New().String()
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Pay-System", "jiu")
req.Header.Set("X-Pay-Timestamp", ts)
req.Header.Set("X-Pay-Nonce", nonce)
req.Header.Set("X-Pay-Sign", util.PaySign(s.secret, "jiu", ts, nonce, string(rawBody)))
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("pay HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return body, nil
}
// ---------- ② webhook 入账 ----------
// payNotification pay v2 webhook payload(无 trade_no 字段,事件以 event_type 为准,
// X-Pay-Event 头不参与签名不依赖)。
type payNotification struct {
EventType string `json:"event_type"`
OutTradeNo string `json:"out_trade_no"`
BizSystem string `json:"biz_system"`
BizRef string `json:"biz_ref"`
ProductBizCode string `json:"product_biz_code"`
AmountMinor int64 `json:"amount_minor"`
Currency string `json:"currency"`
Channel string `json:"channel"`
PaidAt string `json:"paid_at"`
}
// HandleCallback 验签 + 时间窗 + nonce 防重放 + 按 event_type 分发。错误分两类:
// ErrPaySignature(回 401,不重试也无效);其余(回非 SUCCESS,pay 会重试)。
func (s *PayService) HandleCallback(rawBody []byte, ts, nonce, sign string) error {
if !s.Configured() {
return ErrPaySignature
}
if !util.PaySignVerify(s.secret, sign, "jiu", ts, nonce, string(rawBody)) {
return ErrPaySignature
}
tsInt, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return ErrPaySignature
}
if d := time.Since(time.Unix(tsInt, 0)); d > 5*time.Minute || d < -5*time.Minute {
return ErrPaySignature
}
if s.replayed(nonce) {
return ErrPaySignature
}
var n payNotification
if err := json.Unmarshal(rawBody, &n); err != nil || n.OutTradeNo == "" {
return fmt.Errorf("回调体解析失败")
}
paidAt := time.Now()
if t, err := time.Parse(time.RFC3339, n.PaidAt); err == nil {
paidAt = t
}
switch n.EventType {
case "payment.succeeded":
return s.settle(n.OutTradeNo, n.ProductBizCode, n.AmountMinor, n.Currency, n.Channel, paidAt)
default: // refund.*/未来事件:本期不接(另起任务);ack 防 60s 永久重投,ALERT 留痕
log.Printf("[pay] ALERT unhandled webhook event=%s out_trade_no=%s (acked)", n.EventType, n.OutTradeNo)
return nil
}
}
// replayed nonce 防重放:10 分钟窗口内重复即拒绝(pay 合法重投每次生成新 nonce 不受影响;
// 单实例内存实现,重启丢失由 settle 幂等兜底)。
func (s *PayService) replayed(nonce string) bool {
s.seenMu.Lock()
defer s.seenMu.Unlock()
now := time.Now()
for k, t := range s.seen {
if now.Sub(t) > 10*time.Minute {
delete(s.seen, k)
}
}
if _, ok := s.seen[nonce]; ok {
return true
}
if s.seen == nil {
s.seen = map[string]time.Time{}
}
s.seen[nonce] = now
return false
}
// settle 入账:幂等(同 out_trade_no 只续一次)+ 金额核对(残单用入参权威金额回填)+ 同事务续期。
// webhook 与查单兜底(reconcileOnce)共用此入口,两个调用方都自带权威金额(webhook 签名负载 /
// reconcile 自身 queryOrder 结果),恒非零。
//
// 事务内以 FOR UPDATE 读到的行值为准:若行内 amount_minor 仍为 0(残单,建单时回填曾失败),
// 直接用入参 amountMinor 回填落库;若行内已有金额,则入参必须与之一致(防篡改)。核对逻辑
// p.AmountMinor==0 || amountMinor != p.AmountMinor || currency 不一致 → ErrPayAmount)对两种
// 情形都成立:入参为 0(不应出现于真实流)→ 回填后行内仍为 0 → 核对不过,fail-closed。
func (s *PayService) settle(outTradeNo, bizCode string, amountMinor int64, currency, channel string, paidAt time.Time) error {
var shopID uint64
err := s.db.Transaction(func(tx *gorm.DB) error {
var p model.LicensePurchase
if err := tx.Set("gorm:query_option", "FOR UPDATE").
Where("out_trade_no = ?", outTradeNo).First(&p).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPurchaseNotFound
}
return err
}
if p.Status == "paid" { // 幂等:pay 会重发
return nil
}
if p.AmountMinor == 0 && amountMinor > 0 { // 行内金额仍为 0 才回填,入参权威金额直接落库
p.AmountMinor, p.Currency = amountMinor, currency
if err := tx.Model(&model.LicensePurchase{}).Where("id = ?", p.ID).
Updates(map[string]any{"amount_minor": p.AmountMinor, "currency": p.Currency}).Error; err != nil {
return err
}
}
if p.AmountMinor == 0 || amountMinor != p.AmountMinor || !strings.EqualFold(currency, p.Currency) {
log.Printf("[pay] amount mismatch out_trade_no=%s purchase=%d/%s callback=%d/%s", outTradeNo, p.AmountMinor, p.Currency, amountMinor, currency)
return ErrPayAmount
}
// 权益按建单时的套餐映射;回调 biz_code 仅一致性校验(不一致以本地为准并告警)
if bizCode != "" && bizCode != p.ProductBizCode {
log.Printf("[pay] biz_code mismatch out_trade_no=%s purchase=%s callback=%s", outTradeNo, p.ProductBizCode, bizCode)
}
plan, ok := payPlans[p.ProductBizCode]
if !ok {
return ErrUnknownPlan
}
// 特惠限次 webhook 兜底(防绕过前端/并发双买):入账前事务内再查一次,
// 本店已有另一笔已支付特惠单 → 本单只标 paid 不叠加时长,记告警(契约 INTEGRATION-BOARD 要求)
entitleOK := true
if p.ProductBizCode == PromoBizCode {
var dup int64
if err := tx.Model(&model.LicensePurchase{}).
Where("shop_id = ? AND product_biz_code = ? AND status = ? AND id <> ?",
p.ShopID, PromoBizCode, "paid", p.ID).
Count(&dup).Error; err != nil {
return err
}
if dup > 0 {
entitleOK = false
log.Printf("[pay] ALERT promo double-claim out_trade_no=%s shop=%d:本店已享受过首月特惠,本单不叠加时长", outTradeNo, p.ShopID)
}
}
updates := map[string]any{
"status": "paid",
"channel": channel,
"paid_at": paidAt,
}
if entitleOK {
renewedTo, err := entitle(tx, p.ShopID, plan)
if err != nil {
return err
}
updates["renewed_to"] = renewedTo
}
shopID = p.ShopID
return tx.Model(&model.LicensePurchase{}).Where("id = ?", p.ID).Updates(updates).Error
})
if err != nil {
return err
}
if shopID != 0 {
// 事务提交后失效授权 phase 缓存,写权限即时恢复(同 Redeem)
middleware.InvalidateLicensePhase(shopID)
log.Printf("[pay] settled out_trade_no=%s shop=%d", outTradeNo, shopID)
}
return nil
}
// entitle 给门店续期:时长直接叠加(未过期从到期日叠,已过期从现在起算),
// 并写入套餐权益。逻辑对齐 LicenseService.Redeem 的叠加段。返回续期后的授权到期日
// settle 落库 renewed_to 展示用)。
func entitle(tx *gorm.DB, shopID uint64, plan payPlan) (time.Time, error) {
now := time.Now()
var lic model.License
err := tx.Set("gorm:query_option", "FOR UPDATE").
Where("shop_id = ? AND is_active = ?", shopID, true).
Order("id DESC").First(&lic).Error
creating := errors.Is(err, gorm.ErrRecordNotFound)
if err != nil && !creating {
return time.Time{}, err
}
base := now
if !creating && lic.ExpiresAt != nil && lic.ExpiresAt.After(now) {
base = *lic.ExpiresAt
}
expires := base.Add(time.Duration(plan.Days) * 24 * time.Hour)
if creating {
lic = model.License{
ShopID: shopID,
LicenseKey: "PAY-" + uuid.New().String(),
Type: plan.Type,
Tier: plan.Tier,
ExpiresAt: &expires,
IsActive: true,
MaxDevices: plan.MaxDevices,
Features: plan.Features,
}
if err := tx.Create(&lic).Error; err != nil {
return time.Time{}, err
}
return expires, nil
}
if err := tx.Model(&lic).Updates(map[string]any{
"type": plan.Type,
"tier": plan.Tier,
"expires_at": expires,
"is_active": true,
"max_devices": plan.MaxDevices,
"features": plan.Features,
}).Error; err != nil {
return time.Time{}, err
}
return expires, nil
}
// BackfillPurchaseAmountMinor 启动回填:v1 存量购买单 amount("2999.00") → amount_minor(299900)+CNY。
// 幂等:只处理 amount_minor=0 且 amount 非空的行(参照 backfillPinyin 先例)。
func BackfillPurchaseAmountMinor(db *gorm.DB) {
var rows []model.LicensePurchase
if err := db.Where("amount_minor = 0 AND amount <> ''").Find(&rows).Error; err != nil {
return
}
for _, p := range rows {
if c, err := toCents(p.Amount); err == nil && c > 0 {
db.Model(&model.LicensePurchase{}).Where("id = ?", p.ID).
Updates(map[string]any{"amount_minor": c, "currency": "CNY"})
}
}
}
func toCents(s string) (int64, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, fmt.Errorf("empty amount")
}
neg := false
if strings.HasPrefix(s, "-") {
neg, s = true, s[1:]
}
intPart, frac, _ := strings.Cut(s, ".")
if intPart == "" {
intPart = "0"
}
frac = frac + "00"
i, err := strconv.ParseInt(intPart, 10, 64)
if err != nil {
return 0, err
}
f, err := strconv.ParseInt(frac[:2], 10, 64)
if err != nil {
return 0, err
}
c := i*100 + f
if neg {
c = -c
}
return c, nil
}
// ---------- ③ 状态查询(结果页轮询) ----------
type PurchaseStatus struct {
OutTradeNo string `json:"out_trade_no"`
Status string `json:"status"`
BizCode string `json:"product_biz_code"`
Amount string `json:"amount"`
AmountMinor int64 `json:"amount_minor"`
Currency string `json:"currency"`
PaidAt *time.Time `json:"paid_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"` // 续期后的门店授权到期时间
}
func (s *PayService) Status(shopID uint64, outTradeNo string) (*PurchaseStatus, error) {
var p model.LicensePurchase
if err := s.db.Where("shop_id = ? AND out_trade_no = ?", shopID, outTradeNo).First(&p).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrPurchaseNotFound
}
return nil, err
}
amount := formatMinor(p.AmountMinor)
if amount == "" {
amount = p.Amount // 残单回退:v1 遗留/查单未回填时用旧列
}
st := &PurchaseStatus{
OutTradeNo: p.OutTradeNo, Status: p.Status, BizCode: p.ProductBizCode,
Amount: amount, AmountMinor: p.AmountMinor, Currency: p.Currency, PaidAt: p.PaidAt,
}
if p.Status == "paid" {
var lic model.License
if err := s.db.Where("shop_id = ? AND is_active = ?", shopID, true).
Order("id DESC").First(&lic).Error; err == nil {
st.ExpiresAt = lic.ExpiresAt
}
}
return st, nil
}
// PromoUsed 返回本店是否已享受过首月特惠(已支付的特惠单存在即视为已用)。
func (s *PayService) PromoUsed(shopID uint64) (bool, error) {
var count int64
err := s.db.Model(&model.LicensePurchase{}).
Where("shop_id = ? AND product_biz_code = ? AND status = ?", shopID, PromoBizCode, "paid").
Count(&count).Error
return count > 0, err
}
// ---------- ④ 查单兜底 ----------
// StartPayReconcile 后台每 60s 对 pending 超 5 分钟的购买单主动查 pay 对账,
// 防 webhook 全丢。与 webhook 同一入账入口(settle),天然幂等。
func StartPayReconcile(s *PayService) {
if !s.Configured() {
log.Println("[pay] PAY_SECRET 未配置,查单兜底不启动")
return
}
go func() {
for {
time.Sleep(time.Minute)
s.reconcileOnce()
}
}()
}
func (s *PayService) reconcileOnce() {
var pendings []model.LicensePurchase
cutoff := time.Now().Add(-5 * time.Minute)
if err := s.db.Where("status = ? AND out_trade_no <> '' AND created_at < ?", "pending", cutoff).
Limit(50).Find(&pendings).Error; err != nil {
return
}
for _, p := range pendings {
st, err := s.queryOrder(p.OutTradeNo)
if err != nil {
continue
}
// v2 八态映射:created|pending 继续等;paid 入账续期;canceled|expired 标 failed
// WHERE status='pending' 防竞态,webhook 可能已抢先入账);退款三态本轮不冲权益,
// 仅记录(退款接入另起任务)。
switch st.Status {
case "paid":
paidAt := time.Now()
if st.PaidAt != nil {
paidAt = *st.PaidAt
}
// st.AmountMinor/st.Currency 已是本次查单拿到的权威价,settle 收到非 0 金额
// 不会再触发内部补查(见 settle 注释),此处查单只外呼一次。
if err := s.settle(p.OutTradeNo, p.ProductBizCode, st.AmountMinor, st.Currency, "", paidAt); err != nil {
log.Printf("[pay] reconcile settle failed out_trade_no=%s: %v", p.OutTradeNo, err)
} else {
log.Printf("[pay] reconcile settled out_trade_no=%s (webhook missed)", p.OutTradeNo)
}
case "canceled", "expired":
s.db.Model(&model.LicensePurchase{}).Where("id = ? AND status = 'pending'", p.ID).
Update("status", "failed")
case "refunding", "partially_refunded", "refunded":
log.Printf("[pay] order %s status=%s (no-op,退款接入另起任务)", p.OutTradeNo, st.Status)
} // created/pending:继续等
}
}
// payOrderStatus 查单响应(pay v2 契约,GET /api/v2/orders/:order_no,无鉴权)。
// 不回传 biz_ref/trade_no。status 八态:created|pending|paid|canceled|expired|
// refunding|partially_refunded|refunded。
type payOrderStatus struct {
OrderNo string `json:"order_no"`
Status string `json:"status"`
Subject string `json:"subject"`
AmountMinor int64 `json:"amount_minor"`
Currency string `json:"currency"`
PaidAt *time.Time `json:"paid_at"`
}
// queryOrder 查单(契约未要求签名头)。
func (s *PayService) queryOrder(orderNo string) (*payOrderStatus, error) {
resp, err := s.client.Get(s.baseURL + "/api/v2/orders/" + orderNo)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("pay HTTP %d", resp.StatusCode)
}
var r struct {
Data payOrderStatus `json:"data"`
}
if err := json.Unmarshal(body, &r); err != nil {
return nil, err
}
return &r.Data, nil
}
// ---------- ⑤ 取消透传(防 pending 单堆积挤占 reconcile 每轮 50 条限额)----------
// CancelPurchase 取消本店一笔购买单:仅对 pending 单外呼 pay 取消;已终态/不存在均幂等无害。
// pay 侧语义是 `UPDATE ... WHERE out_trade_no=? AND status='pending'`canceled=RowsAffected>0
// - canceled=truepay 确认取消(钱未扣/未入账),本地条件更新标 failed。
// - canceled=false:已支付竞态(取消请求到达 pay 时单已被支付),本地必须保持 pending,
// 等 webhook/reconcile 正常入账,不得误标 failed(否则钱已收但门店权益丢失)。
func (s *PayService) CancelPurchase(shopID uint64, outTradeNo string) (bool, error) {
var p model.LicensePurchase
if err := s.db.Where("shop_id = ? AND out_trade_no = ?", shopID, outTradeNo).First(&p).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, ErrPurchaseNotFound
}
return false, err
}
if p.Status != "pending" { // 非 pending:幂等无害,不外呼 pay
return false, nil
}
canceled, err := s.cancelOrder(outTradeNo)
if err != nil {
return false, fmt.Errorf("pay 取消失败: %w", err)
}
if !canceled {
return false, nil
}
if err := s.db.Model(&model.LicensePurchase{}).Where("id = ? AND status = 'pending'", p.ID).
Update("status", "failed").Error; err != nil {
return false, err
}
return true, nil
}
// cancelOrder 调 pay 取消单(无签名无请求体)。
func (s *PayService) cancelOrder(orderNo string) (bool, error) {
resp, err := s.client.Post(s.baseURL+"/api/v2/orders/"+orderNo+"/cancel", "application/json", nil)
if err != nil {
return false, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("pay HTTP %d", resp.StatusCode)
}
var r struct {
Data struct {
Canceled bool `json:"canceled"`
} `json:"data"`
}
if err := json.Unmarshal(body, &r); err != nil {
return false, err
}
return r.Data.Canceled, nil
}
// ---------- ⑥ 订单列表(授权管理·订单管理 tab 数据源)----------
// PurchaseListItem 订单管理 tab 一行。pay_url 仅 pending 单输出(继续支付用),
// paid/failed 抹空;amount 是 Deprecated 兼容串(同 formatMinor 口径)。
type PurchaseListItem struct {
OutTradeNo string `json:"out_trade_no"`
BizCode string `json:"product_biz_code"`
AmountMinor int64 `json:"amount_minor"`
Currency string `json:"currency"`
Amount string `json:"amount"` // Deprecated: formatMinor(AmountMinor) 分转元字符串
Status string `json:"status"`
PayURL string `json:"pay_url"`
UserName string `json:"user_name"` // 下单人显示名(LEFT JOIN users.real_name),查不到留空
CreatedAt time.Time `json:"created_at"`
PaidAt *time.Time `json:"paid_at"`
RenewedTo *time.Time `json:"renewed_to"`
}
// PurchaseSummary 汇总统计:同店全量(不受分页/status 筛选影响)。
type PurchaseSummary struct {
PaidTotalMinor int64 `json:"paid_total_minor"`
PaidCount int64 `json:"paid_count"`
PendingCount int64 `json:"pending_count"`
TotalCount int64 `json:"total_count"`
}
// PurchaseList ListPurchases 响应体。
type PurchaseList struct {
Items []PurchaseListItem `json:"items"`
Total int64 `json:"total"`
Summary PurchaseSummary `json:"summary"`
}
// ListPurchases 本店订单列表:分页 + status 筛选 + 全量汇总。status 空=全部,
// 非空须为 pending/paid/failed 之一(否则 ErrInvalidStatushandler 回 400)。
func (s *PayService) ListPurchases(shopID uint64, page, pageSize int, status string) (*PurchaseList, error) {
if status != "" && status != "pending" && status != "paid" && status != "failed" {
return nil, ErrInvalidStatus
}
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
// 每次从 s.db 重新起 query,避免 *gorm.DB 复用累加条件的坑。
scope := func() *gorm.DB {
q := s.db.Model(&model.LicensePurchase{}).Where("shop_id = ?", shopID)
if status != "" {
q = q.Where("status = ?", status)
}
return q
}
var total int64
if err := scope().Count(&total).Error; err != nil {
return nil, err
}
var rows []model.LicensePurchase
if err := scope().Order("created_at DESC, id DESC").
Offset((page - 1) * pageSize).Limit(pageSize).Find(&rows).Error; err != nil {
return nil, err
}
// 下单人显示名:批量查 users,查不到留空。
userIDs := make([]uint64, 0, len(rows))
seen := map[uint64]bool{}
for _, p := range rows {
if !seen[p.UserID] {
seen[p.UserID] = true
userIDs = append(userIDs, p.UserID)
}
}
names := map[uint64]string{}
if len(userIDs) > 0 {
var users []model.User
if err := s.db.Where("id IN ?", userIDs).Find(&users).Error; err != nil {
return nil, err
}
for _, u := range users {
if u.RealName != "" {
names[u.ID] = u.RealName
} else {
names[u.ID] = u.Username
}
}
}
items := make([]PurchaseListItem, 0, len(rows))
for _, p := range rows {
amount := formatMinor(p.AmountMinor)
if amount == "" {
amount = p.Amount
}
payURL := ""
if p.Status == "pending" {
payURL = p.PayURL
}
items = append(items, PurchaseListItem{
OutTradeNo: p.OutTradeNo,
BizCode: p.ProductBizCode,
AmountMinor: p.AmountMinor,
Currency: p.Currency,
Amount: amount,
Status: p.Status,
PayURL: payURL,
UserName: names[p.UserID],
CreatedAt: p.CreatedAt,
PaidAt: p.PaidAt,
RenewedTo: p.RenewedTo,
})
}
// 汇总:同店全量,一次 GROUP BY 扫出后内存汇总,不受分页/status 筛选影响。
var groups []struct {
Status string
Cnt int64
Amt int64
}
if err := s.db.Model(&model.LicensePurchase{}).
Select("status, COUNT(*) as cnt, COALESCE(SUM(amount_minor),0) as amt").
Where("shop_id = ?", shopID).Group("status").Scan(&groups).Error; err != nil {
return nil, err
}
summary := PurchaseSummary{}
for _, g := range groups {
summary.TotalCount += g.Cnt
switch g.Status {
case "paid":
summary.PaidCount = g.Cnt
summary.PaidTotalMinor = g.Amt
case "pending":
summary.PendingCount = g.Cnt
}
}
return &PurchaseList{Items: items, Total: total, Summary: summary}, nil
}