jjpay

package module
v0.1.6 Latest Latest
Warning

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

Go to latest
Published: Sep 19, 2026 License: Apache-2.0 Imports: 18 Imported by: 0

README

jjpay Go SDK

test lint codeql OpenSSF Scorecard Go Reference License

jjpay 支付网关的 Go 客户端。只做四件事:签名、验签、超时与有限重试、请求响应结构体。 只依赖标准库——不引 gin、不引 gorm、不引任何日志库。

下面这份 README 就是接入所需的全部。

本仓库由上游自动同步,PR 已关闭;issue 照常,见 CONTRIBUTING。

go get github.com/Star-Flex/jjpay-go
import "github.com/Star-Flex/jjpay-go"   // 包名是 jjpay

一、三步接入

第 1 步 · 建客户端(进程启动时建一个,复用)

c, err := jjpay.NewFromEnv()
if err != nil {
    log.Fatal(err) // 配置不合法当场炸,别留到第一次收款
}

读三个环境变量:

JJPAY_BASE_URL jjpay 的根地址,如 https://pay.example.com/api。管理员给的路径前缀要连着填:网关按它分流,省掉就到不了后端。前缀不参与签名
JJPAY_APP_ID 后台创建接入方时生成,不是秘密
JJPAY_APP_SECRET 只显示一次,丢了只能重置

要在代码里传也行,显式的赢:

c, err := jjpay.New(jjpay.Config{
    BaseURL: "https://pay.example.com/api", // 留空则回退到 JJPAY_BASE_URL
    AppID:   "app_k7m2qx9b4t",
    Secret:  os.Getenv("MY_OWN_SECRET_NAME"),
})

本包不带内置默认域名。地址属于部署,不属于代码——换域名、在测试和生产之间切, 都只该改一个环境变量,而不是升级依赖重新编译。

Client 并发安全。可选项:HTTPClient(自带连接池 / 代理)、Timeout(默认 15s)、 SkewWindow(默认 ±5 分钟)、ReadRetries(默认 2,负数关闭)、Logger。

Logger 只有一个方法 Printf(string, ...any),*log.Logger 直接满足。传了之后 本包会在每次重试前打一行——那是唯一会被静默吞掉的事件,一次成功的调用背后 可能藏着两次失败。不传就什么都不打;本包没有包级 logger,也不会写 stdout / stderr。

第 2 步 · 下单,把 CheckoutURL 给用户

resp, err := c.CreateOrder(ctx, jjpay.CreateOrderReq{
    OutTradeNo: "A2026080200123",           // 你自己的单号,商户内唯一
    Subject:    "基础版 包月",
    TotalMinor: 1990,                       // 分。jjpay 收到的就是最终应付额
    Attach:     "plan_id=7",                // 原样回传给你
    NotifyURL:  "https://your-app.example/pay/notify",
    ReturnURL:  "https://your-app.example/orders/123",
})
if err != nil { return err }
// 302 到 resp.CheckoutURL,或把它渲染成按钮

想让收银台上显示商品明细与折扣,再加三个可选字段(都不传就只显示标题和一个金额):

jjpay.CreateOrderReq{
    TotalMinor:    1490,          // 实际要收的钱
    OriginalMinor: 1990,          // 原价。差额 500 由 jjpay 算,你不用传
    DiscountLabel: "年付立减",     // 那一行叫什么,留空显示「优惠」
    Items: []jjpay.Item{          // 明细按原价列,Qty 省略当 1
        {Name: "基础版托管", UnitMinor: 1590, Qty: 1},
        {Name: "快照备份", UnitMinor: 200, Qty: 2},
    },
    // …其余同上
}

账单得算得平:Σ(UnitMinor × Qty) 要等于 OriginalMinor(没传原价时等于 TotalMinor),不平返回 10001 并把算式回给你。没打折就别传 OriginalMinor, 或者照实传一个等于 TotalMinor 的数——两者等价。

这几个字段只是画面:TotalMinor 始终是唯一要收的钱,退款也永远按它退。

第 3 步 · 收异步通知(这一步才是"钱到了"的真相)

mux := http.NewServeMux()

// 【把 OnError 接上】默认是 nil:验签失败只回一个 401,你这边什么都看不到。
// 而"商户端点一直 401"在 jjpay 那侧的表现是通知重试 10 次后进死信——
// 等你发现时钱已经收了一天了。
notify := c.MiddlewareWithOptions(jjpay.NotifyOptions{
    OnError: func(r *http.Request, err error) {
        log.Printf("jjpay 通知验签失败 path=%s ip=%s err=%v", r.URL.Path, r.RemoteAddr, err)
    },
})

