Files
pay/main.go
T
wangjia 0ae7769abe test(pay): 补 settle 并发/跨包全链 e2e/reconcile 装配/alipay 验签健壮性/money 零小数位
- gateway: TestSettleConcurrentSameOrder 真并发钉住 MarkAttemptPaid 行锁不变量
  (文件型 sqlite,规避 in-memory cache=shared 的 SQLITE_LOCKED_SHAREDCACHE)。
- gateway: e2e_fullchain_test.go 补下单→回调→settle→webhook 实际 HTTP 投递→
  delivered 整链(httptest server + 真实 store.WebhookStore/webhook.Notifier)。
- reconcile: main.go 装配抽到 reconcile.Assemble(+Runner.TaskNames 访问器),
  补 assembly_test.go 钉住 7 个后台任务全部注册 + crypto 缺渠道/interval=0 分支。
- alipay: 补验签健壮性(未知多余字段/字段乱序/中文unicode/空值字段),意外定位
  vendor Encoder 对空值字段的真实语义与官方文档描述不同,已记录在测试注释。
- money: 补 TestZeroDecimalCurrencyJPYNotSupported,记录 JPY/KRW 当前未注册进
  exponents(接入会先踩 ErrUnknownCurrency,不是乘除法坑)。
2026-07-11 08:50:45 +08:00

410 lines
19 KiB
Go

