18b97d5d35
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
792 lines
27 KiB
Go
792 lines
27 KiB
Go
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=true:pay 确认取消(钱未扣/未入账),本地条件更新标 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 之一(否则 ErrInvalidStatus,handler 回 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
|
||
}
|