mux.Handle("/pay/notify", notify(http.HandlerFunc(   // c 就是第 1 步那只 Client
    func(w http.ResponseWriter, r *http.Request) {
        evt := jjpay.EventFrom(r.Context()) // 已验签、已解析

        switch evt.Event {
        case jjpay.EventPaySucceeded:
            // 幂等地开通服务(见下文第三节)
        case jjpay.EventRefundSucceeded:
        case jjpay.EventRefundFailed:
        case jjpay.EventOrderClosed:
        }

        io.WriteString(w, "SUCCESS") // 必须回 200 + SUCCESS,否则 jjpay 会重试 10 次
    },
)))

应答规范:HTTP 200 且 body 为 SUCCESS(大小写不敏感、允许前后空白), 或 JSON {"code":"SUCCESS"}。其余一律视为失败并按阶梯重试 (0/15/60/300/900/1800/3600/10800/21600/43200 秒,共 10 次约 24 小时,之后进死信)。


第 3½ 步 · 回跳页(可选):签名过的结果直接画,不必查单

用户付完从收银台跳回你的 return_url 时,地址上带着 jjpay 签名过的结果:

res, err := c.VerifyReturn(r.URL.Query())
if err != nil {
    // 不是收银台送回来的、被改过、或链接太旧。
    // 【必须 return】err != nil 时 res 是 nil,往下走就是空指针。
    // 当作"没带结果"处理:拿 trade_no 去查单。
    http.Redirect(w, r, "/orders?checking=1", http.StatusFound)
    return
}
if res.Paid() {
    // 画「支付成功 ¥19.90」;res.TradeNo / OutTradeNo / TotalMinor / PaidAt
}

只准展示,不准发货。回跳和通知是两条独立链路——用户付完关掉浏览器就没有回跳; 反过来,旧链接被收藏 / 转发也不该再当凭证(SDK 默认只认 ±5 分钟内的,要严格一次性自己记 res.Nonce)。 开通服务永远在第 3 步里做。

二、gin 的两行胶水

主 SDK 刻意不提供 gin 中间件——那会把 gin 拖进每个接入方的依赖树。gin 用户自己包一下:

// pay 是第 1 步建好的 *jjpay.Client
func GinVerify(pay *jjpay.Client) gin.HandlerFunc {
    return func(c *gin.Context) {
        body, err := io.ReadAll(io.LimitReader(c.Request.Body, 256<<10))
        if err != nil {
            c.AbortWithStatus(http.StatusUnauthorized)
            return
        }
        evt, err := pay.Verify(c.Request.Header, body)
        if err != nil {
            log.Printf("jjpay 通知验签失败: %v", err) // 一定要打,否则线上 401 毫无线索
            c.AbortWithStatus(http.StatusUnauthorized)
            return
        }
        c.Request = c.Request.WithContext(jjpay.WithEvent(c.Request.Context(), evt))
        c.Next()
    }
}

r.POST("/pay/notify", GinVerify(pay), func(c *gin.Context) {
    evt := jjpay.EventFrom(c.Request.Context())
    // …幂等处理…
    c.String(200, "SUCCESS")
})

echo / chi / fiber 同理:拿到 http.Header 和原始 body 字节,调 (*Client).Verify,就这两件事。

别用自由函数版本的 Middleware(secret) / Verify(header, body, secret),除非你手上真的没有 *Client:那个版本要求你在 handler 这一侧再读一遍密钥,而读空的后果是每一条真实通知都被 401 ——jjpay 按阶梯重试 10 次约 24 小时后进死信,你这边什么日志都没有。 Middleware("") 会直接 panic 正是为了不让这件事发生在运行期。


三、商户侧要做的事

1. 验签(SDK 帮你做了,但你得真的接上)

不验签的回调端点 = 任何人都能给你发"已支付"。用 Middleware 或 Verify, 并且不要在验签之前读取/相信 body 里的任何字段。

Verify 除了签名还卡时间戳窗口(默认 ±5 分钟):签名本身不会过期, 截获一条合法通知无限重放的话,每次都验签通过——窗口是唯一挡得住重放的那道闸。 不要为了"省事"把窗口调很大;服务器时钟没校准才是要修的东西。

