onepay

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 16, 2026 License: MIT Imports: 24 Imported by: 0

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

View Source
const (
	DefaultWechatTitle       = "微信支付"
	DefaultWechatLoadingText = "支付中…"
	DefaultWechatSuccessText = "支付已提交,请等待结果确认"
	DefaultWechatFailText    = "未完成支付,请返回重试"
)

微信 JSAPI 调起页默认文案(Config.WechatPage 零值时使用)。

注意:DefaultWechatLoadingText 现为「支付中…」(旧版为「正在调起微信支付…」)。 依赖精确旧文案的 E2E/监控需同步更新。

Variables

This section is empty.

Functions

func WechatBridgeCSP added in v0.2.0

func WechatBridgeCSP(nonce string) string

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 CodeOption

type CodeOption func(*codeOptions)

CodeOption 定义单个码选项。

func WithExpiresAt

func WithExpiresAt(expiresAt time.Time) CodeOption

WithExpiresAt 覆盖单个码的过期时间。

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 Option

type Option func(*options)

Option 定义一码付服务选项。

func WithQRSize

func WithQRSize(size int) Option

WithQRSize 设置 PNG 宽高,范围 128–1024。

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service 生成中立二维码并提供扫码 HTTP handler。

func New

func New(config Config, opts ...Option) (*Service, error)

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。

func (*Service) Handler

func (s *Service) Handler() http.Handler

Handler 返回只处理 Config.Path 前缀的 HTTP handler。

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。

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL