Documentation
¶
Overview ¶
Package onepay 实现支付宝与微信共用的中立支付二维码(一码付)。
同一 HTTPS 地址与 PNG 二维码,按扫码客户端 User-Agent 路由: 微信走 OAuth + JSAPI,支付宝走 WAP 收银台。onepay 不直接向平台下单, 收银台创建与幂等复用由业务实现的 CheckoutResolver 负责。
功能特性 ¶
- 生成中立 HTTPS 支付 URL 与可扫码 PNG
- AES-256-GCM 加密 token(金额、订单号不进二维码)
- 微信:OAuth snsapi_base → OpenID → JSAPI bridge HTML(CSP nonce)
- 微信调起页文案/模板可配(WechatPage);零值用 DefaultWechat* 与默认 HTML
- 支付宝:WAP 收银台 HTTP 303(仅官方 gateway host)
- 未知客户端返回安全提示页,不猜测渠道
- 标准库 net/http.Handler,可挂载到任意 HTTP 框架
快速开始 ¶
svc, err := onepay.New(onepay.Config{
BaseURL: "https://pay.example.com",
TokenKey: tokenKey32Bytes, // 恰好 32 字节
Resolver: businessResolver, // 业务实现 CheckoutResolver
Wechat: wechatClient, // 提供 OAuth 的微信 adapter
// 可选:覆盖默认「支付中…」等文案
// WechatPage: onepay.WechatPage{LoadingText: "正在支付…", Title: "收银台"},
}, onepay.WithQRSize(256))
if err != nil {
log.Fatal(err)
}
code, err := svc.CreateCode(ctx, "intent-1001")
// code.URL 写入二维码;code.PNG 可直接下发图片
http.Handle("/pay/", svc.Handler()) // 或 mux.Handle("/pay/", svc.Handler())
微信调起页(WechatPage) ¶
默认 loading 文案为「支付中…」(旧版「正在调起微信支付…」已变更;E2E/文案断言需同步)。
自定义整页 Template 时数据为 WechatBridgeData,且必须兼容 WechatBridgeCSP。 SuccessText/FailText 为 JSON 字符串字面量(template.JS),只应出现在 <script> 中; HTML 标题与正文仅使用 Title / LoadingText。
tpl := template.Must(template.New("wx").Parse(`<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><title>{{.Title}}</title></head>
<body><p id="status">{{.LoadingText}}</p>
<script nonce="{{.Nonce}}">
const pay = {{.Params}};
function invokePay(){
WeixinJSBridge.invoke('getBrandWCPayRequest', pay, function(res){
document.getElementById('status').textContent =
res.err_msg === 'get_brand_wcpay_request:ok' ? {{.SuccessText}} : {{.FailText}};
});
}
if (typeof WeixinJSBridge === 'undefined') {
document.addEventListener('WeixinJSBridgeReady', invokePay, false);
} else { invokePay(); }
</script></body></html>`))
WechatPage: onepay.WechatPage{Template: tpl, LoadingText: "支付中…"}
CheckoutResolver 契约 ¶
Resolver 是业务持久化边界,onepay 不缓存订单。必须满足:
- 同一 intentID + provider 只允许一个活动平台订单
- 支付宝:payerOpenID 为空,返回未过期 WAP
- 微信:绑定 OpenID 摘要;同 OpenID 复用未过期 JSAPI;不同 OpenID 不得复用
- 不同 provider 使用不同商户订单号,并保留映射到主支付意图
- artifact 过期后先查原单状态;仍未支付则关单并换新单号重建
- 平台写请求结果不确定时先 Query,不盲目重试创建
- 首个成功支付回调由业务 CAS 完成主意图,再关闭另一平台待支付单
内存骨架见本包 ExampleCheckoutResolver / TestMemoryResolver*: 创建中/退役中占位、intent paid 单调、uncertain 仅锁 provider 槽、ctx 取消不落永久墓碑。 主意图 CAS 与真实 DB 须由业务补齐,勿直接当生产实现。
挂载 ¶
标准库:
mux := http.NewServeMux()
mux.Handle("/pay/", svc.Handler())
Gin(或其它框架)把标准 Handler 包一层即可:
r.Any("/pay/*path", gin.WrapH(svc.Handler()))
注意事项 ¶
- BaseURL 须为 HTTPS origin(localhost/loopback 测试允许 HTTP)
- Path 默认 /pay/,须以 / 开头和结尾,禁止 . 与 .. 段
- TokenTTL 默认 15 分钟,最大 24 小时
- 同步跳转/JS bridge 仅用于 UI;入账只认异步回调验签结果
- 请使用微信或支付宝扫码;其它 UA 返回 HTTP 400
Index ¶
Examples ¶
Constants ¶
const ( DefaultWechatTitle = "微信支付" DefaultWechatLoadingText = "支付中…" DefaultWechatSuccessText = "支付已提交,请等待结果确认" DefaultWechatFailText = "未完成支付,请返回重试" )
微信 JSAPI 调起页默认文案(Config.WechatPage 零值时使用)。
注意:DefaultWechatLoadingText 现为「支付中…」(旧版为「正在调起微信支付…」)。 依赖精确旧文案的 E2E/监控需同步更新。
Variables ¶
This section is empty.
Functions ¶
func WechatBridgeCSP ¶ added in v0.2.0
WechatBridgeCSP 返回调起页固定 Content-Security-Policy。 自定义 Template 必须兼容该策略:无外链 script/style/img;inline script 仅 nonce 匹配。
Types ¶
type Checkout ¶
type Checkout struct {
// Provider 是收银台所属支付平台。
Provider payment.Provider
// OrderID 是平台商户订单号。
OrderID string
// WAP 是支付宝手机网站收银台结果。
WAP *payment.WAPResult
// JSAPI 是微信 JSAPI 调起参数。
JSAPI *payment.JSAPIResult
// ExpiresAt 是收银台结果过期时间。
ExpiresAt time.Time
}
Checkout 表示 Resolver 持久化并可复用的平台收银台结果。
type CheckoutResolver ¶
type CheckoutResolver interface {
// ResolveOrCreate 原子创建或复用指定支付意图、平台和付款人的收银台。
ResolveOrCreate(ctx context.Context, intentID string, provider payment.Provider, payerOpenID string) (*Checkout, error)
}
CheckoutResolver 在业务持久层幂等地解析或创建完整平台收银台。
Example ¶
ExampleCheckoutResolver 演示业务侧 CheckoutResolver 的内存骨架契约。
本 Example 仅断言:主意图取金额、同 intent+provider 复用、双平台不同订单号、微信 OpenID 冲突。 骨架细节(creating/retiring、slot uncertain、intent paid 单调、ctx 取消不落墓碑)由 TestMemoryResolver* 覆盖。 生产仍须:真实 DB 事务、主意图 CAS、成功回调后关另一平台 pending。
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log"
"sync"
"time"
"github.com/f2xme/gox/payment"
"github.com/f2xme/gox/payment/adapter/onepay"
)
func main() {
alipayPay := &staticAlipayCheckout{
url: "https://openapi.alipay.com/gateway.do?method=alipay.trade.wap.pay&sign=demo",
}
wechatPay := &staticWechatCheckout{
jsapi: &payment.JSAPIResult{
AppID: "wx_app", Timestamp: "1710000000", NonceStr: "n",
Package: "prepay_id=p", SignType: "RSA", PaySign: "s",
},
}
resolver := newMemoryResolverWithProviders(alipayPay, wechatPay)
ali, err := resolver.ResolveOrCreate(context.Background(), "intent-1", payment.ProviderAlipay, "")
if err != nil {
log.Fatal(err)
}
ali2, err := resolver.ResolveOrCreate(context.Background(), "intent-1", payment.ProviderAlipay, "")
if err != nil {
log.Fatal(err)
}
wx, err := resolver.ResolveOrCreate(context.Background(), "intent-1", payment.ProviderWechat, "openid-a")
if err != nil {
log.Fatal(err)
}
wx2, err := resolver.ResolveOrCreate(context.Background(), "intent-1", payment.ProviderWechat, "openid-a")
if err != nil {
log.Fatal(err)
}
_, conflict := resolver.ResolveOrCreate(context.Background(), "intent-1", payment.ProviderWechat, "openid-b")
fmt.Println(ali.OrderID == ali2.OrderID)
fmt.Println(ali.OrderID != wx.OrderID)
fmt.Println(wx.OrderID == wx2.OrderID)
fmt.Println(conflict != nil)
}
type alipayCheckoutProvider interface {
WAPPay(ctx context.Context, order *payment.Order) (*payment.WAPResult, error)
}
type wechatCheckoutProvider interface {
JSAPIPay(ctx context.Context, order *payment.Order, openID string) (*payment.JSAPIResult, error)
}
type orderLifecycle interface {
Query(ctx context.Context, orderID string) (*payment.QueryResult, error)
Close(ctx context.Context, orderID string) error
}
type staticAlipayCheckout struct {
url string
err error
}
func (s *staticAlipayCheckout) WAPPay(context.Context, *payment.Order) (*payment.WAPResult, error) {
if s.err != nil {
return nil, s.err
}
return &payment.WAPResult{URL: s.url}, nil
}
type staticWechatCheckout struct {
jsapi *payment.JSAPIResult
err error
}
func (s *staticWechatCheckout) JSAPIPay(context.Context, *payment.Order, string) (*payment.JSAPIResult, error) {
if s.err != nil {
return nil, s.err
}
cp := *s.jsapi
return &cp, nil
}
type staticLifecycle struct {
mu sync.Mutex
paidOrderIDs map[string]struct{}
closedOrderIDs map[string]struct{}
queryStatus map[string]payment.PaymentStatus
queryErr map[string]error
closeErr error
}
func (s *staticLifecycle) Query(_ context.Context, orderID string) (*payment.QueryResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.queryErr != nil {
if err := s.queryErr[orderID]; err != nil {
return nil, err
}
}
if s.queryStatus != nil {
if st, ok := s.queryStatus[orderID]; ok {
return &payment.QueryResult{OrderID: orderID, Status: st}, nil
}
}
if s.paidOrderIDs != nil {
if _, ok := s.paidOrderIDs[orderID]; ok {
return &payment.QueryResult{OrderID: orderID, Status: payment.PaymentStatusSuccess}, nil
}
}
return &payment.QueryResult{OrderID: orderID, Status: payment.PaymentStatusPending}, nil
}
func (s *staticLifecycle) Close(_ context.Context, orderID string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closeErr != nil {
return s.closeErr
}
if s.closedOrderIDs == nil {
s.closedOrderIDs = make(map[string]struct{})
}
s.closedOrderIDs[orderID] = struct{}{}
return nil
}
type checkoutKey struct {
intent string
provider payment.Provider
}
type slotPhase int
const (
phaseReady slotPhase = iota
phaseCreating
phaseRetiring
phaseUncertain
)
type storedCheckout struct {
phase slotPhase
checkout *onepay.Checkout
orderID string
payerDigest string
ready chan struct{}
}
// intent 级仅 open/paid(单调:paid 不可降级)。uncertain 只落在 provider 槽位。
type intentState int
const (
intentOpen intentState = iota
intentPaid
)
type intentRecord struct {
Amount int64
Subject string
NotifyURL string
state intentState
paidOrderID string
}
type retireOutcome int
const (
retireAllowRebuild retireOutcome = iota
retireAlreadyPaid
retireUncertain
retireCtxAbort
)
// memoryResolver 进程内骨架:
// - creating/retiring 占位 + ready 等待
// - intent paid 单调 CAS;uncertain 仅锁 provider 槽
// - 创建失败先 Query;ctx 取消不落永久墓碑
type memoryResolver struct {
mu sync.Mutex
store map[checkoutKey]*storedCheckout
intents map[string]*intentRecord
seq int
alipay alipayCheckoutProvider
wechat wechatCheckoutProvider
lifecycle orderLifecycle
now func() time.Time
}
func demoIntents() map[string]*intentRecord {
return map[string]*intentRecord{
"intent-1": {
Amount: 9900, Subject: "会员订阅",
NotifyURL: "https://merchant.example/payment/notify",
},
"intent-1001": {
Amount: 9900, Subject: "会员订阅",
NotifyURL: "https://merchant.example/payment/notify",
},
}
}
func newMemoryResolverWithProviders(ali alipayCheckoutProvider, wx wechatCheckoutProvider) *memoryResolver {
return &memoryResolver{
store: make(map[checkoutKey]*storedCheckout),
intents: demoIntents(),
alipay: ali,
wechat: wx,
lifecycle: &staticLifecycle{},
now: time.Now,
}
}
func (m *memoryResolver) ResolveOrCreate(ctx context.Context, intentID string, provider payment.Provider, payerOpenID string) (*onepay.Checkout, error) {
if intentID == "" {
return nil, fmt.Errorf("%w: empty intent", payment.ErrInvalidRequest)
}
key := checkoutKey{intent: intentID, provider: provider}
digest := ""
if provider == payment.ProviderWechat {
if payerOpenID == "" {
return nil, fmt.Errorf("%w: wechat openid required", payment.ErrInvalidRequest)
}
sum := sha256.Sum256([]byte(payerOpenID))
digest = hex.EncodeToString(sum[:])
}
for {
if err := ctx.Err(); err != nil {
return nil, err
}
m.mu.Lock()
intent, ok := m.intents[intentID]
if !ok {
m.mu.Unlock()
return nil, fmt.Errorf("%w: unknown intent %q", payment.ErrInvalidRequest, intentID)
}
if intent.Amount <= 0 || intent.Subject == "" || intent.NotifyURL == "" {
m.mu.Unlock()
return nil, fmt.Errorf("%w: intent %q missing amount/subject/notify", payment.ErrInvalidRequest, intentID)
}
if intent.state == intentPaid {
orderID := intent.paidOrderID
m.mu.Unlock()
return nil, fmt.Errorf("%w: intent already paid (order %s), do not recreate", payment.ErrInvalidRequest, orderID)
}
existing := m.store[key]
if existing != nil {
switch existing.phase {
case phaseCreating, phaseRetiring:
if provider == payment.ProviderWechat && existing.payerDigest != "" && existing.payerDigest != digest {
m.mu.Unlock()
return nil, fmt.Errorf("%w: wechat payer conflict", payment.ErrInvalidRequest)
}
ch := existing.ready
m.mu.Unlock()
select {
case <-ch:
case <-ctx.Done():
return nil, ctx.Err()
}
continue
case phaseUncertain:
orderID := existing.orderID
ready := make(chan struct{})
existing.phase = phaseRetiring
existing.ready = ready
old := cloneStored(existing)
old.orderID = orderID
m.mu.Unlock()
outcome, retireErr := m.retireExpired(ctx, old)
m.finishRetire(key, ready, intentID, old, outcome)
switch outcome {
case retireAlreadyPaid:
return nil, alreadyPaidError(old.orderID)
case retireUncertain:
return nil, fmt.Errorf("%w: slot uncertain for order %s; re-query later", payment.ErrInvalidRequest, orderID)
case retireCtxAbort:
return nil, contextAbortError(ctx, retireErr)
case retireAllowRebuild:
continue
default:
return nil, fmt.Errorf("%w: unknown retire outcome %v", payment.ErrInvalidRequest, outcome)
}
case phaseReady:
if existing.checkout != nil && !existing.checkout.ExpiresAt.Before(m.now()) {
if provider == payment.ProviderWechat && existing.payerDigest != digest {
m.mu.Unlock()
return nil, fmt.Errorf("%w: wechat payer conflict", payment.ErrInvalidRequest)
}
out := cloneCheckout(existing.checkout)
m.mu.Unlock()
return out, nil
}
ready := make(chan struct{})
existing.phase = phaseRetiring
existing.ready = ready
if existing.orderID == "" && existing.checkout != nil {
existing.orderID = existing.checkout.OrderID
}
old := cloneStored(existing)
m.mu.Unlock()
outcome, retireErr := m.retireExpired(ctx, old)
m.finishRetire(key, ready, intentID, old, outcome)
switch outcome {
case retireAlreadyPaid:
return nil, alreadyPaidError(old.orderID)
case retireUncertain:
return nil, fmt.Errorf("%w: expire retire uncertain for order %s; re-query later", payment.ErrInvalidRequest, old.orderID)
case retireCtxAbort:
return nil, contextAbortError(ctx, retireErr)
case retireAllowRebuild:
continue
default:
return nil, fmt.Errorf("%w: unknown retire outcome %v", payment.ErrInvalidRequest, outcome)
}
}
}
ready := make(chan struct{})
m.seq++
orderID := fmt.Sprintf("%s-%s-%d", intentID, provider, m.seq)
intentSnap := *intent
m.store[key] = &storedCheckout{phase: phaseCreating, ready: ready, payerDigest: digest, orderID: orderID}
m.mu.Unlock()
checkout, err := m.createPlatformCheckout(ctx, provider, orderID, intentSnap, payerOpenID)
if err != nil {
return nil, m.handleCreateFailure(ctx, key, ready, orderID, intentID, err)
}
m.mu.Lock()
cur := m.store[key]
if cur == nil || cur.phase != phaseCreating || cur.ready != ready {
closeReady(ready)
m.mu.Unlock()
m.closeOrphan(ctx, orderID)
continue
}
if provider == payment.ProviderWechat && cur.payerDigest != digest {
delete(m.store, key)
closeReady(ready)
m.mu.Unlock()
m.closeOrphan(ctx, orderID)
return nil, fmt.Errorf("%w: wechat payer conflict", payment.ErrInvalidRequest)
}
if intent.state == intentPaid {
delete(m.store, key)
closeReady(ready)
paidOrder := intent.paidOrderID
m.mu.Unlock()
m.closeOrphan(ctx, orderID)
return nil, fmt.Errorf("%w: intent already paid (order %s), do not recreate", payment.ErrInvalidRequest, paidOrder)
}
cur.phase = phaseReady
cur.checkout = checkout
cur.orderID = orderID
closeReady(ready)
out := cloneCheckout(checkout)
m.mu.Unlock()
return out, nil
}
}
func (m *memoryResolver) createPlatformCheckout(ctx context.Context, provider payment.Provider, orderID string, intent intentRecord, payerOpenID string) (*onepay.Checkout, error) {
order := &payment.Order{
OrderID: orderID, Amount: intent.Amount, Subject: intent.Subject, NotifyURL: intent.NotifyURL,
}
expires := m.now().Add(15 * time.Minute)
checkout := &onepay.Checkout{Provider: provider, OrderID: orderID, ExpiresAt: expires}
switch provider {
case payment.ProviderAlipay:
wap, err := m.alipay.WAPPay(ctx, order)
if err != nil {
return nil, err
}
checkout.WAP = wap
case payment.ProviderWechat:
jsapi, err := m.wechat.JSAPIPay(ctx, order, payerOpenID)
if err != nil {
return nil, err
}
checkout.JSAPI = jsapi
default:
return nil, fmt.Errorf("%w: unsupported provider %q", payment.ErrInvalidRequest, provider)
}
return checkout, nil
}
func (m *memoryResolver) handleCreateFailure(ctx context.Context, key checkoutKey, ready chan struct{}, orderID, intentID string, createErr error) error {
if isContextError(createErr) || isContextError(ctx.Err()) {
m.clearCreating(key, ready)
if isContextError(createErr) {
return createErr
}
return ctx.Err()
}
if isDeterministicCreateError(createErr) {
m.clearCreating(key, ready)
return createErr
}
if m.lifecycle == nil {
m.markSlotUncertain(key, ready, orderID)
return fmt.Errorf("%w: create uncertain without lifecycle (order %s): %v", payment.ErrInvalidRequest, orderID, createErr)
}
q, err := m.lifecycle.Query(ctx, orderID)
if err != nil {
m.markSlotUncertain(key, ready, orderID)
if isContextError(err) || isContextError(ctx.Err()) {
return contextAbortError(ctx, err)
}
return fmt.Errorf("%w: create uncertain, query failed (order %s): %v", payment.ErrInvalidRequest, orderID, createErr)
}
switch q.Status {
case payment.PaymentStatusSuccess:
m.markIntentPaid(key, ready, orderID, intentID)
return fmt.Errorf("%w: intent already paid (order %s), do not recreate", payment.ErrInvalidRequest, orderID)
case payment.PaymentStatusRefunded:
m.markIntentPaid(key, ready, orderID, intentID)
return fmt.Errorf("%w: intent already paid-then-refunded (order %s), do not recreate", payment.ErrInvalidRequest, orderID)
case payment.PaymentStatusPending:
if closeErr := m.lifecycle.Close(ctx, orderID); closeErr != nil {
m.markSlotUncertain(key, ready, orderID)
if isContextError(closeErr) {
return contextAbortError(ctx, closeErr)
}
return fmt.Errorf("%w: create pending but close failed (order %s): %v", payment.ErrInvalidRequest, orderID, createErr)
}
m.clearCreating(key, ready)
return createErr
case payment.PaymentStatusClosed, payment.PaymentStatusFailed:
m.clearCreating(key, ready)
return createErr
default:
m.markSlotUncertain(key, ready, orderID)
return fmt.Errorf("%w: create uncertain unknown status %q (order %s)", payment.ErrInvalidRequest, q.Status, orderID)
}
}
func isDeterministicCreateError(err error) bool {
return errors.Is(err, payment.ErrInvalidRequest) || errors.Is(err, payment.ErrInvalidConfig)
}
func isContextError(err error) bool {
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
}
func alreadyPaidError(orderID string) error {
return fmt.Errorf("%w: intent already paid (order %s), do not recreate", payment.ErrInvalidRequest, orderID)
}
// contextAbortError 保证 retireCtxAbort 永不返回 (nil, nil)。
// 优先下游 context 错误;其次父 ctx.Err();皆空时回落 Canceled。
func contextAbortError(ctx context.Context, retireErr error) error {
if isContextError(retireErr) {
return retireErr
}
if err := ctx.Err(); err != nil {
return err
}
if retireErr != nil {
return retireErr
}
return fmt.Errorf("%w: retire aborted", context.Canceled)
}
// closeReady 关闭 ready 并容忍重复 close。
// 调用约定:必须在持有 m.mu 时调用(与 store 所有权校验同一临界区)。
func closeReady(ready chan struct{}) {
if ready == nil {
return
}
select {
case <-ready:
default:
close(ready)
}
}
func (m *memoryResolver) clearCreating(key checkoutKey, ready chan struct{}) {
m.mu.Lock()
defer m.mu.Unlock()
if cur := m.store[key]; cur != nil && cur.phase == phaseCreating && cur.ready == ready {
delete(m.store, key)
closeReady(ready)
return
}
closeReady(ready)
}
// markSlotUncertain 仅在 owner 时改槽位;从不写 intent 级 uncertain,不覆盖他人 store。
func (m *memoryResolver) markSlotUncertain(key checkoutKey, ready chan struct{}, orderID string) {
m.mu.Lock()
defer m.mu.Unlock()
if cur := m.store[key]; cur != nil && cur.ready == ready {
cur.phase = phaseUncertain
cur.checkout = nil
cur.orderID = orderID
closeReady(ready)
return
}
closeReady(ready)
}
// markIntentPaid 单调写入 intentPaid(不可被 uncertain 降级);owner 清槽。
func (m *memoryResolver) markIntentPaid(key checkoutKey, ready chan struct{}, orderID, intentID string) {
m.mu.Lock()
defer m.mu.Unlock()
if intent := m.intents[intentID]; intent != nil {
if intent.state != intentPaid {
intent.state = intentPaid
intent.paidOrderID = orderID
} else if intent.paidOrderID == "" {
intent.paidOrderID = orderID
}
}
if cur := m.store[key]; cur != nil && cur.ready == ready {
delete(m.store, key)
closeReady(ready)
return
}
closeReady(ready)
}
func (m *memoryResolver) finishRetire(key checkoutKey, ready chan struct{}, intentID string, old *storedCheckout, outcome retireOutcome) {
m.mu.Lock()
defer m.mu.Unlock()
cur := m.store[key]
owns := cur != nil && cur.phase == phaseRetiring && cur.ready == ready
if !owns {
closeReady(ready)
return
}
switch outcome {
case retireAlreadyPaid:
if intent := m.intents[intentID]; intent != nil {
if intent.state != intentPaid {
intent.state = intentPaid
if old != nil {
intent.paidOrderID = old.orderID
}
}
}
delete(m.store, key)
closeReady(ready)
case retireUncertain:
cur.phase = phaseUncertain
cur.checkout = nil
if old != nil {
cur.orderID = old.orderID
}
closeReady(ready)
case retireCtxAbort:
if cur.checkout != nil {
cur.phase = phaseReady
} else {
orderID := cur.orderID
if orderID == "" && old != nil {
orderID = old.orderID
}
if orderID != "" {
cur.phase = phaseUncertain
cur.checkout = nil
cur.orderID = orderID
} else {
delete(m.store, key)
}
}
closeReady(ready)
case retireAllowRebuild:
delete(m.store, key)
closeReady(ready)
default:
orderID := cur.orderID
if orderID == "" && old != nil {
orderID = old.orderID
}
if orderID != "" {
cur.phase = phaseUncertain
cur.checkout = nil
cur.orderID = orderID
} else {
delete(m.store, key)
}
closeReady(ready)
}
}
func (m *memoryResolver) closeOrphan(ctx context.Context, orderID string) {
if m.lifecycle == nil || orderID == "" {
return
}
if err := m.lifecycle.Close(ctx, orderID); err != nil {
_ = err
}
}
// retireExpired 退役过期/uncertain 槽位。ctx 类错误时返回 (retireCtxAbort, err),err 供调用方避免 (nil,nil)。
func (m *memoryResolver) retireExpired(ctx context.Context, old *storedCheckout) (retireOutcome, error) {
if old == nil || old.orderID == "" {
return retireAllowRebuild, nil
}
if m.lifecycle == nil {
return retireUncertain, nil
}
q, err := m.lifecycle.Query(ctx, old.orderID)
if err != nil {
if isContextError(err) {
return retireCtxAbort, err
}
if isContextError(ctx.Err()) {
return retireCtxAbort, ctx.Err()
}
return retireUncertain, nil
}
switch q.Status {
case payment.PaymentStatusSuccess, payment.PaymentStatusRefunded:
return retireAlreadyPaid, nil
case payment.PaymentStatusPending:
if err := m.lifecycle.Close(ctx, old.orderID); err != nil {
if isContextError(err) {
return retireCtxAbort, err
}
if isContextError(ctx.Err()) {
return retireCtxAbort, ctx.Err()
}
return retireUncertain, nil
}
return retireAllowRebuild, nil
case payment.PaymentStatusFailed, payment.PaymentStatusClosed:
return retireAllowRebuild, nil
default:
return retireUncertain, nil
}
}
func cloneStored(s *storedCheckout) *storedCheckout {
if s == nil {
return nil
}
out := *s
if s.checkout != nil {
out.checkout = cloneCheckout(s.checkout)
}
return &out
}
func cloneCheckout(c *onepay.Checkout) *onepay.Checkout {
if c == nil {
return nil
}
out := *c
if c.WAP != nil {
wap := *c.WAP
out.WAP = &wap
}
if c.JSAPI != nil {
js := *c.JSAPI
out.JSAPI = &js
}
return &out
}
Output: true true true true
type Code ¶
type Code struct {
// URL 是二维码编码的中立支付地址。
URL string
// PNG 是二维码图片内容。
PNG []byte
// ExpiresAt 是二维码过期时间。
ExpiresAt time.Time
}
Code 是可展示的一码付 URL 与 PNG。
type Config ¶
type Config struct {
// BaseURL 是二维码访问使用的 HTTPS origin。
BaseURL string
// Path 是 handler 路径前缀,默认 /pay/。
Path string
// TokenKey 是 32 字节 AES-256 密钥。
TokenKey []byte
// TokenTTL 是二维码默认有效期,默认 15 分钟。
TokenTTL time.Duration
// Resolver 是业务收银台的幂等持久化边界。
Resolver CheckoutResolver
// Wechat 提供微信 OAuth 能力。
Wechat WechatOAuth
// WechatPage 微信 JSAPI 调起页;零值使用包默认文案与模板。
WechatPage WechatPage
}
Config 定义一码付服务配置。
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service 生成中立二维码并提供扫码 HTTP handler。
func New ¶
New 创建一码付服务。
Example (CreateCodeAndMount) ¶
ExampleNew_createCodeAndMount 演示创建一码付服务、生成二维码并挂载 Handler。 Resolver / Wechat 使用进程内假实现,保证示例可运行且不出网。
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"sync"
"time"
"github.com/f2xme/gox/payment"
"github.com/f2xme/gox/payment/adapter/onepay"
)
func main() {
resolver := newMemoryResolver()
wechat := &staticWechatOAuth{openID: "oTestOpenID"}
svc, err := onepay.New(onepay.Config{
BaseURL: "https://pay.example.com",
TokenKey: bytes.Repeat([]byte{7}, 32),
Resolver: resolver,
Wechat: wechat,
}, onepay.WithQRSize(256))
if err != nil {
log.Fatal(err)
}
code, err := svc.CreateCode(context.Background(), "intent-1001")
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.Handle("/pay/", svc.Handler())
// Gin: r.Any("/pay/*path", gin.WrapH(svc.Handler()))
fmt.Println(len(code.PNG) > 0)
fmt.Println(len(code.URL) > len("https://pay.example.com/pay/"))
}
type staticWechatOAuth struct{ openID string }
func (s *staticWechatOAuth) OAuthURL(redirectURL, state string) (string, error) {
u, err := url.Parse("https://open.weixin.qq.com/connect/oauth2/authorize")
if err != nil {
return "", err
}
q := u.Query()
q.Set("redirect_uri", redirectURL)
q.Set("state", state)
u.RawQuery = q.Encode()
return u.String(), nil
}
func (s *staticWechatOAuth) ExchangeOAuthCode(context.Context, string) (string, error) {
return s.openID, nil
}
type alipayCheckoutProvider interface {
WAPPay(ctx context.Context, order *payment.Order) (*payment.WAPResult, error)
}
type wechatCheckoutProvider interface {
JSAPIPay(ctx context.Context, order *payment.Order, openID string) (*payment.JSAPIResult, error)
}
type orderLifecycle interface {
Query(ctx context.Context, orderID string) (*payment.QueryResult, error)
Close(ctx context.Context, orderID string) error
}
type staticAlipayCheckout struct {
url string
err error
}
func (s *staticAlipayCheckout) WAPPay(context.Context, *payment.Order) (*payment.WAPResult, error) {
if s.err != nil {
return nil, s.err
}
return &payment.WAPResult{URL: s.url}, nil
}
type staticWechatCheckout struct {
jsapi *payment.JSAPIResult
err error
}
func (s *staticWechatCheckout) JSAPIPay(context.Context, *payment.Order, string) (*payment.JSAPIResult, error) {
if s.err != nil {
return nil, s.err
}
cp := *s.jsapi
return &cp, nil
}
type staticLifecycle struct {
mu sync.Mutex
paidOrderIDs map[string]struct{}
closedOrderIDs map[string]struct{}
queryStatus map[string]payment.PaymentStatus
queryErr map[string]error
closeErr error
}
func (s *staticLifecycle) Query(_ context.Context, orderID string) (*payment.QueryResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.queryErr != nil {
if err := s.queryErr[orderID]; err != nil {
return nil, err
}
}
if s.queryStatus != nil {
if st, ok := s.queryStatus[orderID]; ok {
return &payment.QueryResult{OrderID: orderID, Status: st}, nil
}
}
if s.paidOrderIDs != nil {
if _, ok := s.paidOrderIDs[orderID]; ok {
return &payment.QueryResult{OrderID: orderID, Status: payment.PaymentStatusSuccess}, nil
}
}
return &payment.QueryResult{OrderID: orderID, Status: payment.PaymentStatusPending}, nil
}
func (s *staticLifecycle) Close(_ context.Context, orderID string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closeErr != nil {
return s.closeErr
}
if s.closedOrderIDs == nil {
s.closedOrderIDs = make(map[string]struct{})
}
s.closedOrderIDs[orderID] = struct{}{}
return nil
}
type checkoutKey struct {
intent string
provider payment.Provider
}
type slotPhase int
const (
phaseReady slotPhase = iota
phaseCreating
phaseRetiring
phaseUncertain
)
type storedCheckout struct {
phase slotPhase
checkout *onepay.Checkout
orderID string
payerDigest string
ready chan struct{}
}
// intent 级仅 open/paid(单调:paid 不可降级)。uncertain 只落在 provider 槽位。
type intentState int
const (
intentOpen intentState = iota
intentPaid
)
type intentRecord struct {
Amount int64
Subject string
NotifyURL string
state intentState
paidOrderID string
}
type retireOutcome int
const (
retireAllowRebuild retireOutcome = iota
retireAlreadyPaid
retireUncertain
retireCtxAbort
)
// memoryResolver 进程内骨架:
// - creating/retiring 占位 + ready 等待
// - intent paid 单调 CAS;uncertain 仅锁 provider 槽
// - 创建失败先 Query;ctx 取消不落永久墓碑
type memoryResolver struct {
mu sync.Mutex
store map[checkoutKey]*storedCheckout
intents map[string]*intentRecord
seq int
alipay alipayCheckoutProvider
wechat wechatCheckoutProvider
lifecycle orderLifecycle
now func() time.Time
}
func demoIntents() map[string]*intentRecord {
return map[string]*intentRecord{
"intent-1": {
Amount: 9900, Subject: "会员订阅",
NotifyURL: "https://merchant.example/payment/notify",
},
"intent-1001": {
Amount: 9900, Subject: "会员订阅",
NotifyURL: "https://merchant.example/payment/notify",
},
}
}
func newMemoryResolver() *memoryResolver {
return newMemoryResolverWithProviders(
&staticAlipayCheckout{url: "https://openapi.alipay.com/gateway.do?x=1"},
&staticWechatCheckout{jsapi: &payment.JSAPIResult{
AppID: "app", Timestamp: "1", NonceStr: "n", Package: "prepay_id=p", SignType: "RSA", PaySign: "s",
}},
)
}
func newMemoryResolverWithProviders(ali alipayCheckoutProvider, wx wechatCheckoutProvider) *memoryResolver {
return &memoryResolver{
store: make(map[checkoutKey]*storedCheckout),
intents: demoIntents(),
alipay: ali,
wechat: wx,
lifecycle: &staticLifecycle{},
now: time.Now,
}
}
func (m *memoryResolver) ResolveOrCreate(ctx context.Context, intentID string, provider payment.Provider, payerOpenID string) (*onepay.Checkout, error) {
if intentID == "" {
return nil, fmt.Errorf("%w: empty intent", payment.ErrInvalidRequest)
}
key := checkoutKey{intent: intentID, provider: provider}
digest := ""
if provider == payment.ProviderWechat {
if payerOpenID == "" {
return nil, fmt.Errorf("%w: wechat openid required", payment.ErrInvalidRequest)
}
sum := sha256.Sum256([]byte(payerOpenID))
digest = hex.EncodeToString(sum[:])
}
for {
if err := ctx.Err(); err != nil {
return nil, err
}
m.mu.Lock()
intent, ok := m.intents[intentID]
if !ok {
m.mu.Unlock()
return nil, fmt.Errorf("%w: unknown intent %q", payment.ErrInvalidRequest, intentID)
}
if intent.Amount <= 0 || intent.Subject == "" || intent.NotifyURL == "" {
m.mu.Unlock()
return nil, fmt.Errorf("%w: intent %q missing amount/subject/notify", payment.ErrInvalidRequest, intentID)
}
if intent.state == intentPaid {
orderID := intent.paidOrderID
m.mu.Unlock()
return nil, fmt.Errorf("%w: intent already paid (order %s), do not recreate", payment.ErrInvalidRequest, orderID)
}
existing := m.store[key]
if existing != nil {
switch existing.phase {
case phaseCreating, phaseRetiring:
if provider == payment.ProviderWechat && existing.payerDigest != "" && existing.payerDigest != digest {
m.mu.Unlock()
return nil, fmt.Errorf("%w: wechat payer conflict", payment.ErrInvalidRequest)
}
ch := existing.ready
m.mu.Unlock()
select {
case <-ch:
case <-ctx.Done():
return nil, ctx.Err()
}
continue
case phaseUncertain:
orderID := existing.orderID
ready := make(chan struct{})
existing.phase = phaseRetiring
existing.ready = ready
old := cloneStored(existing)
old.orderID = orderID
m.mu.Unlock()
outcome, retireErr := m.retireExpired(ctx, old)
m.finishRetire(key, ready, intentID, old, outcome)
switch outcome {
case retireAlreadyPaid:
return nil, alreadyPaidError(old.orderID)
case retireUncertain:
return nil, fmt.Errorf("%w: slot uncertain for order %s; re-query later", payment.ErrInvalidRequest, orderID)
case retireCtxAbort:
return nil, contextAbortError(ctx, retireErr)
case retireAllowRebuild:
continue
default:
return nil, fmt.Errorf("%w: unknown retire outcome %v", payment.ErrInvalidRequest, outcome)
}
case phaseReady:
if existing.checkout != nil && !existing.checkout.ExpiresAt.Before(m.now()) {
if provider == payment.ProviderWechat && existing.payerDigest != digest {
m.mu.Unlock()
return nil, fmt.Errorf("%w: wechat payer conflict", payment.ErrInvalidRequest)
}
out := cloneCheckout(existing.checkout)
m.mu.Unlock()
return out, nil
}
ready := make(chan struct{})
existing.phase = phaseRetiring
existing.ready = ready
if existing.orderID == "" && existing.checkout != nil {
existing.orderID = existing.checkout.OrderID
}
old := cloneStored(existing)
m.mu.Unlock()
outcome, retireErr := m.retireExpired(ctx, old)
m.finishRetire(key, ready, intentID, old, outcome)
switch outcome {
case retireAlreadyPaid:
return nil, alreadyPaidError(old.orderID)
case retireUncertain:
return nil, fmt.Errorf("%w: expire retire uncertain for order %s; re-query later", payment.ErrInvalidRequest, old.orderID)
case retireCtxAbort:
return nil, contextAbortError(ctx, retireErr)
case retireAllowRebuild:
continue
default:
return nil, fmt.Errorf("%w: unknown retire outcome %v", payment.ErrInvalidRequest, outcome)
}
}
}
ready := make(chan struct{})
m.seq++
orderID := fmt.Sprintf("%s-%s-%d", intentID, provider, m.seq)
intentSnap := *intent
m.store[key] = &storedCheckout{phase: phaseCreating, ready: ready, payerDigest: digest, orderID: orderID}
m.mu.Unlock()
checkout, err := m.createPlatformCheckout(ctx, provider, orderID, intentSnap, payerOpenID)
if err != nil {
return nil, m.handleCreateFailure(ctx, key, ready, orderID, intentID, err)
}
m.mu.Lock()
cur := m.store[key]
if cur == nil || cur.phase != phaseCreating || cur.ready != ready {
closeReady(ready)
m.mu.Unlock()
m.closeOrphan(ctx, orderID)
continue
}
if provider == payment.ProviderWechat && cur.payerDigest != digest {
delete(m.store, key)
closeReady(ready)
m.mu.Unlock()
m.closeOrphan(ctx, orderID)
return nil, fmt.Errorf("%w: wechat payer conflict", payment.ErrInvalidRequest)
}
if intent.state == intentPaid {
delete(m.store, key)
closeReady(ready)
paidOrder := intent.paidOrderID
m.mu.Unlock()
m.closeOrphan(ctx, orderID)
return nil, fmt.Errorf("%w: intent already paid (order %s), do not recreate", payment.ErrInvalidRequest, paidOrder)
}
cur.phase = phaseReady
cur.checkout = checkout
cur.orderID = orderID
closeReady(ready)
out := cloneCheckout(checkout)
m.mu.Unlock()
return out, nil
}
}
func (m *memoryResolver) createPlatformCheckout(ctx context.Context, provider payment.Provider, orderID string, intent intentRecord, payerOpenID string) (*onepay.Checkout, error) {
order := &payment.Order{
OrderID: orderID, Amount: intent.Amount, Subject: intent.Subject, NotifyURL: intent.NotifyURL,
}
expires := m.now().Add(15 * time.Minute)
checkout := &onepay.Checkout{Provider: provider, OrderID: orderID, ExpiresAt: expires}
switch provider {
case payment.ProviderAlipay:
wap, err := m.alipay.WAPPay(ctx, order)
if err != nil {
return nil, err
}
checkout.WAP = wap
case payment.ProviderWechat:
jsapi, err := m.wechat.JSAPIPay(ctx, order, payerOpenID)
if err != nil {
return nil, err
}
checkout.JSAPI = jsapi
default:
return nil, fmt.Errorf("%w: unsupported provider %q", payment.ErrInvalidRequest, provider)
}
return checkout, nil
}
func (m *memoryResolver) handleCreateFailure(ctx context.Context, key checkoutKey, ready chan struct{}, orderID, intentID string, createErr error) error {
if isContextError(createErr) || isContextError(ctx.Err()) {
m.clearCreating(key, ready)
if isContextError(createErr) {
return createErr
}
return ctx.Err()
}
if isDeterministicCreateError(createErr) {
m.clearCreating(key, ready)
return createErr
}
if m.lifecycle == nil {
m.markSlotUncertain(key, ready, orderID)
return fmt.Errorf("%w: create uncertain without lifecycle (order %s): %v", payment.ErrInvalidRequest, orderID, createErr)
}
q, err := m.lifecycle.Query(ctx, orderID)
if err != nil {
m.markSlotUncertain(key, ready, orderID)
if isContextError(err) || isContextError(ctx.Err()) {
return contextAbortError(ctx, err)
}
return fmt.Errorf("%w: create uncertain, query failed (order %s): %v", payment.ErrInvalidRequest, orderID, createErr)
}
switch q.Status {
case payment.PaymentStatusSuccess:
m.markIntentPaid(key, ready, orderID, intentID)
return fmt.Errorf("%w: intent already paid (order %s), do not recreate", payment.ErrInvalidRequest, orderID)
case payment.PaymentStatusRefunded:
m.markIntentPaid(key, ready, orderID, intentID)
return fmt.Errorf("%w: intent already paid-then-refunded (order %s), do not recreate", payment.ErrInvalidRequest, orderID)
case payment.PaymentStatusPending:
if closeErr := m.lifecycle.Close(ctx, orderID); closeErr != nil {
m.markSlotUncertain(key, ready, orderID)
if isContextError(closeErr) {
return contextAbortError(ctx, closeErr)
}
return fmt.Errorf("%w: create pending but close failed (order %s): %v", payment.ErrInvalidRequest, orderID, createErr)
}
m.clearCreating(key, ready)
return createErr
case payment.PaymentStatusClosed, payment.PaymentStatusFailed:
m.clearCreating(key, ready)
return createErr
default:
m.markSlotUncertain(key, ready, orderID)
return fmt.Errorf("%w: create uncertain unknown status %q (order %s)", payment.ErrInvalidRequest, q.Status, orderID)
}
}
func isDeterministicCreateError(err error) bool {
return errors.Is(err, payment.ErrInvalidRequest) || errors.Is(err, payment.ErrInvalidConfig)
}
func isContextError(err error) bool {
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
}
func alreadyPaidError(orderID string) error {
return fmt.Errorf("%w: intent already paid (order %s), do not recreate", payment.ErrInvalidRequest, orderID)
}
// contextAbortError 保证 retireCtxAbort 永不返回 (nil, nil)。
// 优先下游 context 错误;其次父 ctx.Err();皆空时回落 Canceled。
func contextAbortError(ctx context.Context, retireErr error) error {
if isContextError(retireErr) {
return retireErr
}
if err := ctx.Err(); err != nil {
return err
}
if retireErr != nil {
return retireErr
}
return fmt.Errorf("%w: retire aborted", context.Canceled)
}
// closeReady 关闭 ready 并容忍重复 close。
// 调用约定:必须在持有 m.mu 时调用(与 store 所有权校验同一临界区)。
func closeReady(ready chan struct{}) {
if ready == nil {
return
}
select {
case <-ready:
default:
close(ready)
}
}
func (m *memoryResolver) clearCreating(key checkoutKey, ready chan struct{}) {
m.mu.Lock()
defer m.mu.Unlock()
if cur := m.store[key]; cur != nil && cur.phase == phaseCreating && cur.ready == ready {
delete(m.store, key)
closeReady(ready)
return
}
closeReady(ready)
}
// markSlotUncertain 仅在 owner 时改槽位;从不写 intent 级 uncertain,不覆盖他人 store。
func (m *memoryResolver) markSlotUncertain(key checkoutKey, ready chan struct{}, orderID string) {
m.mu.Lock()
defer m.mu.Unlock()
if cur := m.store[key]; cur != nil && cur.ready == ready {
cur.phase = phaseUncertain
cur.checkout = nil
cur.orderID = orderID
closeReady(ready)
return
}
closeReady(ready)
}
// markIntentPaid 单调写入 intentPaid(不可被 uncertain 降级);owner 清槽。
func (m *memoryResolver) markIntentPaid(key checkoutKey, ready chan struct{}, orderID, intentID string) {
m.mu.Lock()
defer m.mu.Unlock()
if intent := m.intents[intentID]; intent != nil {
if intent.state != intentPaid {
intent.state = intentPaid
intent.paidOrderID = orderID
} else if intent.paidOrderID == "" {
intent.paidOrderID = orderID
}
}
if cur := m.store[key]; cur != nil && cur.ready == ready {
delete(m.store, key)
closeReady(ready)
return
}
closeReady(ready)
}
func (m *memoryResolver) finishRetire(key checkoutKey, ready chan struct{}, intentID string, old *storedCheckout, outcome retireOutcome) {
m.mu.Lock()
defer m.mu.Unlock()
cur := m.store[key]
owns := cur != nil && cur.phase == phaseRetiring && cur.ready == ready
if !owns {
closeReady(ready)
return
}
switch outcome {
case retireAlreadyPaid:
if intent := m.intents[intentID]; intent != nil {
if intent.state != intentPaid {
intent.state = intentPaid
if old != nil {
intent.paidOrderID = old.orderID
}
}
}
delete(m.store, key)
closeReady(ready)
case retireUncertain:
cur.phase = phaseUncertain
cur.checkout = nil
if old != nil {
cur.orderID = old.orderID
}
closeReady(ready)
case retireCtxAbort:
if cur.checkout != nil {
cur.phase = phaseReady
} else {
orderID := cur.orderID
if orderID == "" && old != nil {
orderID = old.orderID
}
if orderID != "" {
cur.phase = phaseUncertain
cur.checkout = nil
cur.orderID = orderID
} else {
delete(m.store, key)
}
}
closeReady(ready)
case retireAllowRebuild:
delete(m.store, key)
closeReady(ready)
default:
orderID := cur.orderID
if orderID == "" && old != nil {
orderID = old.orderID
}
if orderID != "" {
cur.phase = phaseUncertain
cur.checkout = nil
cur.orderID = orderID
} else {
delete(m.store, key)
}
closeReady(ready)
}
}
func (m *memoryResolver) closeOrphan(ctx context.Context, orderID string) {
if m.lifecycle == nil || orderID == "" {
return
}
if err := m.lifecycle.Close(ctx, orderID); err != nil {
_ = err
}
}
// retireExpired 退役过期/uncertain 槽位。ctx 类错误时返回 (retireCtxAbort, err),err 供调用方避免 (nil,nil)。
func (m *memoryResolver) retireExpired(ctx context.Context, old *storedCheckout) (retireOutcome, error) {
if old == nil || old.orderID == "" {
return retireAllowRebuild, nil
}
if m.lifecycle == nil {
return retireUncertain, nil
}
q, err := m.lifecycle.Query(ctx, old.orderID)
if err != nil {
if isContextError(err) {
return retireCtxAbort, err
}
if isContextError(ctx.Err()) {
return retireCtxAbort, ctx.Err()
}
return retireUncertain, nil
}
switch q.Status {
case payment.PaymentStatusSuccess, payment.PaymentStatusRefunded:
return retireAlreadyPaid, nil
case payment.PaymentStatusPending:
if err := m.lifecycle.Close(ctx, old.orderID); err != nil {
if isContextError(err) {
return retireCtxAbort, err
}
if isContextError(ctx.Err()) {
return retireCtxAbort, ctx.Err()
}
return retireUncertain, nil
}
return retireAllowRebuild, nil
case payment.PaymentStatusFailed, payment.PaymentStatusClosed:
return retireAllowRebuild, nil
default:
return retireUncertain, nil
}
}
func cloneStored(s *storedCheckout) *storedCheckout {
if s == nil {
return nil
}
out := *s
if s.checkout != nil {
out.checkout = cloneCheckout(s.checkout)
}
return &out
}
func cloneCheckout(c *onepay.Checkout) *onepay.Checkout {
if c == nil {
return nil
}
out := *c
if c.WAP != nil {
wap := *c.WAP
out.WAP = &wap
}
if c.JSAPI != nil {
js := *c.JSAPI
out.JSAPI = &js
}
return &out
}
Output: true true
func (*Service) CreateCode ¶
func (s *Service) CreateCode(ctx context.Context, intentID string, opts ...CodeOption) (*Code, error)
CreateCode 生成中立 URL 与内存 PNG。
type WechatBridgeData ¶ added in v0.2.0
type WechatBridgeData struct {
Nonce string
Params *payment.JSAPIResult
Title string
LoadingText string
SuccessText template.JS
FailText template.JS
}
WechatBridgeData 是默认/自定义微信调起页模板的数据。
- Title / LoadingText:仅用于 HTML 正文/标题,由 html/template 转义
- SuccessText / FailText:仅用于 <script> 内(JSON 字符串字面量,含引号);勿写入 HTML 正文
- Params:JSAPI 六元组;在 script 上下文中由 html/template 按 json tag 输出
- Nonce:须写入 script 的 nonce 属性,并与响应 CSP 一致
type WechatOAuth ¶
type WechatOAuth interface {
// OAuthURL 创建微信网页授权地址。
OAuthURL(redirectURL, state string) (string, error)
// ExchangeOAuthCode 使用授权 code 换取 openid。
ExchangeOAuthCode(ctx context.Context, code string) (string, error)
}
WechatOAuth 定义一码付需要的微信网页授权能力。
type WechatPage ¶ added in v0.2.0
type WechatPage struct {
// Title 页面 <title>;空则 DefaultWechatTitle(「微信支付」)。
Title string
// LoadingText 调起收银台前的状态文案;空则 DefaultWechatLoadingText(「支付中…」)。
LoadingText string
// SuccessText 用户完成支付确认后的状态文案。
SuccessText string
// FailText 用户取消或支付失败时的状态文案。
FailText string
// Template 可选自定义整页 HTML。数据为 WechatBridgeData。
//
// 约束(handler 固定写入 WechatBridgeCSP(nonce)):
// - 无外链 script / style / img / font / connect
// - inline <script> 必须带 nonce="{{.Nonce}}"
// - 须自行调用 WeixinJSBridge.invoke('getBrandWCPayRequest', {{.Params}}, …)
// - {{.SuccessText}}/{{.FailText}} 仅放 script;HTML 正文只用 Title/LoadingText
// 最小示例见包文档「微信调起页(WechatPage)」。
Template *template.Template
}
WechatPage 控制微信 JSAPI 调起页文案与模板。 文案字段经 TrimSpace 后为空则使用 DefaultWechat* 常量;Template 为 nil 时使用包内默认 HTML。