2. 幂等(SDK 帮不了你)

同一事件可能收到多次(投递重试、极端情况下的重复投递)。

先把去重键取对。 一个支付单只会成功一次,但可以退很多次:

事件 去重键
pay.succeeded / order.closed trade_no + event
refund.succeeded / refund.failed / refund.stalled refund_no + event

拿 trade_no + event 去给退款事件去重,同一支付单的第二笔退款会被当成重复投递 直接丢掉——那是一笔真实发生、你却没入账的退款。 (一张表接多个 App 的话,键里还要带上你自己的 App 标识。)

再把去重和业务放进同一个事务。

func handle(evt *jjpay.Event) error {
    key := evt.TradeNo            // 支付类
    if evt.RefundNo != "" {
        key = evt.RefundNo        // 退款类
    }

    tx, err := db.Begin()
    if err != nil {
        return err                // 别回 SUCCESS,让 jjpay 重投
    }
    defer tx.Rollback()

    res, err := tx.Exec(`INSERT INTO pay_event_seen(biz_no, event) VALUES(?,?)
                         ON CONFLICT DO NOTHING`, key, evt.Event)
    if err != nil {
        return err                // 【数据库出错 ≠ 重复】不能只看 RowsAffected
    }
    if n, err := res.RowsAffected(); err != nil {
        return err
    } else if n == 0 {
        return nil                // 真的处理过了 → 回 SUCCESS
    }

    if err := deliver(tx, evt); err != nil {   // 开通服务也在这个事务里
        return err
    }
    return tx.Commit()
}

两个点,少哪个都会丢业务:

  1. 不能"先写已处理、再去开通服务"。 中间失败的话,去重记录已经落下了, jjpay 重投的那几次全部被挡在门外,而服务一次都没开通——一笔收了钱没发货的单, 且没有任何告警。放进同一个事务,失败就一起回滚,下一次重投才救得回来。
  2. RowsAffected == 0 不等于"重复"。 数据库连接断了、约束冲突以外的错误, Exec 返回的是 err,不是 0 行。把 err 当成"处理过了"回一个 SUCCESS, 这条通知就再也不会来了。

开通服务要调外部系统(发短信、通知第三方)时,事务里只写一条待办, 由你自己的 outbox / 任务队列去投——别把网络调用放进数据库事务。

「先查一下有没有处理过,再处理」的两步式写法在并发下会重复发货——两个请求同时查到"没处理过"。 必须让数据库的唯一约束来裁决。

3. 核对回执——这一条 SDK 已经替你做了

CreateOrder / QueryOrder / QueryOrderByOutTradeNo / CloseOrder / Refund / QueryRefund 都会在返回前核对回执里的单号是不是本次请求的那一个, 对不上返回 ErrBadResponse。你不需要再写一遍。

应答签名证明的是"这段内容是 jjpay 发的",不证明"是回答哪一次请求的"—— 待签串里不含 method 与 path(微信支付 APIv3 的应答验签同样如此)。 比对回执里的单号就能识破,SDK 已经替你比了。

你仍然要做的是别拿 status 当发货依据的唯一来源:发货以异步通知或查单为准, 回跳页只展示。


四、API 一览

枚举:ChannelAlipay / ChannelWechat / ChannelMock、 MethodAlipayPage / MethodAlipayWap / MethodWechatNative / MethodWechatH5 / MethodWechatJSAPI、 OrderPending / OrderPaying / OrderPaid / OrderClosed。

只有 RefundStatus 是整数(RefundProcessing=1 / RefundSucceeded=2 / RefundFailed=3), 它是退款单自己的状态,与上面那三组对外枚举不同源。写 switch 时注意别当成字符串。

方法 接口 重试
CreateOrder(ctx, CreateOrderReq) (*CreateOrderResp, error) POST /openapi/v1/orders 否
QueryOrder(ctx, tradeNo) (*Order, error) GET /openapi/v1/orders/{trade_no} 是
QueryOrderByOutTradeNo(ctx, outTradeNo) (*Order, error) GET /openapi/v1/orders?out_trade_no= 是
CloseOrder(ctx, tradeNo) (*CloseOrderResp, error) POST /openapi/v1/orders/{trade_no}/close 否
Refund(ctx, RefundReq) (*Refund, error) POST /openapi/v1/refunds 否
QueryRefund(ctx, refundNo) (*Refund, error) GET /openapi/v1/refunds/{refund_no} 是
QueryRefundByOutRefundNo(ctx, outRefundNo) (*Refund, error) GET /openapi/v1/refunds?out_refund_no= 是
(*Client).Verify(header, body) (*Event, error) 通知验签 —
(*Client).Middleware() func(http.Handler) http.Handler 通知验签中间件 —
(*Client).VerifyReturn(query) (*ReturnResult, error) 回跳结果验签 —
SignNotify(secret, event, body, at) (http.Header, error) 造一条通知的签名头,给你写自己的回调测试用 —

