feat(dev): 本地联调 mock 渠道工具链(fake 注册/seed pangolin/模拟付款端点/可配投递间隔,MOCK_CHANNEL_ENABLED 门控)
让 pangolin(业务方)能在本地端到端测通:下单(method=fake)→显示假收款→手动
POST /api/v2/dev/orders/:order_no/mark-paid 模拟付款成功→pay 签名 webhook→pangolin
开通订阅。全部改动收在 env MOCK_CHANNEL_ENABLED 门控内,生产默认 false 零影响:
- providerbuild.BuildRegistry:门控下注册 fake provider,SettleCurrencies 覆盖成
["CNY","USDT"](CNY 排首位对齐 pangolin 实际下单币种),fake.go 加 SetSettleCurrencies
测试缝,默认("USDT")不变、不破坏既有测试。
- main.go:门控下注入内存 fake 账户(picker 才选得到 method=fake)+ seedPangolin()
幂等 upsert pro_month/pro_quarter/pro_year 三档商品 + 挂载 dev mark-paid 路由。
- config:新增 MOCK_CHANNEL_ENABLED / WEBHOOK_TICK_SECONDS(默认 60,本地可设 2 近实时
投递)两个 env 开关;config.yaml 补 biz.pangolin 声明(secret 走 BIZ_PANGOLIN_SECRET env)。
- internal/handler/devmock.go:MarkPaid 按 out_trade_no 取 fake 渠道最新 attempt,
原样组回调体走 gateway.HandleCallback("fake",...),与真实回调同一入口。
自测:go build/vet/test 全绿;起服务后签名下单(currency=CNY,与 pangolin 未传
currency 时的实际结算币种一致)→ mark-paid → 订单翻 paid → outbox ~2s 内尝试投递
(因本机未起 pangolin 收 404,预期内,证明 pay 侧链路已通)。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
||||
"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"
|
||||
@@ -31,6 +32,9 @@ func main() {
|
||||
autoMigrate(db)
|
||||
seed(db)
|
||||
seedWechat(db)
|
||||
if config.C.MockChannelEnabled {
|
||||
seedPangolin(db) // dev-only:仅 MOCK_CHANNEL_ENABLED=true 时 seed pangolin 三档商品
|
||||
}
|
||||
|
||||
reg := channel.NewRegistry(db)
|
||||
|
||||
@@ -65,16 +69,33 @@ func main() {
|
||||
}
|
||||
return o.Status.Settled(), nil
|
||||
})
|
||||
notifier.Start(60 * time.Second)
|
||||
// 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)
|
||||
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)
|
||||
log.Println("[dev] MOCK_CHANNEL_ENABLED=true:已挂载 POST /api/v2/dev/orders/:order_no/mark-paid")
|
||||
}
|
||||
|
||||
// P6 后台守护 / 对账:订单过期清理 + 用量刷新 + 查单对账收敛 + 已付抽查 +
|
||||
// 退款修复扫描/卡滞告警(P4 T3 review 追加义务,归到本任务一起装配)+
|
||||
@@ -304,3 +325,60 @@ func seedWechat(db *gorm.DB) {
|
||||
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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user