Files
jiu/backend/internal/service/pay.go
T
wangjia a996593b1c fix(backend): 特惠限次 webhook 兜底——第二笔已支付特惠单只标 paid 不叠加时长
按 pay-contract 看板要求:续期入账前事务内再查本店是否已有另一笔 paid 的
promo_first_month(防绕过前端/并发双买);命中则跳过 entitle、记 ALERT 告警,
回调仍回 SUCCESS(钱已收,退款人工处理)。补对应测试(双买不叠加/正常套餐不受影响)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-03 22:28:21 +08:00

516 lines
16 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 收款中枢对接(契约 ~/code/pay-contract openapi.yaml v1.0.0)。
// 四块职责:下单(CreatePurchase)、webhook 入账(HandleCallback)、续期(entitle)、查单兜底(reconcileOnce)。
type PayService struct {
db *gorm.DB
baseURL string
secret string
retURL string
client *http.Client
prodMu sync.Mutex
prodCache map[string]int64 // biz_code -> pay product_id
prodAt time.Time
}
var (
ErrPayNotConfigured = errors.New("在线支付未配置")
ErrUnknownPlan = errors.New("未知套餐")
ErrPaySignature = errors.New("签名校验失败")
ErrPayAmount = errors.New("回调金额与订单不符")
ErrPurchaseNotFound = errors.New("购买记录不存在")
ErrPromoUsed = 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 != "" }
// ---------- ① 购买下单 ----------
type PurchaseResult struct {
PayURL string `json:"pay_url"`
OutTradeNo string `json:"out_trade_no"`
Amount string `json:"amount"`
Subject string `json:"subject"`
}
// CreatePurchase 建购买记录并调 pay 下单,返回收银台跳转 URL。
func (s *PayService) CreatePurchase(shopID, userID uint64, bizCode 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
}
}
productID, err := s.productID(bizCode)
if err != nil {
return nil, fmt.Errorf("获取套餐信息失败: %w", err)
}
p := model.LicensePurchase{ShopID: shopID, UserID: userID, ProductBizCode: bizCode, Status: "pending"}
if err := s.db.Create(&p).Error; err != nil {
return nil, err
}
reqBody, _ := json.Marshal(map[string]any{
"product_id": productID,
"biz_system": "jiu",
"biz_ref": strconv.FormatUint(p.ID, 10),
"return_url": s.retURL,
})
respBody, err := s.signedPost("/api/v1/orders", reqBody)
if err != nil {
return nil, fmt.Errorf("pay 下单失败: %w", err)
}
var resp struct {
Data PurchaseResult `json:"data"`
}
if err := json.Unmarshal(respBody, &resp); err != nil || resp.Data.PayURL == "" || resp.Data.OutTradeNo == "" {
return nil, fmt.Errorf("pay 下单响应异常")
}
if err := s.db.Model(&model.LicensePurchase{}).Where("id = ?", p.ID).Updates(map[string]any{
"out_trade_no": resp.Data.OutTradeNo,
"amount": resp.Data.Amount,
}).Error; err != nil {
return nil, err
}
return &resp.Data, nil
}
// 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
}
// productID 按 biz_code 查 pay 套餐 idGET /api/v1/products,内存缓存 10 分钟)。
func (s *PayService) productID(bizCode string) (int64, error) {
s.prodMu.Lock()
defer s.prodMu.Unlock()
if s.prodCache != nil && time.Since(s.prodAt) < 10*time.Minute {
if id, ok := s.prodCache[bizCode]; ok {
return id, nil
}
}
resp, err := s.client.Get(s.baseURL + "/api/v1/products")
if err != nil {
return 0, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("pay HTTP %d", resp.StatusCode)
}
var pr struct {
Data []struct {
ID int64 `json:"id"`
BizCode string `json:"biz_code"`
} `json:"data"`
}
if err := json.Unmarshal(body, &pr); err != nil {
return 0, err
}
cache := make(map[string]int64, len(pr.Data))
for _, p := range pr.Data {
if p.BizCode != "" {
cache[p.BizCode] = p.ID
}
}
s.prodCache, s.prodAt = cache, time.Now()
id, ok := cache[bizCode]
if !ok {
return 0, fmt.Errorf("pay 侧无 biz_code=%s 的套餐", bizCode)
}
return id, nil
}
// ---------- ② webhook 入账 ----------
type payNotification struct {
OutTradeNo string `json:"out_trade_no"`
BizSystem string `json:"biz_system"`
BizRef string `json:"biz_ref"`
ProductBizCode string `json:"product_biz_code"`
Amount string `json:"amount"`
TradeNo string `json:"trade_no"`
Channel string `json:"channel"`
PaidAt string `json:"paid_at"`
}
// HandleCallback 验签 + 幂等 + 金额核对 + 续期。错误分两类:
// 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
}
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
}
return s.settle(n.OutTradeNo, n.ProductBizCode, n.Amount, n.TradeNo, n.Channel, paidAt)
}
// settle 入账:幂等(同 out_trade_no 只续一次)+ 金额核对 + 同事务续期。
// webhook 与查单兜底共用此入口。
func (s *PayService) settle(outTradeNo, bizCode, amount, tradeNo, 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 !amountEqual(p.Amount, amount) {
log.Printf("[pay] amount mismatch out_trade_no=%s purchase=%s callback=%s", outTradeNo, p.Amount, amount)
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)
}
}
if entitleOK {
if err := entitle(tx, p.ShopID, plan); err != nil {
return err
}
}
shopID = p.ShopID
return tx.Model(&model.LicensePurchase{}).Where("id = ?", p.ID).Updates(map[string]any{
"status": "paid",
"trade_no": tradeNo,
"channel": channel,
"paid_at": paidAt,
}).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 的叠加段。
func entitle(tx *gorm.DB, shopID uint64, plan payPlan) 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 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,
}
return tx.Create(&lic).Error
}
return 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
}
// amountEqual 金额按分归一比较("2999.00" == "2999" == "2999.0")。
func amountEqual(a, b string) bool {
ca, ea := toCents(a)
cb, eb := toCents(b)
return ea == nil && eb == nil && ca == cb
}
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"`
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
}
st := &PurchaseStatus{OutTradeNo: p.OutTradeNo, Status: p.Status, BizCode: p.ProductBizCode, Amount: p.Amount, 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
}
switch st.Status {
case "paid":
paidAt := time.Now()
if st.PaidAt != nil {
paidAt = *st.PaidAt
}
if err := s.settle(p.OutTradeNo, p.ProductBizCode, st.Amount, st.TradeNo, "", 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 "closed":
s.db.Model(&model.LicensePurchase{}).Where("id = ? AND status = 'pending'", p.ID).
Update("status", "failed")
case "refunded":
// 本轮不冲权益,仅记录(退款处理后续设计)
log.Printf("[pay] order refunded out_trade_no=%s (no-op)", p.OutTradeNo)
}
}
}
type payOrderStatus struct {
OutTradeNo string `json:"out_trade_no"`
Amount string `json:"amount"`
Status string `json:"status"` // pending | paid | closed | refunded
TradeNo string `json:"trade_no"`
PaidAt *time.Time `json:"paid_at"`
}
// queryOrder 查单(契约未要求签名头)。
func (s *PayService) queryOrder(outTradeNo string) (*payOrderStatus, error) {
resp, err := s.client.Get(s.baseURL + "/api/v1/orders/" + outTradeNo)
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
}