feat(backend): 在线购买/续费对接 pay 收款中枢——下单/webhook 验签入账/续期叠加/查单兜底

- POST /license/purchase(仅管理员)建单并调 pay 下单,返回收银台 pay_url
- POST /pay/callback 公开接收器:HMAC 验签+时间戳窗口+按 out_trade_no 幂等+金额逐分核对,同事务续期
- 续期与兑换券同口径:未过期从到期日叠加、已过期从现在起算,写入 tier/max_devices/features
- 后台每 60s 查单兜底防 webhook 丢失;closed 标 failed
- 新表 license_purchases(schema.sql/AutoMigrate/testutil 同步);配置 PAY_SECRET/PAY_BASE_URL/PAY_RETURN_URL
- 契约真相源 ~/code/pay-contract openapi.yaml v1.0.0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
This commit is contained in:
wangjia
2026-07-03 20:40:06 +08:00
parent fbcf573034
commit 18925d7d15
28 changed files with 961 additions and 12003 deletions
+15
View File
@@ -14,6 +14,16 @@ type Config struct {
Storage StorageConfig
Session SessionConfig
RateLimit RateLimitConfig
Pay PayConfig
}
// PayConfig pay 收款中枢对接(契约:~/code/pay-contract openapi.yaml v1.0.0)。
// Secret 为 jiu↔pay 双向 HMAC 共享密钥(pay 侧 BIZ_JIU_SECRET 同值),只经环境变量注入;
// 为空时购买接口返回 503,webhook 一律拒绝。
type PayConfig struct {
BaseURL string `mapstructure:"base_url"` // pay 服务地址
Secret string `mapstructure:"secret"` // HMAC 共享密钥(PAY_SECRET
ReturnURL string `mapstructure:"return_url"` // 支付完成回跳页(pay 会拼 out_trade_no
}
type ServerConfig struct {
@@ -86,6 +96,9 @@ func Load() {
_ = viper.BindEnv("storage.base_url", "STORAGE_BASE_URL")
_ = viper.BindEnv("storage.public_url", "STORAGE_PUBLIC_URL")
_ = viper.BindEnv("storage.web_dir", "STORAGE_WEB_DIR")
_ = viper.BindEnv("pay.base_url", "PAY_BASE_URL")
_ = viper.BindEnv("pay.secret", "PAY_SECRET")
_ = viper.BindEnv("pay.return_url", "PAY_RETURN_URL")
// 默认值
viper.SetDefault("server.port", "8080")
@@ -116,6 +129,8 @@ func Load() {
viper.SetDefault("storage.base_url", "http://localhost:8080/images")
viper.SetDefault("storage.public_url", "http://localhost:8081")
viper.SetDefault("storage.web_dir", "./web")
viper.SetDefault("pay.base_url", "https://pay.51yanmei.com")
viper.SetDefault("pay.return_url", "https://jiu.51yanmei.com/license/result/")
if err := viper.ReadInConfig(); err != nil {
log.Println("[config] no config file found, using defaults and env vars")
+90
View File
@@ -0,0 +1,90 @@
package handler
import (
"errors"
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/service"
"github.com/wangjia/jiu/backend/internal/util"
)
type PayHandler struct {
svc *service.PayService
}
func NewPayHandler(svc *service.PayService) *PayHandler {
return &PayHandler{svc: svc}
}
// Purchase POST /api/v1/license/purchase — 在线购买/续费下单,返回收银台 pay_url。
// 仅管理员可购买(handler 内判权,同 withdraw 模式)。
func (h *PayHandler) Purchase(c *gin.Context) {
role := middleware.GetRole(c)
if role != "admin" && role != "superadmin" {
c.JSON(http.StatusForbidden, gin.H{"error": "仅管理员可购买授权"})
return
}
var req struct {
BizCode string `json:"biz_code" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
res, err := h.svc.CreatePurchase(middleware.GetShopID(c), middleware.GetUserID(c), req.BizCode)
if err != nil {
switch {
case errors.Is(err, service.ErrPayNotConfigured):
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrUnknownPlan):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
log.Printf("[pay] purchase failed shop=%d biz_code=%s: %v", middleware.GetShopID(c), req.BizCode, err)
c.JSON(http.StatusBadGateway, gin.H{"error": "下单失败,请稍后重试"})
}
return
}
util.RespondSuccess(c, res)
}
// PurchaseStatus GET /api/v1/license/purchase/:out_trade_no — 结果页轮询购买单状态。
func (h *PayHandler) PurchaseStatus(c *gin.Context) {
st, err := h.svc.Status(middleware.GetShopID(c), c.Param("out_trade_no"))
if err != nil {
if errors.Is(err, service.ErrPurchaseNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
util.RespondSuccess(c, st)
}
// Callback POST /api/v1/pay/callback — pay 支付成功 webhook(公开路由,HMAC 验签)。
// 契约:验签失败回 401;受理成功回 200 + {"code":"SUCCESS"},否则 pay 每 60s 重试 24h。
func (h *PayHandler) Callback(c *gin.Context) {
rawBody, err := c.GetRawData()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "read body failed"})
return
}
err = h.svc.HandleCallback(rawBody,
c.GetHeader("X-Pay-Timestamp"), c.GetHeader("X-Pay-Nonce"), c.GetHeader("X-Pay-Sign"))
if err != nil {
if errors.Is(err, service.ErrPaySignature) {
c.JSON(http.StatusUnauthorized, gin.H{"code": "FAIL", "message": "signature verification failed"})
return
}
// 其余错误(暂时性/金额不符/单不存在)回非 SUCCESS,让 pay 重试;金额不符会持续失败并留日志告警
log.Printf("[pay] callback rejected: %v", err)
c.JSON(http.StatusOK, gin.H{"code": "FAIL", "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"})
}
@@ -0,0 +1,19 @@
package model
import "time"
// LicensePurchase 在线购买/续费记录(走 pay 收款中枢,契约见 ~/code/pay-contract)。
// out_trade_no = pay 订单号,兼作对账键与幂等键(同一单只续期一次)。
// Amount 存 pay 下单响应回传的权威金额(如 "2999.00"),webhook 回调时逐分核对。
type LicensePurchase struct {
Base
ShopID uint64 `gorm:"not null;index" json:"shop_id"`
UserID uint64 `gorm:"not null" json:"user_id"`
ProductBizCode string `gorm:"size:64;not null" json:"product_biz_code"`
Amount string `gorm:"size:16" json:"amount"`
OutTradeNo string `gorm:"size:64;uniqueIndex:uk_out_trade_no" json:"out_trade_no"`
Status string `gorm:"type:enum('pending','paid','failed');default:'pending';index" json:"status"`
TradeNo string `gorm:"size:64" json:"trade_no,omitempty"` // 渠道交易号(支付宝/微信)
Channel string `gorm:"size:16" json:"channel,omitempty"`
PaidAt *time.Time `json:"paid_at,omitempty"`
}
+9
View File
@@ -15,11 +15,14 @@ func Setup(r *gin.Engine, db *gorm.DB) {
authSvc := service.NewAuthService(db)
licenseSvc := service.NewLicenseService(db)
stockSvc := service.NewStockService(db)
paySvc := service.NewPayService(db, config.C.Pay.BaseURL, config.C.Pay.Secret, config.C.Pay.ReturnURL)
service.StartPayReconcile(paySvc) // 查单兜底(PAY_SECRET 未配置时空转不启动)
// 处理器
authH := handler.NewAuthHandler(authSvc, licenseSvc)
sessionH := handler.NewSessionHandler(authSvc)
licenseH := handler.NewLicenseHandler(licenseSvc)
payH := handler.NewPayHandler(paySvc)
productH := handler.NewProductHandler(db)
warehouseH := handler.NewWarehouseHandler(db)
partnerH := handler.NewPartnerHandler(db)
@@ -79,6 +82,9 @@ func Setup(r *gin.Engine, db *gorm.DB) {
public.POST("/register", registerIP, authH.Register)
}
// pay 支付成功 webhook(公开路由,HMAC 验签在 handler 内;按 IP 限流防噪)
v1.POST("/pay/callback", publicReadIP, payH.Callback)
// 需要 JWT 的基础路由组
api := v1.Group("")
api.Use(middleware.JWT(db))
@@ -104,6 +110,9 @@ func Setup(r *gin.Engine, db *gorm.DB) {
license.GET("/verify", licenseH.Verify)
license.POST("/deactivate", licenseH.Deactivate)
license.GET("/devices", licenseH.Devices)
// 在线购买/续费(走 pay 收款中枢;仅管理员,handler 内判权)
license.POST("/purchase", payH.Purchase)
license.GET("/purchase/:out_trade_no", payH.PurchaseStatus)
}
// 业务路由:ReadOnly + LicenseGuard(过期只读/锁定拦截写操作)
+473
View File
@@ -0,0 +1,473 @@
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("购买记录不存在")
)
// 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{
"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
}
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
}
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
}
// ---------- ④ 查单兜底 ----------
// 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
}
+293
View File
@@ -0,0 +1,293 @@
package service
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
"github.com/wangjia/jiu/backend/testutil"
)
const testPaySecret = "test-shared-secret"
func newTestPaySvc(db *gorm.DB, baseURL string) *PayService {
return NewPayService(db, baseURL, testPaySecret, "https://jiu.example.com/license/result/")
}
// signedCallbackArgs 按契约给回调体生成签名参数(模拟 pay 侧签名)。
func signedCallbackArgs(body []byte) (ts, nonce, sign string) {
ts = strconv.FormatInt(time.Now().Unix(), 10)
nonce = "test-nonce"
sign = util.PaySign(testPaySecret, "jiu", ts, nonce, string(body))
return
}
func callbackBody(outTradeNo, bizCode, amount string) []byte {
b, _ := json.Marshal(map[string]any{
"out_trade_no": outTradeNo,
"biz_system": "jiu",
"biz_ref": "1",
"product_biz_code": bizCode,
"amount": amount,
"trade_no": "2026070322001",
"channel": "alipay",
"paid_at": time.Now().Format(time.RFC3339),
})
return b
}
func createPendingPurchase(t *testing.T, db *gorm.DB, shopID uint64, bizCode, amount, otn string) *model.LicensePurchase {
t.Helper()
p := &model.LicensePurchase{ShopID: shopID, UserID: 1, ProductBizCode: bizCode, Amount: amount, OutTradeNo: otn, Status: "pending"}
require.NoError(t, db.Create(p).Error)
return p
}
// ---------- 签名 ----------
func TestPaySign_Vector(t *testing.T) {
// 与契约参考实现一致:base64(HMAC_SHA256(secret, join("\n", parts)))
got := util.PaySign("secret", "jiu", "1751520000", "nonce", `{"a":1}`)
assert.NotEmpty(t, got)
assert.True(t, util.PaySignVerify("secret", got, "jiu", "1751520000", "nonce", `{"a":1}`))
assert.False(t, util.PaySignVerify("secret", got, "jiu", "1751520001", "nonce", `{"a":1}`))
assert.False(t, util.PaySignVerify("other", got, "jiu", "1751520000", "nonce", `{"a":1}`))
}
func TestAmountEqual(t *testing.T) {
assert.True(t, amountEqual("2999.00", "2999"))
assert.True(t, amountEqual("2999.0", "2999.00"))
assert.True(t, amountEqual("0.01", "0.01"))
assert.False(t, amountEqual("2999.00", "2999.01"))
assert.False(t, amountEqual("", "2999"))
assert.False(t, amountEqual("abc", "2999"))
}
// ---------- 回调:验签门 ----------
func TestHandleCallback_BadSignature(t *testing.T) {
db := testutil.SetupTestDB()
svc := newTestPaySvc(db, "http://pay.invalid")
body := callbackBody("yanmei-1", "annual_standard", "2999.00")
ts, nonce, _ := signedCallbackArgs(body)
err := svc.HandleCallback(body, ts, nonce, "forged-signature")
assert.ErrorIs(t, err, ErrPaySignature)
}
func TestHandleCallback_ExpiredTimestamp(t *testing.T) {
db := testutil.SetupTestDB()
svc := newTestPaySvc(db, "http://pay.invalid")
body := callbackBody("yanmei-1", "annual_standard", "2999.00")
ts := strconv.FormatInt(time.Now().Add(-10*time.Minute).Unix(), 10)
sign := util.PaySign(testPaySecret, "jiu", ts, "n", string(body))
err := svc.HandleCallback(body, ts, "n", sign)
assert.ErrorIs(t, err, ErrPaySignature)
}
func TestHandleCallback_NotConfigured(t *testing.T) {
db := testutil.SetupTestDB()
svc := NewPayService(db, "http://pay.invalid", "", "")
body := callbackBody("yanmei-1", "annual_standard", "2999.00")
ts, nonce, sign := signedCallbackArgs(body)
assert.ErrorIs(t, svc.HandleCallback(body, ts, nonce, sign), ErrPaySignature)
}
// ---------- 回调:入账 ----------
func TestHandleCallback_SettleAndRenew(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PAY001")
createPendingPurchase(t, db, shop.ID, "annual_standard", "2999.00", "yanmei-otn-1")
svc := newTestPaySvc(db, "http://pay.invalid")
body := callbackBody("yanmei-otn-1", "annual_standard", "2999.00")
ts, nonce, sign := signedCallbackArgs(body)
require.NoError(t, svc.HandleCallback(body, ts, nonce, sign))
var p model.LicensePurchase
require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-1").First(&p).Error)
assert.Equal(t, "paid", p.Status)
assert.Equal(t, "alipay", p.Channel)
assert.NotNil(t, p.PaidAt)
var lic model.License
require.NoError(t, db.Where("shop_id = ?", shop.ID).Order("id DESC").First(&lic).Error)
assert.Equal(t, "standard", lic.Tier)
assert.Equal(t, "annual", lic.Type)
assert.Equal(t, 2, lic.MaxDevices)
assert.InDelta(t, 365, daysFromNow(lic.ExpiresAt), 1)
assert.Equal(t, float64(1000), lic.Features["image_quota"]) // JSON 数字解出 float64
}
func TestHandleCallback_Idempotent(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PAY002")
createPendingPurchase(t, db, shop.ID, "monthly_pro", "599.00", "yanmei-otn-2")
svc := newTestPaySvc(db, "http://pay.invalid")
body := callbackBody("yanmei-otn-2", "monthly_pro", "599.00")
ts, nonce, sign := signedCallbackArgs(body)
require.NoError(t, svc.HandleCallback(body, ts, nonce, sign))
var expires1 time.Time
{
var lic model.License
require.NoError(t, db.Where("shop_id = ?", shop.ID).First(&lic).Error)
expires1 = *lic.ExpiresAt
}
// pay 重发同一单:不得重复续期
ts2, nonce2, sign2 := signedCallbackArgs(body)
require.NoError(t, svc.HandleCallback(body, ts2, nonce2, sign2))
var lic model.License
require.NoError(t, db.Where("shop_id = ?", shop.ID).First(&lic).Error)
assert.True(t, lic.ExpiresAt.Equal(expires1), "重发不得二次叠加")
}
func TestHandleCallback_AmountMismatch(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PAY003")
createPendingPurchase(t, db, shop.ID, "annual_pro", "5999.00", "yanmei-otn-3")
svc := newTestPaySvc(db, "http://pay.invalid")
body := callbackBody("yanmei-otn-3", "annual_pro", "0.01") // 篡改金额
ts, nonce, sign := signedCallbackArgs(body)
assert.ErrorIs(t, svc.HandleCallback(body, ts, nonce, sign), ErrPayAmount)
var p model.LicensePurchase
require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-3").First(&p).Error)
assert.Equal(t, "pending", p.Status, "金额不符不得入账")
}
func TestHandleCallback_UnknownOrder(t *testing.T) {
db := testutil.SetupTestDB()
svc := newTestPaySvc(db, "http://pay.invalid")
body := callbackBody("yanmei-not-exist", "annual_standard", "2999.00")
ts, nonce, sign := signedCallbackArgs(body)
assert.ErrorIs(t, svc.HandleCallback(body, ts, nonce, sign), ErrPurchaseNotFound)
}
// ---------- 续期叠加 ----------
func TestEntitle_StackOnActiveLicense(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PAY004")
// 现有授权还剩 100 天(如试用/兑换券),购买年付应从到期日往后叠
future := time.Now().Add(100 * 24 * time.Hour)
require.NoError(t, db.Create(&model.License{
ShopID: shop.ID, LicenseKey: "SEED-1", Type: "trial", Tier: "standard",
ExpiresAt: &future, IsActive: true, MaxDevices: 3,
}).Error)
createPendingPurchase(t, db, shop.ID, "annual_standard", "2999.00", "yanmei-otn-4")
svc := newTestPaySvc(db, "http://pay.invalid")
body := callbackBody("yanmei-otn-4", "annual_standard", "2999.00")
ts, nonce, sign := signedCallbackArgs(body)
require.NoError(t, svc.HandleCallback(body, ts, nonce, sign))
var lic model.License
require.NoError(t, db.Where("shop_id = ?", shop.ID).Order("id DESC").First(&lic).Error)
assert.InDelta(t, 100+365, daysFromNow(lic.ExpiresAt), 1, "未过期应从到期日叠加")
assert.Equal(t, "annual", lic.Type)
}
func TestEntitle_ExpiredStartsFromNow(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PAY005")
past := time.Now().Add(-30 * 24 * time.Hour)
require.NoError(t, db.Create(&model.License{
ShopID: shop.ID, LicenseKey: "SEED-2", Type: "trial", Tier: "standard",
ExpiresAt: &past, IsActive: true, MaxDevices: 3,
}).Error)
createPendingPurchase(t, db, shop.ID, "monthly_standard", "299.00", "yanmei-otn-5")
svc := newTestPaySvc(db, "http://pay.invalid")
body := callbackBody("yanmei-otn-5", "monthly_standard", "299.00")
ts, nonce, sign := signedCallbackArgs(body)
require.NoError(t, svc.HandleCallback(body, ts, nonce, sign))
var lic model.License
require.NoError(t, db.Where("shop_id = ?", shop.ID).Order("id DESC").First(&lic).Error)
assert.InDelta(t, 30, daysFromNow(lic.ExpiresAt), 1, "已过期应从现在起算")
}
// ---------- 下单 ----------
func TestCreatePurchase_HappyPath(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PAY006")
// 假 pay 服务:/products 列表 + /orders 验签后返回 pay_url
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/products", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"data":[{"id":3,"biz_code":"annual_standard"},{"id":4,"biz_code":"monthly_pro"}]}`)
})
var gotBizRef string
mux.HandleFunc("POST /api/v1/orders", func(w http.ResponseWriter, r *http.Request) {
body := make([]byte, r.ContentLength)
_, _ = r.Body.Read(body)
if !util.PaySignVerify(testPaySecret, r.Header.Get("X-Pay-Sign"),
r.Header.Get("X-Pay-System"), r.Header.Get("X-Pay-Timestamp"), r.Header.Get("X-Pay-Nonce"), string(body)) {
w.WriteHeader(http.StatusUnauthorized)
return
}
var req map[string]any
_ = json.Unmarshal(body, &req)
gotBizRef, _ = req["biz_ref"].(string)
fmt.Fprint(w, `{"data":{"pay_url":"https://openapi.alipay.com/gateway","out_trade_no":"yanmei-new-1","amount":"2999.00","subject":"年付标准"}}`)
})
payServer := httptest.NewServer(mux)
defer payServer.Close()
svc := newTestPaySvc(db, payServer.URL)
res, err := svc.CreatePurchase(shop.ID, 1, "annual_standard")
require.NoError(t, err)
assert.Equal(t, "https://openapi.alipay.com/gateway", res.PayURL)
assert.Equal(t, "yanmei-new-1", res.OutTradeNo)
var p model.LicensePurchase
require.NoError(t, db.Where("out_trade_no = ?", "yanmei-new-1").First(&p).Error)
assert.Equal(t, "pending", p.Status)
assert.Equal(t, "2999.00", p.Amount)
assert.Equal(t, strconv.FormatUint(p.ID, 10), gotBizRef, "biz_ref 应为购买记录 id")
}
func TestCreatePurchase_UnknownPlanAndUnconfigured(t *testing.T) {
db := testutil.SetupTestDB()
svc := newTestPaySvc(db, "http://pay.invalid")
_, err := svc.CreatePurchase(1, 1, "no_such_plan")
assert.ErrorIs(t, err, ErrUnknownPlan)
unconfigured := NewPayService(db, "http://pay.invalid", "", "")
_, err = unconfigured.CreatePurchase(1, 1, "annual_standard")
assert.ErrorIs(t, err, ErrPayNotConfigured)
}
// ---------- Status ----------
func TestStatus_ScopedToShop(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PAY007")
other := testutil.CreateTestShop(db, "PAY008")
createPendingPurchase(t, db, shop.ID, "annual_standard", "2999.00", "yanmei-otn-7")
svc := newTestPaySvc(db, "http://pay.invalid")
st, err := svc.Status(shop.ID, "yanmei-otn-7")
require.NoError(t, err)
assert.Equal(t, "pending", st.Status)
_, err = svc.Status(other.ID, "yanmei-otn-7")
assert.ErrorIs(t, err, ErrPurchaseNotFound, "跨店不可见")
}
+21
View File
@@ -0,0 +1,21 @@
package util
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"strings"
)
// PaySign pay 收款中枢双向 HMAC 签名(契约 ~/code/pay-contract §签名,与 pay 侧 util.HMACSign 一致):
// sign = base64( HMAC_SHA256( secret, biz_system + "\n" + timestamp + "\n" + nonce + "\n" + rawBody ) )
func PaySign(secret string, parts ...string) string {
m := hmac.New(sha256.New, []byte(secret))
m.Write([]byte(strings.Join(parts, "\n")))
return base64.StdEncoding.EncodeToString(m.Sum(nil))
}
// PaySignVerify 常量时间比较验签。
func PaySignVerify(secret, got string, parts ...string) bool {
return hmac.Equal([]byte(PaySign(secret, parts...)), []byte(got))
}
+1
View File
@@ -121,6 +121,7 @@ func autoMigrate(db *gorm.DB) {
&model.License{},
&model.LicenseDevice{},
&model.LicenseCode{},
&model.LicensePurchase{},
&model.UserSession{},
&model.LoginAttempt{},
&model.ProductCategory{},
+25
View File
@@ -157,6 +157,31 @@ CREATE TABLE IF NOT EXISTS `license_codes` (
KEY `idx_redeemed_shop` (`redeemed_shop_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='兑换券码池';
-- ------------------------------------------------------------
-- 在线购买/续费记录(pay 收款中枢,契约 pay-contract v1.0.0
-- out_trade_no = pay 订单号,对账键 + 幂等键;amount 为 pay 回传权威金额
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `license_purchases` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`shop_id` BIGINT UNSIGNED NOT NULL,
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '下单管理员',
`product_biz_code` VARCHAR(64) NOT NULL COMMENT '套餐稳定码(monthly_standard/annual_standard/monthly_pro/annual_pro)',
`amount` VARCHAR(16) DEFAULT NULL COMMENT 'pay 下单回传金额,如 2999.00',
`out_trade_no` VARCHAR(64) DEFAULT NULL COMMENT 'pay 订单号',
`status` ENUM('pending','paid','failed') NOT NULL DEFAULT 'pending',
`trade_no` VARCHAR(64) DEFAULT NULL COMMENT '渠道交易号(支付宝/微信)',
`channel` VARCHAR(16) DEFAULT NULL,
`paid_at` DATETIME DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted_at` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_out_trade_no` (`out_trade_no`),
KEY `idx_shop` (`shop_id`),
KEY `idx_status` (`status`),
KEY `idx_deleted_at` (`deleted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='在线购买/续费记录';
-- ------------------------------------------------------------
-- 商品分类
-- ------------------------------------------------------------
+15
View File
@@ -150,6 +150,21 @@ func SetupTestDB() *gorm.DB {
created_at DATETIME,
updated_at DATETIME
)`,
`CREATE TABLE IF NOT EXISTS license_purchases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
product_biz_code TEXT NOT NULL,
amount TEXT,
out_trade_no TEXT UNIQUE,
status TEXT NOT NULL DEFAULT 'pending',
trade_no TEXT,
channel TEXT,
paid_at DATETIME
)`,
`CREATE TABLE IF NOT EXISTS license_devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
license_id INTEGER NOT NULL,