签名原语(HMAC 计算、待签串拼接)不导出。需要自己造一条能通过验签的通知来测试 自己的 handler 时,用 SignNotify。

金额

一律 int64 币种最小单位(CNY 即分),字段名带 Minor 后缀,币种走 Currency(留空即 CNY)。本包不提供以元为单位的便捷参数—— 浮点的元是让人算错钱的邀请。要展示成元请在渲染层除 100。

写操作永不自动重试

CreateOrder / Refund / CloseOrder 超时或网络错误时,SDK 原样把错误抛给你, 一次都不重试。

因为 SDK 无法区分"没建成单"和"建成了但回执丢了"。自作主张重试会让其实已经成功的 请求看起来像失败。正确的重试姿势是你自己用同一个 OutTradeNo / OutRefundNo 再调一次—— 那是幂等的:要么拿回原单,要么拿到 ErrOrderConflict(说明这个单号被不同参数占了, 那是业务 bug,不是网络抖动)。

读操作(查单、查退款)会对下面这些做有限重试(默认 2 次,指数退避 200ms / 400ms):

  • 传输层失败(连不上、连接被断、超时)
  • HTTP 5xx 与 429
  • 业务码 10002(系统繁忙)与 20006(限流)——这两个必须单列, 因为 jjpay 的业务响应 HTTP 恒为 200,只认 429 的话服务端自己的限流一次都不会被重试

其余业务错误码、验签失败、context 取消一次都不重试——重试只是把同一个错误再犯一遍。

幂等语义

接口 幂等键 参数一致 参数不一致
CreateOrder (app_id, out_trade_no) 返回已有单(同一个 CheckoutURL),err == nil ErrOrderConflict,绝不改动已有单
Refund (app_id, out_refund_no) 返回同一张退款单,不重复退 ErrRefundNoConflict
CloseOrder 单号 已关闭再关 = 成功 已支付的单 → ErrOrderStatusDenied(要退钱用 Refund)

一致性的判定字段是 TotalMinor + Subject + NotifyURL,其余字段不同视为一致。

退款的同步返回可能已经是终态,按 Status 判

Status == RefundSucceeded 就是真退成功了(支付宝的退款是同步接口,通常走这条), 可以当场记账。Status == RefundProcessing 才是只收下了、结果未定——微信原路退回 银行卡要 T+1~3 天,通常是这一档,真实结果走 refund.succeeded / refund.failed 通知,或用 QueryRefund 查。

不要凭「调用没报错」就记账退款成功,那和 Status 是两回事。

无论同步返回哪一档,终态都会再推一条通知(同步已成功也推,防的是响应写回途中 断连)。按 RefundNo 幂等即可,别把它当成第二笔退款。

refund.stalled:退款卡住了,但还没结束

极少数情况下会收到 refund.stalled。它不是终态——钱已经离开商户账户、 还没落到用户手里,退款单仍是「处理中」,之后一定还会收到 refund.succeeded 或 refund.failed。

目前只有微信会出现:退往用户原路时银行拒收(卡作废或冻结),钱停在微信侧, 要由网关侧的人去渠道后台决定去向,可能要几小时到几天。支付宝不会——它退卡 失败会自动退到用户的支付宝余额。

收到它该做的:把 RefundNo 和 StalledReason(渠道原话,可以直接给客服看) 记下来,别再等这笔自己好。

收到它不该做的:判定退款失败、给用户二次补偿。那笔钱后面仍可能到用户手里, 补了就是退两次。

错误处理

业务错误一律是 *APIError,用哨兵 + errors.Is 判别,别比对文案:

resp, err := c.CreateOrder(ctx, req)
switch {
case err == nil:
    // 成功
case errors.Is(err, jjpay.ErrOrderConflict):
    // 单号撞了且参数不一致 —— 你的业务 bug,查为什么同一个单号金额变了
case errors.Is(err, jjpay.ErrAppDisabled):
    // App 被停用了,联系 jjpay 管理员
default:
    // 网络/超时/5xx:用同一个 out_trade_no 重试,或让用户重新发起
}

