fix(v2): jiu 反馈波——端型透传(wap/page)+ alipay qr(当面付移植)+ 单号增熵/挡板限流

1. CreateOrderInput/RetryOrder 加 Metadata 通道,handler 白名单过滤(is_mobile/render)
   后原样传给 provider.CreateRequest;alipay adapter 据 is_mobile 选 wap/page。
2. alipay adapter 移植 v1 当面付(TradePreCreate):Metadata["render"]=="qr" → 二维码
   render_type,payload={qr_content,display_amount,currency},默认 2 小时窗口。
3. NewOutTradeNo 随机部分 8→16 hex 防生日碰撞(仍 <=64 字符,合规 DB 列/支付宝上限);
   v2 改状态端点(下单/重试/取消)加 per-IP 内存令牌桶限流,默认开 30/min,
   config.rate_limit.disabled 可关;callback/GET 查询不限。

顺带修:internal/reconcile/sync_test.go 的 gateway.New 调用漏传 refunds store,
预先存在的编译期回归(与本次改动无关,但挡住 go test ./... 全绿,一并修掉)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
wangjia
2026-07-10 18:40:15 +08:00
parent 7b90c24a46
commit 8051b0fb16
15 changed files with 698 additions and 17 deletions
+47 -3
View File
@@ -23,6 +23,11 @@ const gmtPaymentLayout = "2006-01-02 15:04:05"
var cst = time.FixedZone("CST", 8*3600)
// qrDefaultTTL 当面付(TradePreCreate)未显式设置 timeout_express 时,支付宝按
// 官方文档默认 2 小时未支付自动关闭交易;这里镜像同一默认值给客户端展示倒计时用,
// 不代表 pay 侧另设了过期逻辑(真实过期仍由支付宝侧判定,pay 靠回调/查单收敛)。
const qrDefaultTTL = 2 * time.Hour
type Provider struct{ client *sw.Client }
func New(client *sw.Client) *Provider { return &Provider{client: client} }
@@ -31,7 +36,7 @@ func (p *Provider) Method() string { return "alipay" }
func (p *Provider) Capabilities() provider.Capabilities {
return provider.Capabilities{
RenderTypes: []provider.RenderType{provider.RenderRedirect},
RenderTypes: []provider.RenderType{provider.RenderRedirect, provider.RenderQR},
SupportsRefund: true, // P4:alipay.trade.refund 同步退款
SettleCurrencies: []string{"CNY"},
Regions: []string{"cn"},
@@ -66,7 +71,7 @@ func (p *Provider) Refund(ctx context.Context, providerRef, refundID string, amo
return refundID, provider.PaidSucceeded, nil
}
func (p *Provider) Create(_ context.Context, req provider.CreateRequest) (*provider.Session, error) {
func (p *Provider) Create(ctx context.Context, req provider.CreateRequest) (*provider.Session, error) {
if req.Currency != "CNY" {
return nil, fmt.Errorf("alipay: 仅支持 CNY, got %s", req.Currency)
}
@@ -74,8 +79,12 @@ func (p *Provider) Create(_ context.Context, req provider.CreateRequest) (*provi
if err != nil {
return nil, err
}
// render=qr:当面付(扫码收银台,不跳转)——移植自 v1 internal/channel/alipay.go PreCreate。
if req.Metadata["render"] == "qr" {
return p.createQR(ctx, req, amount)
}
var payURL *url.URL
if req.Metadata["is_mobile"] == "1" {
if isMobileMetadata(req.Metadata) {
wp := sw.TradeWapPay{}
wp.OutTradeNo = req.OutTradeNo
wp.Subject = req.Subject
@@ -103,6 +112,41 @@ func (p *Provider) Create(_ context.Context, req provider.CreateRequest) (*provi
}, nil
}
// isMobileMetadata 判端型:白名单值 "1"/"true" 均视为手机端(handler 层 resolveIsMobile
// 目前下发 "1",这里同时兼容 "true" 以防未来调用方改用布尔字面量字符串)。
func isMobileMetadata(md map[string]string) bool {
v := md["is_mobile"]
return v == "1" || v == "true"
}
// createQR 当面付(alipay.trade.precreate):返回二维码码串供前端渲染,不跳转、不拉起 App,
// 收银台之外的第三个 render_type(RenderQR)——与 wap/page 共用 VerifyCallback/Query
// (同一套异步通知 + trade_query,渠道侧不区分下单方式)。
func (p *Provider) createQR(ctx context.Context, req provider.CreateRequest, amount string) (*provider.Session, error) {
pc := sw.TradePreCreate{}
pc.OutTradeNo = req.OutTradeNo
pc.Subject = req.Subject
pc.TotalAmount = amount
rsp, err := p.client.TradePreCreate(ctx, pc)
if err != nil {
return nil, fmt.Errorf("alipay: 预下单调用失败: %w", err)
}
if rsp.IsFailure() {
return nil, fmt.Errorf("alipay: 预下单失败: %s / %s", rsp.Msg, rsp.SubMsg)
}
exp := time.Now().Add(qrDefaultTTL)
return &provider.Session{
ProviderRef: req.OutTradeNo, // 支付宝以 out_trade_no 归位,与 wap/page 一致
RenderType: provider.RenderQR,
Payload: map[string]any{
"qr_content": rsp.QRCode,
"display_amount": amount, // 元串(非分),供前端直接展示金额
"currency": "CNY",
},
ExpiresAt: &exp,
}, nil
}
func (p *Provider) VerifyCallback(ctx context.Context, in provider.CallbackInput) (*provider.PaidEvent, error) {
form, err := url.ParseQuery(string(in.Raw))
if err != nil {