18925d7d15
- 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
294 lines
11 KiB
Go
294 lines
11 KiB
Go
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, "跨店不可见")
|
|
}
|