哨兵清单见 errors.go。另外三类非业务错误:

  • *HTTPError —— 非 200 的 HTTP 响应(网关 502、路径写错等)。jjpay 的业务响应恒为 200。
  • ErrResponseSign —— 响应验签失败。SDK 会校验每个响应的签名 (X-Jjpay-Timestamp / X-Jjpay-Nonce / X-Jjpay-Signature), 防中间人改回执。命中它说明回执不可信,不要把它当"失败"处理,要当"不知道"处理:去查单。
  • ErrTimestampWindow —— 时间戳超窗。通常是你的服务器时钟没校准,先看 NTP。

一个例外:服务端对鉴权没过的响应刻意不签名(签了等于给攻击者一个"密钥对不对"的 预言机)。这类响应里没有任何值钱的数据,所以 SDK 会把真实错误码透出来 (ErrSignMismatch / ErrTimestampSkew / ErrAppDisabled …),而不是笼统地报 ErrResponseSign——否则你联调时看到的永远是"响应验签失败",猜不到是密钥配错了。 这条只开给鉴权段(20001~20099):未签名的成功响应或其它业务响应一律按验签失败处理。

CodeOf(err) 取业务码;SDK 没登记过的新错误码同样会返回 *APIError,不会被吞成成功。


五、开发

go test ./...

签名部分有已知向量测试:给定固定的 method / path / timestamp / nonce / body / secret, 断言签出确定的 hex 串。其中几组与服务端共用同一份向量,任何一方改了算法都会当场红。

这些期望值是已发布契约的一部分,别为了让测试变绿去改它们。

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 后缀。 本包不提供以元为单位的参数。

调用方必须自己做的两件事

  1. 验签。不验签的回调端点等于任何人都能给你发“已支付”。 用 c.Middleware 或 c.Verify。
  2. 幂等。同一事件可能收到多次。支付类按 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") // 不回这个就会被重试
		})))
}

Index

Examples

Constants

View Source
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
)

默认值。

View Source
const (
	EnvBaseURL = "JJPAY_BASE_URL"
	EnvAppID   = "JJPAY_APP_ID"
	EnvSecret  = "JJPAY_APP_SECRET" //nolint:gosec // 这是环境变量名,不是密钥
)

环境变量名。三项都是 Config 对应字段留空时的回退来源,见 New。

View Source
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

View Source
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 判别。

View Source
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

func Middleware(secret string) func(http.Handler) http.Handler

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

func SignNotify(secret string, event EventType, body []byte, at time.Time) (http.Header, error)

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

func WithEvent

func WithEvent(ctx context.Context, evt *Event) context.Context

WithEvent 把已验签的事件放进 context。自己写框架胶水时用。

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,不会被吞成"成功"。

func (*APIError) Error

func (e *APIError) Error() string

func (*APIError) Is

func (e *APIError) Is(target error) bool

Is 让 errors.Is 只按 Code 匹配,Msg/Data 不参与——哨兵不带这些。

type Channel

type Channel string

Channel 支付渠道。对外一律字符串:alipay / wechat / mock。

const (
	ChannelAlipay Channel = "alipay"
	ChannelWechat Channel = "wechat"
	ChannelMock   Channel = "mock"
)

type Client

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

Client 是并发安全的,一个 App 建一个复用即可。

func New

func New(cfg Config) (*Client, error)

New 构造 Client。配置不合法立刻返回错误——把"密钥忘了配"这类问题留到 第一次收款时才炸,代价太大。

BaseURL / AppID / Secret 留空时依次回退到 EnvBaseURL / EnvAppID / EnvSecret; 两处都没有才报错,错误里会把环境变量名一并写出来。

func NewFromEnv

func NewFromEnv() (*Client, error)

NewFromEnv 三项全从环境变量取,等价于 New(Config{})。 容器 / systemd 里最常见的形态:地址与密钥都在部署侧,代码里一个字都没有。

func (*Client) AppID

func (c *Client) AppID() string

AppID 返回当前 App 标识,便于调用方打日志。Secret 刻意不给读。

func (*Client) CloseOrder

func (c *Client) CloseOrder(ctx context.Context, tradeNo string) (*CloseOrderResp, error)

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)
	}
}

func (*Client) Middleware

func (c *Client) Middleware() func(http.Handler) http.Handler

