Documentation
¶
Overview ¶
Package jjpay 是 jjpay 支付网关的 Go 客户端。
它只做四件事:签名、验签、超时与有限重试、请求响应结构体。只依赖标准库。
用法 ¶
c, err := jjpay.NewFromEnv()
resp, err := c.CreateOrder(ctx, jjpay.CreateOrderReq{
OutTradeNo: "A2026080200123",
Subject: "基础版 包月",
TotalMinor: 1990,
})
把用户送到 resp.CheckoutURL。付款结果走异步通知,用 c.Middleware 验签。
金额 ¶
金额一律 int64,单位是币种的最小单位(CNY 即分),字段名带 Minor 后缀。 本包不提供以元为单位的参数。
调用方必须自己做的两件事 ¶
- 验签。不验签的回调端点等于任何人都能给你发“已支付”。 用 c.Middleware 或 c.Verify。
- 幂等。同一事件可能收到多次。支付类按 trade_no 去重,退款类按 refund_no。
Example ¶
三步接入:建客户端 → 下单 → 收通知。
package main
import (
"context"
"fmt"
"log"
"net/http"
jjpay "github.com/Star-Flex/jjpay-go"
)
func main() {
// 地址与凭据都在部署侧:JJPAY_BASE_URL / JJPAY_APP_ID / JJPAY_APP_SECRET
c, err := jjpay.NewFromEnv()
if err != nil {
log.Fatal(err) // 配置不合法当场炸,别留到第一次收款
}
// 下单,把 CheckoutURL 给用户
resp, err := c.CreateOrder(context.Background(), jjpay.CreateOrderReq{
OutTradeNo: "A2026080200123", // 你自己的单号,商户内唯一,也是幂等键
Subject: "基础版 包月",
TotalMinor: 1990, // 分,不是元
NotifyURL: "https://your-app.example/pay/notify",
})
if err != nil {
log.Fatal(err)
}
fmt.Println(resp.CheckoutURL)
// 收通知——这一步才是"钱到了"的真相
mux := http.NewServeMux()
mux.Handle("/pay/notify", c.Middleware()(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
evt := jjpay.EventFrom(r.Context()) // 已验签、已解析
if evt.Event == jjpay.EventPaySucceeded {
// 幂等地开通服务:按 trade_no + event 去重,靠唯一索引裁决
}
fmt.Fprint(w, "SUCCESS") // 不回这个就会被重试
})))
}
Output:
Index ¶
- Constants
- Variables
- func Middleware(secret string) func(http.Handler) http.Handler
- func MiddlewareWithOptions(opts NotifyOptions) func(http.Handler) http.Handler
- func SignNotify(secret string, event EventType, body []byte, at time.Time) (http.Header, error)
- func WithEvent(ctx context.Context, evt *Event) context.Context
- type APIError
- type Channel
- type Client
- func (c *Client) AppID() string
- func (c *Client) CloseOrder(ctx context.Context, tradeNo string) (*CloseOrderResp, error)
- func (c *Client) CreateOrder(ctx context.Context, req CreateOrderReq) (*CreateOrderResp, error)
- func (c *Client) Middleware() func(http.Handler) http.Handler
- func (c *Client) MiddlewareWithOptions(opts NotifyOptions) func(http.Handler) http.Handler
- func (c *Client) QueryOrder(ctx context.Context, tradeNo string) (*Order, error)
- func (c *Client) QueryOrderByOutTradeNo(ctx context.Context, outTradeNo string) (*Order, error)
- func (c *Client) QueryRefund(ctx context.Context, refundNo string) (*Refund, error)
- func (c *Client) QueryRefundByOutRefundNo(ctx context.Context, outRefundNo string) (*Refund, error)
- func (c *Client) Refund(ctx context.Context, req RefundReq) (*Refund, error)
- func (c *Client) Verify(header http.Header, body []byte) (*Event, error)
- func (c *Client) VerifyReturn(query url.Values) (*ReturnResult, error)
- type CloseOrderResp
- type Code
- type Config
- type CreateOrderReq
- type CreateOrderResp
- type Event
- type EventType
- type HTTPError
- type Item
- type Logger
- type Method
- type NotifyOptions
- type Order
- type OrderStatus
- type Refund
- type RefundReq
- type RefundStatus
- type ReturnOptions
- type ReturnResult
Examples ¶
Constants ¶
const ( // DefaultTimeout 单次 HTTP 调用的超时。 DefaultTimeout = 15 * time.Second // DefaultSkewWindow 响应/通知时间戳的允许偏差,与服务端 // openapi_timestamp_skew_seconds 的默认值一致。 DefaultSkewWindow = 5 * time.Minute // DefaultReadRetries 读操作(查单/查退款)的额外重试次数。 // 写操作永不重试,理由见 CreateOrder 的注释。 DefaultReadRetries = 2 // MaxReadRetries 额外重试次数的上限。传更大的值会被压到这里。 // // 退避是 200ms 左移,i 一大 time.Duration 就溢出成负数, // 结果是不等待、瞬间把剩下的次数烧完,极端值下甚至一次请求都发不出去却 // 返回成功。封顶比"相信没人会传 100"便宜。 MaxReadRetries = 5 )
默认值。
const ( EnvBaseURL = "JJPAY_BASE_URL" EnvAppID = "JJPAY_APP_ID" EnvSecret = "JJPAY_APP_SECRET" //nolint:gosec // 这是环境变量名,不是密钥 )
环境变量名。三项都是 Config 对应字段留空时的回退来源,见 New。
const ( HeaderAppID = "X-Jjpay-Appid" HeaderTimestamp = "X-Jjpay-Timestamp" HeaderNonce = "X-Jjpay-Nonce" HeaderSignature = "X-Jjpay-Signature" HeaderEvent = "X-Jjpay-Event" )
四个签名头。
HTTP 头名大小写不敏感,Go 的 http.Header 会把它们规范成 `X-Jjpay-Appid` 这样的 MIME 规范形式再存取——两端都用 Header.Get/Set 即可,不必纠结字面大小写。
Variables ¶
var ( ErrParamInvalid = sentinel(CodeParamErr) ErrServerBusy = sentinel(CodeServerBusy) ErrNotFound = sentinel(CodeNotFound) ErrAppNotFound = sentinel(CodeAppNotFound) ErrSignMismatch = sentinel(CodeSignMismatch) ErrTimestampSkew = sentinel(CodeTimestampSkew) ErrNonceReplay = sentinel(CodeNonceReplay) ErrAppDisabled = sentinel(CodeAppDisabled) ErrRateLimited = sentinel(CodeRateLimited) // ErrOrderConflict 同一 out_trade_no 重复下单、但参数与已有单不一致 // (尤其金额)。此时服务端绝不改动已有单。 // 参数一致的重复下单不是错误,会直接返回已有单,见 CreateOrder 的注释。 ErrOrderConflict = sentinel(CodeOrderConflict) ErrOrderNotFound = sentinel(CodeOrderNotFound) ErrOrderExpired = sentinel(CodeOrderExpired) ErrOrderStatusDenied = sentinel(CodeOrderStatusDenied) ErrTokenInvalid = sentinel(CodeTokenInvalid) ErrMethodDisabled = sentinel(CodeMethodDisabled) ErrNoChannelAccount = sentinel(CodeNoChannelAccount) ErrChannelPrepayFail = sentinel(CodeChannelPrepayFail) ErrOpenIDRequired = sentinel(CodeOpenIDRequired) ErrRefundOrderNotPaid = sentinel(CodeRefundOrderNotPaid) ErrRefundExceedTotal = sentinel(CodeRefundExceedTotal) ErrRefundNoConflict = sentinel(CodeRefundNoConflict) ErrRefundChannelFail = sentinel(CodeRefundChannelFail) ErrRefundNotFound = sentinel(CodeRefundNotFound) )
业务错误哨兵。用 errors.Is 判别。
var ( // ErrResponseSign 响应验签失败:signature 对不上、或服务端根本没签。 // 命中它说明回执不可信,不要拿 data 当真。 ErrResponseSign = errors.New("jjpay: 响应验签失败") // ErrNotifySign 通知验签失败。 ErrNotifySign = errors.New("jjpay: 通知验签失败") // ErrReturnSign 回跳参数验签失败:不是 jjpay 送回来的,或被改过。当作没带结果,去查单 ErrReturnSign = errors.New("jjpay: 回跳参数验签失败") // ErrReturnMissing 回跳地址上没有 jjpay 的结果参数(商户没经收银台回跳、或链接被裁过) ErrReturnMissing = errors.New("jjpay: 缺少回跳结果参数") // ErrTimestampWindow 时间戳超出允许窗口(默认 ±5 分钟)。 // 只验签不验时间戳挡不住重放——签名是真的,只是被人录下来重放了。 ErrTimestampWindow = errors.New("jjpay: 时间戳超出允许窗口") // ErrMissingHeader 缺少必需的签名头。 ErrMissingHeader = errors.New("jjpay: 缺少签名头") // ErrBadResponse 响应不是合法的 {code,msg,data} 信封。 ErrBadResponse = errors.New("jjpay: 响应格式非法") )
传输层与验签错误哨兵(不是业务码)。
Functions ¶
func Middleware ¶
Middleware 返回标准库风格的验签中间件:
mux.Handle("/pay/notify", jjpay.Middleware(secret)(myHandler))
验签通过则把事件放进 request context(用 EventFrom 取)、把 body 回填给下游 再调用它;验签失败直接 401,下游一次都不会被调用。
下游必须回 HTTP 200 且 body 为 SUCCESS(或 JSON {"code":"SUCCESS"}), 否则会被重试。幂等仍要自己做。
secret 为空会 panic:放行的话每一条真实通知都会被 401,而调用方拿不到任何 线索。有 Client 就用 (*Client).Middleware,可以完全避开这个口子。
func MiddlewareWithOptions ¶
func MiddlewareWithOptions(opts NotifyOptions) func(http.Handler) http.Handler
MiddlewareWithOptions 同 Middleware,可调窗口、时钟与错误回调。 opts.Secret 为空同样 panic,理由见 Middleware。
func SignNotify ¶
SignNotify 造出一条通知该带的签名头,专门给商户写自己的通知处理测试用。
h, _ := jjpay.SignNotify(secret, jjpay.EventPaySucceeded, body, time.Now())
req := httptest.NewRequest("POST", "/pay/notify", bytes.NewReader(body))
req.Header = h
myNotifyHandler.ServeHTTP(rec, req) // 走真实验签路径
body 必须是最终发出去的那串字节:签的是它的哈希,序列化一次签一次。
Example ¶
商户给自己的通知处理写测试:用 SignNotify 造一条真能通过验签的请求。
package main
import (
"fmt"
"log"
"time"
jjpay "github.com/Star-Flex/jjpay-go"
)
func main() {
const secret = "sk_test_0123456789abcdef"
body := []byte(`{"event":"pay.succeeded","trade_no":"P2026","out_trade_no":"A1","total_minor":1990}`)
h, err := jjpay.SignNotify(secret, jjpay.EventPaySucceeded, body, time.Now())
if err != nil {
log.Fatal(err)
}
// 这一步走的是线上同一条验签路径,不是把校验绕过去。
evt, err := jjpay.Verify(h, body, secret)
if err != nil {
log.Fatal(err)
}
fmt.Println(evt.Event, evt.OutTradeNo, evt.TotalMinor)
}
Output: pay.succeeded A1 1990
Types ¶
type APIError ¶
type APIError struct {
Code Code // 业务码
Msg string // 服务端文案
Data json.RawMessage // 出错时的字段级说明(validator 中文翻译),可能为空
}
APIError 是服务端返回的业务错误(code != 10000)。
判别一律用 errors.Is 配下面的哨兵,别去比 Msg——Msg 是给人看的,随时会 被改文案:
if errors.Is(err, jjpay.ErrOrderConflict) { … }
本包没登记过的新错误码同样会被包成 *APIError,不会被吞成"成功"。
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client 是并发安全的,一个 App 建一个复用即可。
func New ¶
New 构造 Client。配置不合法立刻返回错误——把"密钥忘了配"这类问题留到 第一次收款时才炸,代价太大。
BaseURL / AppID / Secret 留空时依次回退到 EnvBaseURL / EnvAppID / EnvSecret; 两处都没有才报错,错误里会把环境变量名一并写出来。
func NewFromEnv ¶
NewFromEnv 三项全从环境变量取,等价于 New(Config{})。 容器 / systemd 里最常见的形态:地址与密钥都在部署侧,代码里一个字都没有。
func (*Client) CloseOrder ¶
CloseOrder 关闭支付单。
- 已支付的单不能关,返回 ErrOrderStatusDenied——要退钱请用 Refund;
- 已关闭的单再关是幂等成功。
写操作,不自动重试(同 CreateOrder:重试请调用方自己发起,关单本身幂等)。
func (*Client) CreateOrder ¶
func (c *Client) CreateOrder(ctx context.Context, req CreateOrderReq) (*CreateOrderResp, error)
CreateOrder 创建支付单,返回收银台链接。
幂等 ¶
幂等键是 (app_id, out_trade_no)。同一 OutTradeNo 重复调用:
- 参数与已有单一致(判定字段:TotalMinor + Subject + NotifyURL)→ 返回已有的那一单,含同一个 CheckoutURL,不是错误;
- 参数不一致(尤其金额不同)→ 返回 ErrOrderConflict, 且服务端绝不改动已有单。
不重试 ¶
写操作永不自动重试:SDK 分不清"没建成单"和"建成了但回执丢了",自作主张 重试会让已经成功的请求看起来像失败。要重试请用同一个 OutTradeNo 再调一次, 那是幂等的。
Example ¶
下单失败时怎么分辨该重试还是该查代码。
package main
import (
"context"
"errors"
"fmt"
"log"
jjpay "github.com/Star-Flex/jjpay-go"
)
func main() {
c, _ := jjpay.NewFromEnv()
resp, err := c.CreateOrder(context.Background(), jjpay.CreateOrderReq{
OutTradeNo: "A2026080200123",
Subject: "基础版 包月",
TotalMinor: 1990,
})
switch {
case err == nil:
fmt.Println(resp.TradeNo, resp.CheckoutURL)
case errors.Is(err, jjpay.ErrOrderConflict):
// 同一个单号之前用别的金额下过单:这是业务 bug,重试多少次都一样
log.Fatal(err)
default:
// 网络 / 超时 / 5xx:用同一个OutTradeNo 再调一次,那是幂等的。
// 不要换单号重下——SDK 分不清"没建成单"和"建成了但回执丢了",
// 换单号会把一次成功的下单变成两笔应付。
log.Println("稍后重试:", err)
}
}
Output:
func (*Client) Middleware ¶
Middleware 用本 Client 的凭据返回验签中间件,等价于 Middleware(secret)。
mux.Handle("/pay/notify", c.Middleware()(myHandler))
func (*Client) MiddlewareWithOptions ¶
MiddlewareWithOptions 同 Middleware,可再给 OnError 等选项。 opts.Secret 与 opts.SkewWindow 若留空,用 Client 上的值。
func (*Client) QueryOrder ¶
QueryOrder 按 jjpay 单号查单。
服务端可能在此惰性回源向渠道查一次,所以这个调用比纯读 库慢,别拿它当轮询高频打。付款结果的主路径是异步通知,查单是补偿。
读操作,允许有限重试(网络错误与 5xx/429)。
func (*Client) QueryOrderByOutTradeNo ¶
QueryOrderByOutTradeNo 按商户单号查单,走 ?out_trade_no= 形态。
读操作,允许有限重试。
func (*Client) QueryRefund ¶
QueryRefund 按 jjpay 退款单号查退款。读操作,允许有限重试。
func (*Client) QueryRefundByOutRefundNo ¶
QueryRefundByOutRefundNo 按商户退款单号查退款。读操作,允许有限重试。
func (*Client) Refund ¶
Refund 发起退款。
同步返回可能已经是终态,按 Status 判,别假定 ¶
Status == RefundSucceeded 就是真退成功了(支付宝的退款是同步接口, 通常走这条),可以当场记账。Status == RefundProcessing 才是"jjpay 收下了、 结果未定"——微信原路退回银行卡要 T+1~3 天,通常是这一档;真实结果经 refund.succeeded / refund.failed 异步通知,或由 QueryRefund 查。
不要凭"调用没报错"就记账退款成功——那和 Status 是两回事。
无论同步返回哪一档,终态都会再推一条通知(同步已成功也推,防的是 响应写回途中断连)。按 RefundNo 幂等即可,别把它当成第二笔退款。
幂等 ¶
幂等键是 (app_id, out_refund_no)。同一 OutRefundNo 重复调用返回同一张 退款单,不会重复退钱;单号相同但金额等参数不一致返回 ErrRefundNoConflict。
不重试 ¶
同 CreateOrder:写操作永不自动重试。超时了用同一个 OutRefundNo 再调一次。
func (*Client) VerifyReturn ¶
func (c *Client) VerifyReturn(query url.Values) (*ReturnResult, error)
VerifyReturn 用本 Client 的凭据验回跳结果,等价于 VerifyReturn(query, secret)。
Example ¶
回跳页:只准展示,不准发货。
package main
import (
"fmt"
"net/http"
jjpay "github.com/Star-Flex/jjpay-go"
)
func main() {
c, _ := jjpay.NewFromEnv()
handler := func(w http.ResponseWriter, r *http.Request) {
res, err := c.VerifyReturn(r.URL.Query())
if err != nil {
// 不是收银台送回来的、被改过、或链接太旧。
// err != nil 时 res 是 nil,往下走就是空指针。
// 正确做法是当作"没带结果",拿 trade_no 去查单。
http.Redirect(w, r, "/orders?checking=1", http.StatusFound)
return
}
if res.Paid() {
fmt.Fprintf(w, "支付成功 ¥%d.%02d", res.TotalMinor/100, res.TotalMinor%100)
return
}
fmt.Fprint(w, "正在确认…")
}
_ = handler
}
Output:
type CloseOrderResp ¶
type CloseOrderResp struct {
TradeNo string `json:"trade_no"`
Status OrderStatus `json:"status"`
}
CloseOrderResp 关单响应。
type Code ¶
type Code int64
Code 是响应信封里的业务码。
HTTP 状态码恒为 200,业务结果只看这个 code。所以 SDK 里凡是判断成败 一律看 Code,不要去看 HTTP 状态——非 200 只可能是网关/代理出的岔子, 那种情况 SDK 返回 *HTTPError。
const ( // 通用段 CodeSuccess Code = 10000 CodeParamErr Code = 10001 CodeServerBusy Code = 10002 CodeNotFound Code = 10003 // 未在公开错误码表中列出 // 商户鉴权段 CodeAppNotFound Code = 20001 CodeSignMismatch Code = 20002 CodeTimestampSkew Code = 20003 CodeNonceReplay Code = 20004 CodeAppDisabled Code = 20005 CodeRateLimited Code = 20006 // 未在公开错误码表中列出 // 支付单段 CodeOrderConflict Code = 30001 CodeOrderNotFound Code = 30002 CodeOrderExpired Code = 30003 CodeOrderStatusDenied Code = 30004 CodeTokenInvalid Code = 30005 // 渠道下单段 CodeMethodDisabled Code = 40001 CodeNoChannelAccount Code = 40002 CodeChannelPrepayFail Code = 40003 CodeOpenIDRequired Code = 40004 // 退款段 CodeRefundOrderNotPaid Code = 60001 CodeRefundExceedTotal Code = 60002 CodeRefundNoConflict Code = 60003 CodeRefundChannelFail Code = 60004 CodeRefundNotFound Code = 60005 // 未在公开错误码表中列出 )
type Config ¶
type Config struct {
// BaseURL 网关根地址,如 https://pay.example.com/api。允许带路径前缀,
// 「前缀只用来拼地址,不进签名」:网关按前缀分流、剥掉它才回源,
// 待签的 PATH 是网关背后那个应用看到的那一段(`/openapi/v1/...`)。
// 留空则取 JJPAY_BASE_URL。
//
// 本包不带内置默认值:地址属于部署,不属于代码。
BaseURL string
// AppID 即 app.code,作为 X-Jjpay-Appid 发出。留空则取 JJPAY_APP_ID。
AppID string
// Secret app_secret。只从环境变量/密钥管理取,别写进源码或配置文件。
// 留空则取 JJPAY_APP_SECRET。
Secret string
// HTTPClient 可选。想复用连接池、加代理或自定义 TLS 时传自己的。
// 不传则用带 Timeout 的默认客户端。
HTTPClient *http.Client
// Timeout 单次 HTTP 调用的上限(重试的每一次各算一次),默认 15s。
Timeout time.Duration
// SkewWindow 响应时间戳允许的偏差,默认 ±5 分钟。
// 服务器时钟没校准时会大面积验签失败——那正是要暴露的问题,别调大绕过。
SkewWindow time.Duration
// ReadRetries 读操作的额外重试次数,0 取默认值 2(即最多请求 3 次),
// 负数表示关掉重试,超过 MaxReadRetries 按上限算。
ReadRetries int
// Logger 可选。传了之后,本包在重试前会打一行——那是唯一会被静默吞掉的
// 事件:一次成功的调用背后可能藏着两次失败,不打就查不出来。
//
// *log.Logger 直接满足这个接口。本包不会打印密钥、签名或完整响应体。
// 不传则什么都不打;本包没有包级 logger,也不会写 stdout/stderr。
Logger Logger
}
Config 构造 Client 的参数。BaseURL / AppID / Secret 三项必须有值, 但不一定要写在代码里:留空时各自回退到 EnvBaseURL / EnvAppID / EnvSecret。
type CreateOrderReq ¶
type CreateOrderReq struct {
// OutTradeNo 商户单号,商户内唯一。它同时是幂等键,见 CreateOrder。
OutTradeNo string `json:"out_trade_no"`
// Subject 订单标题,≤128。
Subject string `json:"subject"`
// TotalMinor 应付金额,币种最小单位(CNY 即分),必须 > 0。jjpay 收到的
// 就是最终应付额,优惠券/折扣/差价一律在商户侧算完再传。
TotalMinor int64 `json:"total_minor"`
// Currency ISO 4217 币种码,留空即 CNY。目前渠道只收 CNY,传其它码会返回 10001。
Currency string `json:"currency,omitempty"`
Description string `json:"description,omitempty"` // ≤256
Items []Item `json:"items,omitempty"`
// OriginalMinor 原价,DiscountLabel 是收银台账单上优惠那一行的名称
// (≤16,留空显示「优惠」)。
//
// 优惠额 = OriginalMinor − TotalMinor,由服务端算。传两个价而不是
// "价 + 折扣额",是因为两个价都是商户系统里现成的事实,差额是推导值 ——
// 让调用方自己减一遍,就多一次算错的机会。
// 0 或等于 TotalMinor 都表示这一单没有优惠,账单只画明细行。
//
// 两者只是展示,TotalMinor 仍是唯一要收的钱:下单给渠道的是它,
// 查单比对的是它,可退上限也是它 —— 退款永远按实付退,与原价无关。
//
// 给了 OriginalMinor 就必须 ≥ TotalMinor,否则返回 10001。带 Items 时
// 明细是按原价列的,Σ(UnitMinor×Qty) 要等于 OriginalMinor(没给原价时
// 等于 TotalMinor)。不带 Items 只给 OriginalMinor 也可以,
// 账单画成「原价 / 优惠 / 合计」三行。
OriginalMinor int64 `json:"original_minor,omitempty"`
DiscountLabel string `json:"discount_label,omitempty"`
// Attach 商户透传字段,≤128,原样出现在异步通知里。
Attach string `json:"attach,omitempty"`
// ExpireMinutes 有效期,默认取服务端配置,上限 120。
ExpireMinutes int `json:"expire_minutes,omitempty"`
// NotifyURL 覆盖后台配置的回调地址。下单时会快照进订单,之后改后台配置
// 不影响在途老单。
NotifyURL string `json:"notify_url,omitempty"`
// ReturnURL 用户支付完的回跳地址。回跳不代表支付成功,落地页必须
// 自己查单确认。
ReturnURL string `json:"return_url,omitempty"`
}
CreateOrderReq 下单请求。
type CreateOrderResp ¶
type CreateOrderResp struct {
TradeNo string `json:"trade_no"`
OutTradeNo string `json:"out_trade_no"`
// CheckoutURL 收银台链接,直接给用户跳。
CheckoutURL string `json:"checkout_url"`
ExpireAt time.Time `json:"expire_at"`
Status OrderStatus `json:"status"`
}
CreateOrderResp 下单响应。
type Event ¶
type Event struct {
Event EventType `json:"event"`
// —— 支付类(pay.succeeded / order.closed)——
TradeNo string `json:"trade_no,omitempty"`
OutTradeNo string `json:"out_trade_no,omitempty"`
TotalMinor int64 `json:"total_minor,omitempty"`
Currency string `json:"currency,omitempty"`
Channel Channel `json:"channel,omitempty"`
Method Method `json:"method,omitempty"`
ChannelTradeNo string `json:"channel_trade_no,omitempty"`
// PaidAt / ClosedAt 按事件类型二选一:pay.succeeded 给前者,
// order.closed 给后者。omitempty 对 struct 类型不生效,所以这两个
// 字段在没值时会原样序列化成 0001-01-01T00:00:00Z——用 IsZero() 判,别判 nil。
PaidAt time.Time `json:"paid_at"`
ClosedAt time.Time `json:"closed_at"`
Attach string `json:"attach,omitempty"`
// —— 退款类(refund.succeeded / refund.failed / refund.stalled)——
RefundNo string `json:"refund_no,omitempty"`
OutRefundNo string `json:"out_refund_no,omitempty"`
// AmountMinor 本次退款金额;RefundedMinor 该支付单累计已退。
AmountMinor int64 `json:"amount_minor,omitempty"`
RefundedMinor int64 `json:"refunded_minor,omitempty"`
// FinishedAt 只在终态事件里有值。refund.stalled 给的是 StalledAt——
// 那笔退款还没结束,给它一个"完成时刻"会让人以为这就是结果。
FinishedAt time.Time `json:"finished_at"`
FailReason string `json:"fail_reason,omitempty"`
// StalledAt / StalledReason 只在 refund.stalled 里有值,见 EventRefundStalled。
// StalledReason 是渠道原话,可以直接给客服看。
StalledAt time.Time `json:"stalled_at"`
StalledReason string `json:"stalled_reason,omitempty"`
// Raw 是验签通过的原始 body。字段不够用时自己解,别再去读 r.Body。
Raw []byte `json:"-"`
// Timestamp / Nonce 来自请求头,已参与签名。
Timestamp time.Time `json:"-"`
Nonce string `json:"-"`
}
Event 是一条已验签的异步通知。
所有事件共用一个结构体:字段按事件类型部分填充,用不到的留零值。判断先看 Event 字段再取相应字段,别对着零值猜。
func Verify ¶
Verify 验证一条异步通知并解析出事件(默认 ±5 分钟时间戳窗口)。
框架无关:把请求头与原始 body 字节交进来即可,gin / echo / chi 都能 两行接上(见 README)。
校验顺序:头齐全 → 时间戳在窗口内 → 签名一致(hmac.Equal 常数时间)。 任一失败返回错误,此时不要处理这条通知。
待签串:{EVENT}\n{TIMESTAMP}\n{NONCE}\n{SHA256_HEX(BODY)}。
func VerifyWithOptions ¶
VerifyWithOptions 同 Verify,可调时间戳窗口与时钟。
type EventType ¶
type EventType string
EventType 异步通知的事件名。
const ( EventPaySucceeded EventType = "pay.succeeded" // 支付单转为 paid EventOrderClosed EventType = "order.closed" // 支付单转为 closed(超时或主动关) EventRefundSucceeded EventType = "refund.succeeded" // 退款成功 EventRefundFailed EventType = "refund.failed" // 退款失败 // EventRefundStalled 退款卡在渠道侧、需要人工介入。 // // 【它不是终态】钱已经离开商户账户、还没落到用户手里,退款单仍是"处理中"。 // 收到它之后一定还会收到一条 refund.succeeded 或 refund.failed。 // // 目前只有微信会出现:退往用户原路时银行拒收(卡作废或冻结),钱停在微信侧, // 要由网关侧的人去渠道后台决定去向。支付宝不会——它退卡失败会自动退到 // 用户的支付宝余额。 // // 收到它该做的事:别再等这笔退款自己好(可能要几小时到几天), // 把单号记下来告诉自己的客服。切勿据此判定退款失败去做二次补偿—— // 那笔钱后面仍可能到用户手里。 EventRefundStalled EventType = "refund.stalled" )
type HTTPError ¶
HTTPError 非 200 的 HTTP 响应。
正常情况下 jjpay 的业务响应恒为 200,所以拿到它基本意味着请求没走到 应用层(网关 502、限流 429、路径写错 404 之类)。
type Item ¶
type Item struct {
Name string `json:"name"`
UnitMinor int64 `json:"unit_minor"` // 单价,币种最小单位
Qty int `json:"qty"`
}
Item 收银台展示用的订单明细。
行小计 = UnitMinor × Qty。Qty 省略或 ≤0 一律当 1 —— 只想说"这一行多少钱" 而不想拆单价时,给 UnitMinor 一个数就够了。
type Method ¶
type Method string
Method 支付方式,含义依渠道而定:
alipay → page(电脑网站支付)| wap(手机网站支付) wechat → native(扫码)| h5 | jsapi(微信内) mock → pay
type NotifyOptions ¶
type NotifyOptions struct {
// Secret 该 App 的 app_secret,必填。
Secret string
// SkewWindow 允许的时间戳偏差,默认 ±5 分钟。
//
// 不要把它调得很大来"省事":签名是真的、只是被录下来重放时,时间戳
// 窗口是唯一挡得住的那道闸。
SkewWindow time.Duration
// Now 取当前时间,测试注入用;nil 即 time.Now。
Now func() time.Time
// OnError 中间件验签失败时的回调,用于打日志。nil 则静默。
// 强烈建议接上——否则回调端点静默回 401,排查时毫无线索。
OnError func(r *http.Request, err error)
}
NotifyOptions 验签的可调项。除测试外一般只需要 Secret。
type Order ¶
type Order struct {
TradeNo string `json:"trade_no"`
OutTradeNo string `json:"out_trade_no"`
Subject string `json:"subject"`
// TotalMinor 应付金额;RefundedMinor 已退金额。单位都是 Currency 的最小单位。
// 退款不占用 Status:0=未退,0<x<TotalMinor=部分退,==TotalMinor=全退。
TotalMinor int64 `json:"total_minor"`
RefundedMinor int64 `json:"refunded_minor"`
Currency string `json:"currency"`
Status OrderStatus `json:"status"`
StatusText string `json:"status_text"`
Channel Channel `json:"channel"`
Method Method `json:"method"`
// ChannelTradeNo 渠道侧单号(微信 transaction_id / 支付宝 trade_no)。
ChannelTradeNo string `json:"channel_trade_no"`
PaidAt time.Time `json:"paid_at"`
ExpireAt time.Time `json:"expire_at"`
ClosedAt time.Time `json:"closed_at"`
Attach string `json:"attach"`
CreatedAt time.Time `json:"created_at"`
}
Order 查单响应。
可空的时间字段(PaidAt/ClosedAt)在服务端为 null 时是零值,用 IsZero() 判断,不要判 nil。
type OrderStatus ¶
type OrderStatus string
OrderStatus 支付单状态,对外是字符串。
const ( OrderPending OrderStatus = "pending" // 已建单,用户还没选支付方式 OrderPaying OrderStatus = "paying" // 已向渠道下单,凭据已发给用户 OrderPaid OrderStatus = "paid" // 已支付。终态 OrderClosed OrderStatus = "closed" // 已关闭(超时/主动关)。终态 )
type Refund ¶
type Refund struct {
RefundNo string `json:"refund_no"`
OutRefundNo string `json:"out_refund_no"`
TradeNo string `json:"trade_no"`
OutTradeNo string `json:"out_trade_no,omitempty"`
// AmountMinor 本退款单的金额(分)。
AmountMinor int64 `json:"amount_minor"`
// TotalMinor 原支付单的应付总额;RefundedMinor 该支付单累计已退。
// 两者都来自原单,拿它们就能当场判断还能退多少(TotalMinor-RefundedMinor),
// 不必为了对账再查一次单。
TotalMinor int64 `json:"total_minor"`
RefundedMinor int64 `json:"refunded_minor"`
Currency string `json:"currency"`
Status RefundStatus `json:"status"`
StatusText string `json:"status_text"`
CreatedAt time.Time `json:"created_at"`
ChannelRefundNo string `json:"channel_refund_no,omitempty"`
// FinishedAt 终态时间,未终结时是零值——用 IsZero() 判,别判 nil。
FinishedAt time.Time `json:"finished_at"`
FailReason string `json:"fail_reason,omitempty"`
}
Refund 退款单。
查退款比下退款多返回 ChannelRefundNo / FinishedAt / FailReason,同步下单 时这三个字段为空。
type RefundReq ¶
type RefundReq struct {
TradeNo string `json:"trade_no,omitempty"`
OutTradeNo string `json:"out_trade_no,omitempty"`
// OutRefundNo 商户退款单号,商户内唯一,同时是幂等键。
OutRefundNo string `json:"out_refund_no"`
// AmountMinor 本次退款金额(分)。部分退就填部分金额;可多次退,
// 服务端保证累计不超原单(超了返回 ErrRefundExceedTotal)。
AmountMinor int64 `json:"amount_minor"`
Reason string `json:"reason,omitempty"`
NotifyURL string `json:"notify_url,omitempty"`
}
RefundReq 退款请求。
TradeNo 与 OutTradeNo 二选一指明要退哪一单。
type RefundStatus ¶
type RefundStatus int16
RefundStatus 退款单状态。
const ( RefundProcessing RefundStatus = 1 // 处理中。结果未定,等通知或查单 RefundSucceeded RefundStatus = 2 RefundFailed RefundStatus = 3 )
type ReturnOptions ¶
type ReturnOptions struct {
// Secret 该 App 的 app_secret,必填。
Secret string
// SkewWindow 允许的时间戳偏差,默认 ±5 分钟。回跳链接可能被用户收藏、转发,
// 时间窗是唯一挡得住"拿旧链接当凭证"的那道闸;要严格一次性可再记 Nonce。
SkewWindow time.Duration
// Now 取当前时间,测试注入用;nil 即 time.Now。
Now func() time.Time
}
ReturnOptions VerifyReturnWithOptions 的选项。
type ReturnResult ¶
type ReturnResult struct {
TradeNo string
OutTradeNo string
// Status pending / paying / paid / closed
Status OrderStatus
TotalMinor int64
Currency string
// PaidAt 已支付时有,否则零值
PaidAt time.Time
Timestamp time.Time
Nonce string
}
ReturnResult 收银台把用户送回你的 return_url 时附带的、签名过的订单结果。
这是 jjpay 查单确认后签的,验签通过说明确实是 jjpay 给的、 没被改过,可以直接画支付成功 ¥19.90而不必再查一次单。但回跳和异步通知是两条 独立链路:用户付完关掉浏览器,回跳就没有了。开通服务 / 发货仍以通知或查单为准。
func VerifyReturn ¶
func VerifyReturn(query url.Values, secret string) (*ReturnResult, error)
VerifyReturn 验证回跳地址上的结果参数(默认 ±5 分钟时间戳窗口)。
res, err := jjpay.VerifyReturn(r.URL.Query(), secret)
if err != nil { /* 当作没带结果:查单 */ }
if res.Paid() { /* 画成功页 */ }
校验顺序:参数齐全 → 时间戳在窗口内 → 签名一致(hmac.Equal 常数时间)。 任一失败返回错误,此时不要用地址上的任何字段。
func VerifyReturnWithOptions ¶
func VerifyReturnWithOptions(query url.Values, opts ReturnOptions) (*ReturnResult, error)
VerifyReturnWithOptions 带选项的 VerifyReturn。