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")