Middleware 用本 Client 的凭据返回验签中间件,等价于 Middleware(secret)。

mux.Handle("/pay/notify", c.Middleware()(myHandler))

func (*Client) MiddlewareWithOptions

func (c *Client) MiddlewareWithOptions(opts NotifyOptions) func(http.Handler) http.Handler

MiddlewareWithOptions 同 Middleware,可再给 OnError 等选项。 opts.Secret 与 opts.SkewWindow 若留空,用 Client 上的值。

func (*Client) QueryOrder

func (c *Client) QueryOrder(ctx context.Context, tradeNo string) (*Order, error)

QueryOrder 按 jjpay 单号查单。

服务端可能在此惰性回源向渠道查一次,所以这个调用比纯读 库慢,别拿它当轮询高频打。付款结果的主路径是异步通知,查单是补偿。

读操作,允许有限重试(网络错误与 5xx/429)。

func (*Client) QueryOrderByOutTradeNo

func (c *Client) QueryOrderByOutTradeNo(ctx context.Context, outTradeNo string) (*Order, error)

QueryOrderByOutTradeNo 按商户单号查单,走 ?out_trade_no= 形态。

读操作,允许有限重试。

func (*Client) QueryRefund

func (c *Client) QueryRefund(ctx context.Context, refundNo string) (*Refund, error)

QueryRefund 按 jjpay 退款单号查退款。读操作,允许有限重试。

func (*Client) QueryRefundByOutRefundNo

func (c *Client) QueryRefundByOutRefundNo(ctx context.Context, outRefundNo string) (*Refund, error)

QueryRefundByOutRefundNo 按商户退款单号查退款。读操作,允许有限重试。

func (*Client) Refund

func (c *Client) Refund(ctx context.Context, req RefundReq) (*Refund, error)

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) Verify

func (c *Client) Verify(header http.Header, body []byte) (*Event, error)

Verify 用本 Client 的凭据验一条异步通知,等价于 Verify(header, body, secret)。

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
}

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 // 未在公开错误码表中列出
)

func CodeOf

func CodeOf(err error) Code

CodeOf 取出错误里的业务码;不是业务错误时返回 0。

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 EventFrom

func EventFrom(ctx context.Context) *Event

EventFrom 从 context 取出已验签的事件;没有则返回 nil。

只有经过 Middleware(或自己调 WithEvent)的请求才有值。

func Verify

func Verify(header http.Header, body []byte, secret string) (*Event, error)

Verify 验证一条异步通知并解析出事件(默认 ±5 分钟时间戳窗口)。

框架无关:把请求头与原始 body 字节交进来即可,gin / echo / chi 都能 两行接上(见 README)。

校验顺序:头齐全 → 时间戳在窗口内 → 签名一致(hmac.Equal 常数时间)。 任一失败返回错误,此时不要处理这条通知。

待签串:{EVENT}\n{TIMESTAMP}\n{NONCE}\n{SHA256_HEX(BODY)}。

func VerifyWithOptions

func VerifyWithOptions(header http.Header, body []byte, opts NotifyOptions) (*Event, error)

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

type HTTPError struct {
	StatusCode int
	Body       string // 截断后的响应体,便于排查
}

HTTPError 非 200 的 HTTP 响应。

正常情况下 jjpay 的业务响应恒为 200,所以拿到它基本意味着请求没走到 应用层(网关 502、限流 429、路径写错 404 之类)。

func (*HTTPError) Error

func (e *HTTPError) Error() string

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 Logger

type Logger interface {
	Printf(format string, v ...any)
}

Logger 是本包唯一的日志出口,与任何日志库无关。 *log.Logger、zap 的 SugaredLogger 都能直接或包一层满足它。

type Method

type Method string

Method 支付方式,含义依渠道而定:

alipay → page(电脑网站支付)| wap(手机网站支付)
wechat → native(扫码)| h5 | jsapi(微信内)
mock → pay
const (
	MethodAlipayPage   Method = "page"
	MethodAlipayWap    Method = "wap"
	MethodWechatNative Method = "native"
	MethodWechatH5     Method = "h5"
	MethodWechatJSAPI  Method = "jsapi"
	MethodMockPay      Method = "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。

func (*Order) Paid

func (o *Order) Paid() bool

Paid 是否已支付(终态)。

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。

func (*ReturnResult) Paid

func (r *ReturnResult) Paid() bool

Paid 是否已支付。

Jump to

Keyboard shortcuts

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