From 669774d0f7d55d9b764910a10f8b49def5d22583 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 11 Jul 2026 01:26:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(dev):=20=E6=9C=AC=E5=9C=B0=E8=81=94?= =?UTF-8?q?=E8=B0=83=20mock=20=E6=B8=A0=E9=81=93=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E9=93=BE(fake=20=E6=B3=A8=E5=86=8C/seed=20pangolin/=E6=A8=A1?= =?UTF-8?q?=E6=8B=9F=E4=BB=98=E6=AC=BE=E7=AB=AF=E7=82=B9/=E5=8F=AF?= =?UTF-8?q?=E9=85=8D=E6=8A=95=E9=80=92=E9=97=B4=E9=9A=94,MOCK=5FCHANNEL=5F?= =?UTF-8?q?ENABLED=20=E9=97=A8=E6=8E=A7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让 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) Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u --- config/config.go | 11 ++++ config/config.yaml | 3 ++ internal/handler/devmock.go | 73 ++++++++++++++++++++++++++ internal/provider/fake/fake.go | 21 +++++++- internal/providerbuild/registry.go | 16 +++++- main.go | 82 +++++++++++++++++++++++++++++- 6 files changed, 202 insertions(+), 4 deletions(-) create mode 100644 internal/handler/devmock.go diff --git a/config/config.go b/config/config.go index 479c975..f163f43 100644 --- a/config/config.go +++ b/config/config.go @@ -24,6 +24,13 @@ type Config struct { Routing map[string]string `mapstructure:"routing"` // RateLimit v2 改状态端点(下单/重试/取消)的 per-IP 限流(jiu 反馈波)。 RateLimit RateLimitConfig `mapstructure:"rate_limit"` + // MockChannelEnabled 门控本地联调 mock 支付渠道工具链(dev-only):fake provider 注册 + + // 内存 fake 账户注入 + seed 业务方(pangolin)商品 + POST /api/v2/dev/orders/:order_no/mark-paid + // 端点。生产必须为 false(默认);走 env MOCK_CHANNEL_ENABLED,不写进 config.yaml。 + MockChannelEnabled bool `mapstructure:"mock_channel_enabled"` + // WebhookTickSeconds webhook 出站投递后台扫描间隔(秒)。默认 60;本地联调可设小(如 2) + // 让「模拟付款成功」后近实时投递,不必等最多 60s。走 env WEBHOOK_TICK_SECONDS。 + WebhookTickSeconds int `mapstructure:"webhook_tick_seconds"` } // RateLimitConfig v2 网关 per-IP 限流开关与速率。字段故意叫 Disabled 而非 Enabled—— @@ -153,6 +160,8 @@ func Load() { _ = viper.BindEnv("wechat.private_key", "WECHAT_MCH_PRIVATE_KEY") _ = viper.BindEnv("wechat.apiv3_key", "WECHAT_APIV3_KEY") _ = viper.BindEnv("database.dsn", "DATABASE_DSN") + _ = viper.BindEnv("mock_channel_enabled", "MOCK_CHANNEL_ENABLED") + _ = viper.BindEnv("webhook_tick_seconds", "WEBHOOK_TICK_SECONDS") viper.SetDefault("server.port", "8080") viper.SetDefault("server.mode", "debug") @@ -182,6 +191,8 @@ func Load() { viper.SetDefault("reconcile.refund_apply_lookback_min", 48*60) viper.SetDefault("rate_limit.disabled", false) viper.SetDefault("rate_limit.requests_per_min", 30) + viper.SetDefault("mock_channel_enabled", false) + viper.SetDefault("webhook_tick_seconds", 60) if err := viper.ReadInConfig(); err != nil { log.Println("[config] 未找到 config.yaml,使用默认值 + 环境变量") diff --git a/config/config.yaml b/config/config.yaml index 4e24272..78a5cbd 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -49,6 +49,9 @@ biz: # dudu: # 将来接入 dudu:加这段 + 设 BIZ_DUDU_SECRET # callback_url: "https://.../api/v1/pay/callback" # secret: "" + pangolin: + callback_url: "http://localhost:8080/v1/webhook/pay" # pangolin 本地联调固定监听 :8080 的接收器 + secret: "" # 留空!走环境变量 BIZ_PANGOLIN_SECRET(本地联调用 throwaway 值,生产另配) # 兜底主动查单:定时把待支付订单拿去查,防异步回调丢失 query_sync: diff --git a/internal/handler/devmock.go b/internal/handler/devmock.go new file mode 100644 index 0000000..0abcc06 --- /dev/null +++ b/internal/handler/devmock.go @@ -0,0 +1,73 @@ +package handler + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/wangjia/pay/internal/gateway" + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/provider" + "github.com/wangjia/pay/internal/util" +) + +// DevMockHandler is a dev-only handler (registered only when +// config.C.MockChannelEnabled=true, see main.go) that lets local integration +// testing simulate a successful payment on the fake channel with a single +// HTTP request — no manual sqlite surgery or hand-crafted callback body needed. +type DevMockHandler struct { + db *gorm.DB + g *gateway.Gateway +} + +func NewDevMockHandler(db *gorm.DB, g *gateway.Gateway) *DevMockHandler { + return &DevMockHandler{db: db, g: g} +} + +// fakeCallbackBody mirrors provider/fake.Provider.VerifyCallback's expected JSON shape. +type fakeCallbackBody struct { + ProviderRef string `json:"provider_ref"` + Status string `json:"status"` + AmountMinor int64 `json:"amount_minor"` + Currency string `json:"currency"` +} + +// MarkPaid POST /api/v2/dev/orders/:order_no/mark-paid —— dev-only「模拟付款成功」。 +// 按 out_trade_no 取该单在 fake 渠道最近一次 attempt(provider_ref + amount_minor/currency), +// 原样组一个 fake 渠道回调体,内部直接走 gateway.HandleCallback("fake", ...)(与 +// POST /api/v2/callback/fake 同一入口),触发 Settle→webhook 入队。金额/币种从 attempt +// 读,不由调用方传,保证与订单一致、免手工对账。 +func (h *DevMockHandler) MarkPaid(c *gin.Context) { + orderNo := c.Param("order_no") + var att model.Attempt + err := h.db.Where("out_trade_no = ? AND channel = ?", orderNo, "fake"). + Order("id DESC").First(&att).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + util.RespondError(c, http.StatusNotFound, "order_not_found", "订单不存在或未走 fake 渠道下单") + return + } + if err != nil { + util.RespondError(c, http.StatusInternalServerError, "internal_error", "查询订单失败") + return + } + + body, err := json.Marshal(fakeCallbackBody{ + ProviderRef: att.ProviderRef, Status: "succeeded", + AmountMinor: att.AmountMinor, Currency: att.Currency, + }) + if err != nil { + util.RespondError(c, http.StatusInternalServerError, "internal_error", "构造回调体失败") + return + } + + res, err := h.g.HandleCallback(c.Request.Context(), "fake", provider.CallbackInput{Raw: body}) + if err != nil && res != gateway.SettleAmountMismatch { + util.RespondError(c, http.StatusInternalServerError, "mark_paid_failed", "模拟付款失败: "+err.Error()) + return + } + + c.JSON(http.StatusOK, gin.H{"ok": true, "out_trade_no": orderNo, "result": string(res)}) +} diff --git a/internal/provider/fake/fake.go b/internal/provider/fake/fake.go index 07ff5f4..05dc37e 100644 --- a/internal/provider/fake/fake.go +++ b/internal/provider/fake/fake.go @@ -26,6 +26,11 @@ type Provider struct { refundStatus provider.PaidStatus refundErr error + // settleCurrencies overrides the default ["USDT"] capability (测试缝/dev 缝: + // providerbuild.BuildRegistry 在 MOCK_CHANNEL_ENABLED 下把它设成 ["CNY","USDT"] + // 以对齐 pangolin 下单实际用的结算币种;默认 nil 时保持既有测试预期不变)。 + settleCurrencies []string + lastMetadata map[string]string // 测试缝:记录最近一次 Create 收到的 Metadata(jiu 反馈波,验证 gateway 透传) } @@ -33,11 +38,25 @@ func New() *Provider { return &Provider{queryResults: map[string]provider.PaidEv func (p *Provider) Method() string { return "fake" } +// SetSettleCurrencies overrides Capabilities().SettleCurrencies (测试缝/dev 缝,见字段注释)。 +// SettleCurrencies[0] 驱动 gateway.CreateOrder 实际选用的结算币种(设计 §4.1)。 +func (p *Provider) SetSettleCurrencies(cs []string) { + p.mu.Lock() + defer p.mu.Unlock() + p.settleCurrencies = cs +} + func (p *Provider) Capabilities() provider.Capabilities { + p.mu.Lock() + cs := p.settleCurrencies + p.mu.Unlock() + if len(cs) == 0 { + cs = []string{"USDT"} + } return provider.Capabilities{ RenderTypes: []provider.RenderType{provider.RenderCryptoAddress}, SupportsRefund: p.supportsRefund, - SettleCurrencies: []string{"USDT"}, + SettleCurrencies: cs, Regions: []string{"global"}, } } diff --git a/internal/providerbuild/registry.go b/internal/providerbuild/registry.go index 15e8e58..19f1046 100644 --- a/internal/providerbuild/registry.go +++ b/internal/providerbuild/registry.go @@ -17,18 +17,32 @@ import ( sw "github.com/smartwalle/alipay/v3" stripeclient "github.com/stripe/stripe-go/v79/client" + "github.com/wangjia/pay/config" "github.com/wangjia/pay/internal/accounts" "github.com/wangjia/pay/internal/provider" "github.com/wangjia/pay/internal/provider/alipay" "github.com/wangjia/pay/internal/provider/crypto" + "github.com/wangjia/pay/internal/provider/fake" "github.com/wangjia/pay/internal/provider/stripe" ) // BuildRegistry 据 enabled 账户装配注册表:有 enabled 账户且凭证齐备的渠道才 Register。 -// 缺凭证只跳过该渠道(log,不 fatal)——允许只上线部分渠道;fake adapter 从不在此注册。 +// 缺凭证只跳过该渠道(log,不 fatal)——允许只上线部分渠道;fake adapter 只在 +// config.C.MockChannelEnabled=true(env MOCK_CHANNEL_ENABLED)时注册,生产默认 false 不受影响。 func BuildRegistry(accts *accounts.Registry) *provider.Registry { reg := provider.NewRegistry() + // fake:dev-only 本地联调渠道(见顶部包注释)。SettleCurrencies 覆盖成 + // ["CNY","USDT"]——CNY 排首位对齐 pangolin 下单实际结算币种(pangolin CreateOrder + // 不传 currency,由渠道 Capabilities().SettleCurrencies[0] 驱动定价,见 + // server/internal/pay/client.go::createOrderReq),USDT 兜底覆盖其它业务方。 + if config.C.MockChannelEnabled { + fp := fake.New() + fp.SetSettleCurrencies([]string{"CNY", "USDT"}) + reg.Register(fp) + log.Println("[providers] fake 已注册(MOCK_CHANNEL_ENABLED=true,dev-only)") + } + // crypto:整渠道一个 adapter,多地址=多账户(P5 地址池路由前取首个 enabled)。 if len(accts.EnabledFor("crypto", "")) > 0 { reg.Register(crypto.New(accts)) diff --git a/main.go b/main.go index 309bb88..87a7a9d 100644 --- a/main.go +++ b/main.go @@ -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. 选策略(缺省 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)) +}