package main
import (
"context"
"errors"
"log"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/wangjia/pay/config"
"github.com/wangjia/pay/internal/accounts"
"github.com/wangjia/pay/internal/channel"
"github.com/wangjia/pay/internal/gateway"
"github.com/wangjia/pay/internal/handler"
"github.com/wangjia/pay/internal/model"
"github.com/wangjia/pay/internal/providerbuild"
"github.com/wangjia/pay/internal/reconcile"
"github.com/wangjia/pay/internal/router"
"github.com/wangjia/pay/internal/store"
"github.com/wangjia/pay/internal/webhook"
)
func main() {
config.Load()
db := initDB()
autoMigrate(db)
seed(db)
seedWechat(db)
if config.C.MockChannelEnabled {
seedPangolin(db) // dev-only:仅 MOCK_CHANNEL_ENABLED=true 时 seed pangolin 三档商品
}
reg := channel.NewRegistry(db)
gin.SetMode(config.C.Server.Mode)
r := gin.New()
r.Use(gin.Logger(), gin.Recovery())
// 安全review发现:当前部署(阿里云单机,无 nginx/网关前置)不存在真实反向代理,
// 但 gin 默认 trustedProxies=0.0.0.0/0(信任任意来源的 X-Forwarded-For/X-Real-IP)。
// 不关掉的话,c.ClientIP() 会读客户端可控的请求头——攻击者每次伪造不同 XFF 即可
// 让限流中间件(internal/middleware/ratelimit.go)把每次请求都当"新 IP"的新桶,
// burst 永不触发,且伪造 IP 数量无上限还能撑爆 buckets map。
// SetTrustedProxies(nil) 让 engine.trustedCIDRs=nil → isTrustedProxy 恒 false →
// ClientIP() 退回 RemoteIP()(即 net/http 拿到的 socket 对端地址,请求头伪造不了)。
// 若未来加 nginx/API 网关反代,这里要改成该反代的真实出口 IP/CIDR 列表,
// 不能再传 nil(否则又会把加固之后合法的下游 IP 直接当客户端源用)。
if err := r.SetTrustedProxies(nil); err != nil {
log.Fatalf("SetTrustedProxies(nil) 失败: %v", err)
}
orderSvc := router.Setup(r, db, reg)
// v2 统一网关装配(P2 骨架,P3 配置驱动注册接线):provider 注册表 + gateway + webhook notifier。
orderStore := store.NewOrderStore(db)
refundStore := store.NewRefundStore(db)
subStore := store.NewSubscriptionStore(db)
chargebackStore := store.NewChargebackStore(db)
webhookStore := store.NewWebhookStore(db)
notifier := webhook.NewNotifier(webhookStore, config.C.BizByName, func(no string) (bool, error) {
o, err := orderStore.GetOrder(no) // 投递门禁:订单已结算(paid 及退款态)才放行
if err != nil {
return false, err
}
return o.Status.Settled(), nil
})
// WebhookTickSeconds 默认 60(config.Load 兜底);本地联调设 WEBHOOK_TICK_SECONDS=2
// 可让「模拟付款成功」后近实时投递,不必等最多 60s。
notifier.Start(time.Duration(config.C.WebhookTickSeconds) * time.Second)
productResolver := gateway.NewDBProductResolver(db) // 币种由下单渠道结算能力驱动(设计 §4.1)
if config.C.MockChannelEnabled {
// dev-only:fake 渠道要能被 acctPicker 选中才能下单成功(EnabledFor("fake",...)
// 否则候选为空 → ErrNoAccount)。注入一条内存 fake 账户,不落 config.yaml/DB。
// Region 留空 = 不按 region 过滤(accounts.Registry.EnabledFor 对空 Region 放行任意 region)。
config.C.Accounts = append(config.C.Accounts, config.AccountConfig{
AccountID: "fake-dev", Channel: "fake", Weight: 1, Enabled: true, Subject: "本地联调 mock 渠道",
})
}
acctReg := accounts.New(config.C.Accounts)
pReg := providerbuild.BuildRegistry(acctReg) // 配置驱动:有 enabled 账户才注册对应渠道(P3);fake 见 MOCK_CHANNEL_ENABLED 门控
// P5 多账户路由:按 config.routing.<channel> 选策略(缺省 round_robin)。
// limit_aware 用量数据源:P6 对账 Runner 周期 Refresh 的真实用量源(替 NopUsage)。
usage := reconcile.NewUsageSource(orderStore, time.Now)
acctPicker := accounts.NewRouter(acctReg, config.C.Routing, usage)
gw := gateway.New(orderStore, refundStore, pReg, acctPicker, productResolver, notifier, "cn", subStore, chargebackStore)
router.SetupV2(r, gw)
if config.C.MockChannelEnabled {
// dev-only:浏览器/curl 一个请求即可把某笔 fake 渠道订单标为已付,触发 Settle→webhook
// 入队,免去手工查库拼回调体。刻意不挂 stateChangeRateLimit(本机联调用,不设限)。
devH := handler.NewDevMockHandler(db, gw)
r.POST("/api/v2/dev/orders/:order_no/mark-paid", devH.MarkPaid)
// dev-only:fake 渠道 redirect 形态的落地页(自包含,返回可用)。真实流程这里是
// 支付宝/微信收银台;本地 mock 无需真付款,提示用户回购买页点「模拟付款成功」。
r.GET("/mock-cashier", func(c *gin.Context) {
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(200, mockCashierHTML)
})
log.Println("[dev] MOCK_CHANNEL_ENABLED=true:已挂载 POST /api/v2/dev/orders/:order_no/mark-paid + GET /mock-cashier")
}
// P6 后台守护 / 对账:订单过期清理 + 用量刷新 + 查单对账收敛 + 已付抽查 +
// 退款修复扫描/卡滞告警(P4 T3 review 追加义务,归到本任务一起装配)+
// crypto 预留冷启动 Warm + 孤儿扫描(Task 6,AddCryptoJobs)。
if config.C.Reconcile.Enabled {
rc := config.C.Reconcile
orphanStore := store.NewOrphanStore(db)
// 装配逻辑抽到 reconcile.Assemble(单测直接断言任务集合,见
// internal/reconcile/assembly_test.go);这里只是薄薄一层调用 + 预热/常驻启动。
runner := reconcile.Assemble(orderStore, refundStore, usage, gw, pReg, acctReg, orphanStore, rc)
ctx := context.Background()
runner.RunOnceLocal(ctx) // 启动预热:只跑本地任务(usage 快照/过期清理/退款自愈立即生效);网络型任务(sync-pending/paid-spotcheck/crypto-orphan-scan)交各自 ticker 首跳,避免上游慢拖住 r.Run(addr) 前的启动
runner.Start(ctx)
log.Printf("[reconcile] 后台守护已启动(过期清理/用量刷新/查单对账/已付抽查/退款修复扫描/退款卡滞告警/crypto 孤儿扫描)")
}
if config.C.QuerySync.Enabled {
orderSvc.StartQuerySync(
time.Duration(config.C.QuerySync.IntervalSec)*time.Second,
time.Duration(config.C.QuerySync.MaxAgeMin)*time.Minute,
)
log.Printf("查单兜底已启动:每 %ds 一次", config.C.QuerySync.IntervalSec)
}
// 业务回调重试兜底(webhook 丢失/业务方短暂不可用时补推)。
orderSvc.StartBizNotifyRetry(60 * time.Second)
addr := ":" + config.C.Server.Port
log.Printf("支付服务启动 %s (mode=%s, base_url=%s)", addr, config.C.Server.Mode, config.C.Server.BaseURL)
if err := r.Run(addr); err != nil {
log.Fatalf("启动失败: %v", err)
}
}
func initDB() *gorm.DB {
logLevel := logger.Silent
if config.C.Server.Mode == "debug" {
logLevel = logger.Info
}
gormCfg := &gorm.Config{Logger: logger.Default.LogMode(logLevel), TranslateError: true}
switch config.C.Database.Driver {
case "sqlite", "":
db, err := gorm.Open(sqlite.Open(sqliteDSN(config.C.Database.DSN)), gormCfg)
if err != nil {
log.Fatalf("连接 sqlite 失败: %v", err)
}
return db
case "mysql":
log.Fatal("mysql 驱动尚未启用:go get gorm.io/driver/mysql 后在 initDB 接入")
return nil
default:
log.Fatalf("未知 database.driver: %s", config.C.Database.Driver)
return nil
}
}
// sqliteDSN 补 _txlock=immediate(BEGIN IMMEDIATE 立即抢库级写锁,串行化并发事务的
// 读阶段——RefundStore.CreateRefundGuarded 等守卫类事务依赖此语义)+
// busy_timeout(5000)(抢不到锁时等待重试而非立即报 SQLITE_BUSY)。呼应 pangolin
// 的 SQLite DSN 约定(server/internal/db/db.go)。config.yaml 里的 dsn 是纯文件路径
// (如 "pay.db"),这里补上查询串;若用户已经自带 "?",追加而不是覆盖。
func sqliteDSN(raw string) string {
extra := "_txlock=immediate&_pragma=busy_timeout(5000)"
if strings.Contains(raw, "?") {
return raw + "&" + extra
}
return raw + "?" + extra
}
func autoMigrate(db *gorm.DB) {
if err := db.AutoMigrate(
&model.Merchant{},
&model.Product{},
&model.ProductPrice{},
&model.Order{},
&model.NotifyLog{},
&model.BizNotifyLog{},
&model.OrderV2{}, &model.Attempt{}, &model.Account{}, &model.Refund{}, &model.WebhookDelivery{}, &model.OrphanPayment{}, // v2
&model.Subscription{}, // v2 recurring
&model.Chargeback{}, // v2 recurring — P8 Task6 拒付记录
); err != nil {
log.Fatalf("自动迁移失败: %v", err)
}
// AutoMigrate 只按名字判断索引是否存在,不会把老库同名的 2 列 uq_delivery
// 加宽到当前模型的 3 列(out_trade_no,event_type,refund_id);显式补一刀。
if err := model.UpgradeWebhookDeliveryIndex(db); err != nil {
log.Fatalf("升级 uq_delivery 索引失败: %v", err)
}
log.Println("AutoMigrate 完成")
}
// seed 据 config 的 alipay_sandbox upsert 一个支付宝商户,并为其补两个测试套餐。
func seed(db *gorm.DB) {
sb := config.C.AlipaySandbox
if !sb.Enabled {
log.Println("[seed] alipay_sandbox.enabled=false,跳过沙箱商户初始化")
return
}
var m model.Merchant
err := db.Where("code = ?", sb.MerchantCode).First(&m).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
m = model.Merchant{
Code: sb.MerchantCode,
Name: sb.MerchantName,
Channel: "alipay",
}
} else if err != nil {
log.Fatalf("[seed] 查询商户失败: %v", err)
}
m.Production = sb.Production
m.Enabled = true
m.AppID = sb.AppID
m.AppPrivateKey = sb.AppPrivateKey
m.AlipayPublicKey = sb.AlipayPublicKey
if err := db.Save(&m).Error; err != nil {
log.Fatalf("[seed] 保存商户失败: %v", err)
}
log.Printf("[seed] 支付宝沙箱商户就绪: code=%s app_id=%s", m.Code, m.AppID)
var cnt int64
db.Model(&model.Product{}).Where("merchant_id = ?", m.ID).Count(&cnt)
if cnt == 0 {
samples := []model.Product{
{MerchantID: m.ID, Name: "测试套餐 A", Description: "沙箱联调用", Price: "0.01", Active: true, Sort: 1},
{MerchantID: m.ID, Name: "测试套餐 B", Description: "沙箱联调用", Price: "0.02", Active: true, Sort: 2},
}
if err := db.Create(&samples).Error; err != nil {
log.Fatalf("[seed] 创建测试套餐失败: %v", err)
}
log.Printf("[seed] 已为商户 %s 创建 %d 个测试套餐", m.Code, len(samples))
}
seedPlans(db, m.ID)
}
// seedPlans 幂等 upsert 业务套餐(带 biz_code,供 jiu 等业务系统按 biz_code 查 product)。
// 价格权威在此表;权益(时长/档位/设备/功能)由业务方按 biz_code 映射。
func seedPlans(db *gorm.DB, merchantID uint64) {
// Name 会成为支付宝收银台的 subject(一行约 12-14 汉字可见):
// 带品牌前缀、短句式,避免截断(2026-07-04 用户反馈收银台信息太少/被截断)。
plans := []model.Product{
{Name: "岩美酒库·联调测试", Description: "1 分钱端到端联调用(标准权益)", Price: "0.01", BizCode: "test_liandiao", Sort: 0},
{Name: "岩美酒库·首月特惠30天", Description: "标准权益 · 30 天 · 每门店限一次(限次由 jiu 侧校验)", Price: "1.00", BizCode: "promo_first_month", Sort: 1},
{Name: "岩美酒库·标准版月付", Description: "单店/单仓库 · 2 客户端 · 千张图片分享", Price: "299.00", BizCode: "monthly_standard", Sort: 2},
{Name: "岩美酒库·高级版月付", Description: "单店/多仓库 · 5 客户端 · 万张照片 · 免费 AI 周/月度商业分析", Price: "599.00", BizCode: "monthly_pro", Sort: 3},
{Name: "岩美酒库·标准版年付", Description: "单店/单仓库 · 2 客户端 · 千张图片分享", Price: "2999.00", BizCode: "annual_standard", Sort: 4},
{Name: "岩美酒库·高级版年付", Description: "单店/多仓库 · 5 客户端 · 万张照片 · 免费 AI 周/月度商业分析", Price: "5999.00", BizCode: "annual_pro", Sort: 5},
}
for _, p := range plans {
var existing model.Product
err := db.Where("biz_code = ?", p.BizCode).First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
p.MerchantID = merchantID
p.Active = true
if err := db.Create(&p).Error; err != nil {
log.Fatalf("[seed] 创建套餐 %s 失败: %v", p.BizCode, err)
}
continue
} else if err != nil {
log.Fatalf("[seed] 查询套餐 %s 失败: %v", p.BizCode, err)
}
// 已存在:更新价格/文案/归属(价格权威可从此改)。
db.Model(&existing).Updates(map[string]any{
"merchant_id": merchantID, "name": p.Name, "description": p.Description,
"price": p.Price, "active": true, "sort": p.Sort,
})
}
log.Printf("[seed] 业务套餐就绪:%d 个(含联调 0.01)", len(plans))
}
// seedWechat 据 config 的 wechat upsert 一个微信商户(V3 Native),并为其补两个测试套餐。
// 微信无沙箱:wechat.enabled=true 即视为正式(Production=true),只能用真实商户号做 1 分钱实测。
func seedWechat(db *gorm.DB) {
wc := config.C.Wechat
if !wc.Enabled {
log.Println("[seed] wechat.enabled=false,跳过微信商户初始化")
return
}
var m model.Merchant
err := db.Where("code = ?", wc.MerchantCode).First(&m).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
m = model.Merchant{
Code: wc.MerchantCode,
Name: wc.MerchantName,
Channel: "wechat",
}
} else if err != nil {
log.Fatalf("[seed] 查询微信商户失败: %v", err)
}
m.Production = true // 微信无沙箱,一律正式网关
m.Enabled = true
m.MchID = wc.MchID
m.WxAppID = wc.AppID
m.CertSerial = wc.CertSerial
m.WxPrivateKey = wc.PrivateKey
m.APIv3Key = wc.APIv3Key
if err := db.Save(&m).Error; err != nil {
log.Fatalf("[seed] 保存微信商户失败: %v", err)
}
log.Printf("[seed] 微信商户就绪: code=%s mch_id=%s", m.Code, m.MchID)
var cnt int64
db.Model(&model.Product{}).Where("merchant_id = ?", m.ID).Count(&cnt)
if cnt == 0 {
samples := []model.Product{
{MerchantID: m.ID, Name: "微信测试套餐 A", Description: "微信联调用", Price: "0.01", Active: true, Sort: 1},
{MerchantID: m.ID, Name: "微信测试套餐 B", Description: "微信联调用", Price: "0.02", Active: true, Sort: 2},
}
if err := db.Create(&samples).Error; err != nil {
log.Fatalf("[seed] 创建微信测试套餐失败: %v", err)
}
log.Printf("[seed] 已为微信商户 %s 创建 %d 个测试套餐", m.Code, len(samples))
}
}
// seedPangolin dev-only:仅 main() 在 MOCK_CHANNEL_ENABLED=true 时调用,幂等 upsert
// pangolin 业务方的三档订阅商品(biz_code=sku:pro_month/pro_quarter/pro_year)。
// 价格用小额占位;MerchantID 留 0——ProductResolver.Resolve 只按 biz_code 查,不看
// merchant 归属(与 seedPlans 场景不同,这里无需真实商户)。
// 币种与 providerbuild.BuildRegistry 给 fake 设的 SettleCurrencies=["CNY","USDT"]对齐:
// CNY 排首位,是 gateway.CreateOrder 实际会选用的结算币种(pangolin 下单不传
// currency),故用 v1 Product.Price(元 string)回退即可满足;顺带补一行 USDT
// ProductPrice,覆盖将来切换/联调其它币种的场景。
func seedPangolin(db *gorm.DB) {
type pangolinPlan struct {
BizCode string
Name string
CNYYuan string // Product.Price(CNY 回退价,元 string)
USDTMino int64 // ProductPrice(USDT, 6 位小数 minor units)占位
}
plans := []pangolinPlan{
{BizCode: "pro_month", Name: "Pangolin·Pro 月付(联调)", CNYYuan: "1.00", USDTMino: 1000000},
{BizCode: "pro_quarter", Name: "Pangolin·Pro 季付(联调)", CNYYuan: "1.00", USDTMino: 1000000},
{BizCode: "pro_year", Name: "Pangolin·Pro 年付(联调)", CNYYuan: "1.00", USDTMino: 1000000},
}
for i, p := range plans {
var existing model.Product
err := db.Where("biz_code = ?", p.BizCode).First(&existing).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
existing = model.Product{
Name: p.Name, Description: "pay 本地联调 mock 渠道占位商品(MOCK_CHANNEL_ENABLED)",
Price: p.CNYYuan, BizCode: p.BizCode, Active: true, Sort: i,
}
if err := db.Create(&existing).Error; err != nil {
log.Fatalf("[seed-pangolin] 创建套餐 %s 失败: %v", p.BizCode, err)
}
case err != nil:
log.Fatalf("[seed-pangolin] 查询套餐 %s 失败: %v", p.BizCode, err)
default:
db.Model(&existing).Updates(map[string]any{
"name": p.Name, "price": p.CNYYuan, "active": true, "sort": i,
})
}
var pp model.ProductPrice
err = db.Where("product_id = ? AND currency = ?", existing.ID, "USDT").First(&pp).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
pp = model.ProductPrice{ProductID: existing.ID, Currency: "USDT", AmountMinor: p.USDTMino}
if err := db.Create(&pp).Error; err != nil {
log.Fatalf("[seed-pangolin] 创建套餐 %s 的 USDT 价失败: %v", p.BizCode, err)
}
case err != nil:
log.Fatalf("[seed-pangolin] 查询套餐 %s 的 USDT 价失败: %v", p.BizCode, err)
default:
db.Model(&pp).Update("amount_minor", p.USDTMino)
}
}
log.Printf("[seed-pangolin] pangolin 业务套餐就绪:%d 个(pro_month/pro_quarter/pro_year,CNY+USDT)", len(plans))
}
// mockCashierHTML — dev-only:fake 渠道 redirect 形态的自包含落地页(返回可用,无外链)。
const mockCashierHTML = `<!doctype html><html lang="zh"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>模拟收银台</title>
<style>
:root{color-scheme:dark}
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
background:#0b0f17;color:#e8edf5;font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
.card{max-width:460px;padding:36px 32px;background:#141a24;border:1px solid #232c3a;
border-radius:16px;text-align:center;box-shadow:0 8px 40px rgba(0,0,0,.4)}
h1{font-size:22px;margin:8px 0 6px}
.sub{color:#93a1b5;font-size:14px;margin:0 0 20px}
.tip{background:#0f1622;border:1px solid #232c3a;border-radius:10px;padding:14px 16px;
font-size:13px;color:#aab6c6;text-align:left;margin:0 0 22px}
.btn{display:inline-block;padding:11px 20px;border-radius:10px;border:1px solid #2f3b4d;
background:#1b2432;color:#cdd7e4;text-decoration:none;font-size:14px;cursor:pointer}
.btn.primary{background:#2563eb;border-color:#2563eb;color:#fff}
.row{display:flex;gap:10px;justify-content:center}
.emoji{font-size:40px}
</style></head><body>
<div class="card">
<div class="emoji">🧪</div>
<h1>模拟支付收银台</h1>
<p class="sub">这是本地联调的占位收银台,不是真实支付。</p>
<div class="tip">真实流程这里会是<b>支付宝 / 微信 / Stripe</b> 的收银台,你在此完成付款后渠道异步通知到账。
本地 mock 无需真付款——请<b>关闭本页</b>,回到购买页点击<b>「🧪 模拟付款成功」</b>即可看到订阅开通。</div>
<div class="row">
<a class="btn primary" href="javascript:window.close()">关闭本页</a>
<a class="btn" href="javascript:history.back()">← 返回上一页</a>
</div>
</div></body></html>`