Initial 007Pay Go SDK
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
/bin/
|
||||||
|
/dist/
|
||||||
|
*.exe
|
||||||
|
*.test
|
||||||
|
coverage.out
|
||||||
|
.env
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# 007Pay Go Merchant SDK / CLI
|
||||||
|
|
||||||
|
这是 007Pay 商户应用端 Go SDK,包含:
|
||||||
|
|
||||||
|
- `pay007` SDK 包:创建订单、查询订单、退款、订单列表、手动入账、收益统计
|
||||||
|
- `007pay` CLI:可在其他项目或脚本中快速调试接入
|
||||||
|
- 与平台一致的 `pid + key + MD5` 签名工具
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
作为独立子项目使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd merchant-sdk-go
|
||||||
|
go test ./...
|
||||||
|
go build -o bin/007pay ./cmd/007pay
|
||||||
|
```
|
||||||
|
|
||||||
|
发布到自己的 Git 仓库后,其他项目可直接引入:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get gitea.s1f.ren/shiran/007pay-sdk.git
|
||||||
|
```
|
||||||
|
|
||||||
|
## SDK 用法
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
pay007 "gitea.s1f.ren/shiran/007pay-sdk.git"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
client, err := pay007.NewClient(
|
||||||
|
"http://127.0.0.1:8081",
|
||||||
|
"your-pid",
|
||||||
|
"your-secret-key",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := client.Submit(context.Background(), pay007.SubmitRequest{
|
||||||
|
OutTradeNo: "M202607100001",
|
||||||
|
Name: "测试订单",
|
||||||
|
Money: "0.01",
|
||||||
|
Type: "alipay",
|
||||||
|
ClientType: "pc",
|
||||||
|
NotifyURL: "https://merchant.example.com/pay/notify",
|
||||||
|
ReturnURL: "https://merchant.example.com/pay/return",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(order.CashierURL)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
查询订单会调用平台商户查询接口;平台侧会在待支付时主动通过通道查询订单状态,例如支付宝会走 `alipay.trade.query`,并返回 `completed` 与 `channel_checked`。
|
||||||
|
|
||||||
|
```go
|
||||||
|
result, err := client.Query(context.Background(), pay007.QueryRequest{
|
||||||
|
OutTradeNo: "M202607100001",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Completed {
|
||||||
|
fmt.Println("paid")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI 用法
|
||||||
|
|
||||||
|
CLI 支持环境变量:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PAY007_BASE_URL=http://127.0.0.1:8081
|
||||||
|
export PAY007_PID=your-pid
|
||||||
|
export PAY007_KEY=your-secret-key
|
||||||
|
```
|
||||||
|
|
||||||
|
创建订单:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
007pay submit \
|
||||||
|
--out-trade-no M202607100001 \
|
||||||
|
--name "测试订单" \
|
||||||
|
--money 0.01 \
|
||||||
|
--type alipay \
|
||||||
|
--clienttype pc \
|
||||||
|
--notify-url https://merchant.example.com/pay/notify \
|
||||||
|
--return-url https://merchant.example.com/pay/return
|
||||||
|
```
|
||||||
|
|
||||||
|
查询订单:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
007pay query --out-trade-no M202607100001
|
||||||
|
```
|
||||||
|
|
||||||
|
退款:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
007pay refund --out-trade-no M202607100001 --money 0.01 --reason "用户退款"
|
||||||
|
```
|
||||||
|
|
||||||
|
订单列表:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
007pay orders --status 1 --page 1 --page-size 20
|
||||||
|
```
|
||||||
|
|
||||||
|
收益统计:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
007pay stats --begin "2026-07-01 00:00:00" --end "2026-07-31 23:59:59"
|
||||||
|
```
|
||||||
|
|
||||||
|
签名调试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
007pay sign out_trade_no=M202607100001 name=测试订单 money=0.01 type=alipay
|
||||||
|
```
|
||||||
|
|
||||||
|
## 签名规则
|
||||||
|
|
||||||
|
与平台 `utils/sign` 保持一致:
|
||||||
|
|
||||||
|
1. 过滤空值、`sign`、`sign_type`
|
||||||
|
2. 参数按 key 升序排序
|
||||||
|
3. 拼接为 `a=1&b=2`
|
||||||
|
4. 末尾追加应用密钥
|
||||||
|
5. MD5 后转大写
|
||||||
|
|
||||||
|
SDK 中可直接使用:
|
||||||
|
|
||||||
|
```go
|
||||||
|
sign := pay007.BuildSign(params, secret)
|
||||||
|
ok := pay007.VerifySign(params, secret)
|
||||||
|
```
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package pay007
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultUserAgent = "007pay-go-sdk/1.0"
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
pid string
|
||||||
|
secret string
|
||||||
|
httpClient *http.Client
|
||||||
|
userAgent string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Option func(*Client)
|
||||||
|
|
||||||
|
func WithHTTPClient(httpClient *http.Client) Option {
|
||||||
|
return func(c *Client) {
|
||||||
|
if httpClient != nil {
|
||||||
|
c.httpClient = httpClient
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithTimeout(timeout time.Duration) Option {
|
||||||
|
return func(c *Client) {
|
||||||
|
if timeout > 0 {
|
||||||
|
c.httpClient.Timeout = timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithUserAgent(userAgent string) Option {
|
||||||
|
return func(c *Client) {
|
||||||
|
if strings.TrimSpace(userAgent) != "" {
|
||||||
|
c.userAgent = userAgent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(baseURL, pid, secret string, opts ...Option) (*Client, error) {
|
||||||
|
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||||
|
if baseURL == "" {
|
||||||
|
return nil, errors.New("baseURL is required")
|
||||||
|
}
|
||||||
|
if _, err := url.ParseRequestURI(baseURL); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid baseURL: %w", err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(pid) == "" {
|
||||||
|
return nil, errors.New("pid is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(secret) == "" {
|
||||||
|
return nil, errors.New("secret is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &Client{
|
||||||
|
baseURL: baseURL,
|
||||||
|
pid: pid,
|
||||||
|
secret: secret,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
},
|
||||||
|
userAgent: defaultUserAgent,
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(c)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Sign(params map[string]string) string {
|
||||||
|
return BuildSign(params, c.secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Verify(params map[string]string) bool {
|
||||||
|
return VerifySign(params, c.secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Submit(ctx context.Context, req SubmitRequest) (*SubmitResponse, error) {
|
||||||
|
var out SubmitResponse
|
||||||
|
if err := c.doSigned(ctx, http.MethodPost, "/api/pay/submit", req.params(), &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Query(ctx context.Context, req QueryRequest) (*QueryResponse, error) {
|
||||||
|
var out QueryResponse
|
||||||
|
if err := c.doSigned(ctx, http.MethodGet, "/api/pay/query", req.params(), &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Refund(ctx context.Context, req RefundRequest) (*RefundResponse, error) {
|
||||||
|
var out RefundResponse
|
||||||
|
if err := c.doSigned(ctx, http.MethodPost, "/api/pay/refund", req.params(), &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Orders(ctx context.Context, req OrdersRequest) (*OrdersResponse, error) {
|
||||||
|
var out OrdersResponse
|
||||||
|
if err := c.doSigned(ctx, http.MethodGet, "/api/pay/orders", req.params(), &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Manual(ctx context.Context, req ManualRequest) (*ManualResponse, error) {
|
||||||
|
var out ManualResponse
|
||||||
|
if err := c.doSigned(ctx, http.MethodPost, "/api/pay/manual", req.params(), &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Stats(ctx context.Context, req StatsRequest) (*StatsResponse, error) {
|
||||||
|
var out StatsResponse
|
||||||
|
if err := c.doSigned(ctx, http.MethodGet, "/api/pay/stats", req.params(), &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doSigned(ctx context.Context, method, path string, params map[string]string, out any) error {
|
||||||
|
signed := compactParams(params)
|
||||||
|
signed["pid"] = c.pid
|
||||||
|
signed["sign_type"] = "MD5"
|
||||||
|
signed["sign"] = BuildSign(signed, c.secret)
|
||||||
|
|
||||||
|
values := url.Values{}
|
||||||
|
for k, v := range signed {
|
||||||
|
values.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := c.baseURL + path
|
||||||
|
var body io.Reader
|
||||||
|
if method == http.MethodGet {
|
||||||
|
endpoint += "?" + values.Encode()
|
||||||
|
} else {
|
||||||
|
body = bytes.NewBufferString(values.Encode())
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, method, endpoint, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Accept", "application/json")
|
||||||
|
httpReq.Header.Set("User-Agent", c.userAgent)
|
||||||
|
if method != http.MethodGet {
|
||||||
|
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var envelope struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBody, &envelope); err != nil {
|
||||||
|
return &APIError{
|
||||||
|
HTTPStatus: resp.StatusCode,
|
||||||
|
Code: 0,
|
||||||
|
Message: "invalid json response: " + err.Error(),
|
||||||
|
Body: string(respBody),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 || envelope.Code != 200 {
|
||||||
|
return &APIError{
|
||||||
|
HTTPStatus: resp.StatusCode,
|
||||||
|
Code: envelope.Code,
|
||||||
|
Message: envelope.Message,
|
||||||
|
Body: string(respBody),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if out == nil || len(envelope.Data) == 0 || string(envelope.Data) == "null" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(envelope.Data, out); err != nil {
|
||||||
|
return &APIError{
|
||||||
|
HTTPStatus: resp.StatusCode,
|
||||||
|
Code: envelope.Code,
|
||||||
|
Message: "invalid data response: " + err.Error(),
|
||||||
|
Body: string(respBody),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIError struct {
|
||||||
|
HTTPStatus int
|
||||||
|
Code int
|
||||||
|
Message string
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *APIError) Error() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("007pay api error: http=%d code=%d message=%s", e.HTTPStatus, e.Code, e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func compactParams(params map[string]string) map[string]string {
|
||||||
|
out := make(map[string]string, len(params))
|
||||||
|
for k, v := range params {
|
||||||
|
if strings.TrimSpace(k) == "" || v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(v int) string {
|
||||||
|
return strconv.Itoa(v)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package pay007
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSubmitPostsSignedForm(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/pay/submit" {
|
||||||
|
t.Fatalf("path = %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Fatalf("method = %s", r.Method)
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
params := map[string]string{}
|
||||||
|
for k, v := range r.PostForm {
|
||||||
|
if len(v) > 0 {
|
||||||
|
params[k] = v[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if params["pid"] != "pid-1" {
|
||||||
|
t.Fatalf("pid = %s", params["pid"])
|
||||||
|
}
|
||||||
|
if params["sign_type"] != "MD5" {
|
||||||
|
t.Fatalf("sign_type = %s", params["sign_type"])
|
||||||
|
}
|
||||||
|
if !VerifySign(params, "secret") {
|
||||||
|
t.Fatalf("invalid sign: %s", params["sign"])
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"code": 200,
|
||||||
|
"message": "Success",
|
||||||
|
"data": map[string]any{
|
||||||
|
"order_no": "P123",
|
||||||
|
"out_trade_no": params["out_trade_no"],
|
||||||
|
"amount": 100,
|
||||||
|
"cashier_url": "http://pay.test/cashier/?orderNo=P123",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client, err := NewClient(server.URL, "pid-1", "secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Submit(context.Background(), SubmitRequest{
|
||||||
|
OutTradeNo: "A100",
|
||||||
|
Name: "test",
|
||||||
|
Money: "1.00",
|
||||||
|
Type: "alipay",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.OrderNo != "P123" || resp.Amount != 100 {
|
||||||
|
t.Fatalf("unexpected response: %+v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pay007 "gitea.s1f.ren/shiran/007pay-sdk.git"
|
||||||
|
)
|
||||||
|
|
||||||
|
type globalConfig struct {
|
||||||
|
BaseURL string
|
||||||
|
PID string
|
||||||
|
Key string
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(os.Args[1:]); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "error:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(args []string) error {
|
||||||
|
global := flag.NewFlagSet("007pay", flag.ContinueOnError)
|
||||||
|
global.SetOutput(os.Stderr)
|
||||||
|
cfg := globalConfig{}
|
||||||
|
global.StringVar(&cfg.BaseURL, "base-url", envAny("PAY007_BASE_URL", "007PAY_BASE_URL"), "007Pay service base URL")
|
||||||
|
global.StringVar(&cfg.PID, "pid", envAny("PAY007_PID", "007PAY_PID"), "merchant app pid/token")
|
||||||
|
global.StringVar(&cfg.Key, "key", envAny("PAY007_KEY", "007PAY_KEY"), "merchant app secret key")
|
||||||
|
global.DurationVar(&cfg.Timeout, "timeout", 15*time.Second, "HTTP request timeout")
|
||||||
|
global.Usage = usage
|
||||||
|
|
||||||
|
if err := global.Parse(args); errors.Is(err, flag.ErrHelp) {
|
||||||
|
return nil
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rest := global.Args()
|
||||||
|
if len(rest) == 0 {
|
||||||
|
usage()
|
||||||
|
return errors.New("missing command")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd, cmdArgs := rest[0], rest[1:]
|
||||||
|
switch cmd {
|
||||||
|
case "submit":
|
||||||
|
return submit(cfg, cmdArgs)
|
||||||
|
case "query":
|
||||||
|
return query(cfg, cmdArgs)
|
||||||
|
case "refund":
|
||||||
|
return refund(cfg, cmdArgs)
|
||||||
|
case "orders":
|
||||||
|
return orders(cfg, cmdArgs)
|
||||||
|
case "manual":
|
||||||
|
return manual(cfg, cmdArgs)
|
||||||
|
case "stats":
|
||||||
|
return stats(cfg, cmdArgs)
|
||||||
|
case "sign":
|
||||||
|
return signParams(cfg, cmdArgs)
|
||||||
|
case "verify":
|
||||||
|
return verifyParams(cfg, cmdArgs)
|
||||||
|
case "help", "-h", "--help":
|
||||||
|
usage()
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
usage()
|
||||||
|
return fmt.Errorf("unknown command %q", cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newClient(cfg globalConfig) (*pay007.Client, error) {
|
||||||
|
return pay007.NewClient(cfg.BaseURL, cfg.PID, cfg.Key, pay007.WithTimeout(cfg.Timeout))
|
||||||
|
}
|
||||||
|
|
||||||
|
func submit(cfg globalConfig, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("submit", flag.ContinueOnError)
|
||||||
|
req := pay007.SubmitRequest{}
|
||||||
|
fs.StringVar(&req.OutTradeNo, "out-trade-no", "", "merchant order number")
|
||||||
|
fs.StringVar(&req.Name, "name", "", "order subject")
|
||||||
|
fs.StringVar(&req.Money, "money", "", "amount in yuan, for example 9.90")
|
||||||
|
fs.StringVar(&req.Type, "type", "", "channel code, for example alipay or wechat")
|
||||||
|
fs.StringVar(&req.ClientType, "clienttype", "", "client type: pc or mobile")
|
||||||
|
fs.StringVar(&req.NotifyURL, "notify-url", "", "merchant async notify URL")
|
||||||
|
fs.StringVar(&req.ReturnURL, "return-url", "", "merchant sync return URL")
|
||||||
|
fs.StringVar(&req.Body, "body", "", "order description")
|
||||||
|
fs.StringVar(&req.Attach, "attach", "", "extra data")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := newClient(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := client.Submit(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return printJSON(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func query(cfg globalConfig, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("query", flag.ContinueOnError)
|
||||||
|
req := pay007.QueryRequest{}
|
||||||
|
fs.StringVar(&req.TradeNo, "trade-no", "", "007Pay order number")
|
||||||
|
fs.StringVar(&req.OutTradeNo, "out-trade-no", "", "merchant order number")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := newClient(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := client.Query(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return printJSON(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func refund(cfg globalConfig, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("refund", flag.ContinueOnError)
|
||||||
|
req := pay007.RefundRequest{}
|
||||||
|
fs.StringVar(&req.TradeNo, "trade-no", "", "007Pay order number")
|
||||||
|
fs.StringVar(&req.OutTradeNo, "out-trade-no", "", "merchant order number")
|
||||||
|
fs.StringVar(&req.Money, "money", "", "refund amount in yuan; omit to refund full order")
|
||||||
|
fs.StringVar(&req.Reason, "reason", "", "refund reason")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := newClient(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := client.Refund(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return printJSON(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func orders(cfg globalConfig, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("orders", flag.ContinueOnError)
|
||||||
|
req := pay007.OrdersRequest{}
|
||||||
|
status := fs.Int("status", -1, "order status: 0 pending, 1 paid, 2 closed, 3 refunded")
|
||||||
|
fs.StringVar(&req.OrderNo, "order-no", "", "007Pay or merchant order number")
|
||||||
|
fs.IntVar(&req.Page, "page", 1, "page number")
|
||||||
|
fs.IntVar(&req.PageSize, "page-size", 20, "page size")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *status >= 0 {
|
||||||
|
req.Status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := newClient(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := client.Orders(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return printJSON(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func manual(cfg globalConfig, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("manual", flag.ContinueOnError)
|
||||||
|
req := pay007.ManualRequest{}
|
||||||
|
fs.StringVar(&req.OutTradeNo, "out-trade-no", "", "merchant order number")
|
||||||
|
fs.StringVar(&req.Name, "name", "", "order subject")
|
||||||
|
fs.StringVar(&req.Money, "money", "", "amount in yuan")
|
||||||
|
fs.StringVar(&req.Type, "type", "", "channel code")
|
||||||
|
fs.StringVar(&req.Attach, "attach", "", "extra data")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := newClient(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := client.Manual(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return printJSON(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stats(cfg globalConfig, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("stats", flag.ContinueOnError)
|
||||||
|
req := pay007.StatsRequest{}
|
||||||
|
fs.StringVar(&req.Begin, "begin", "", "begin time: 2006-01-02 15:04:05")
|
||||||
|
fs.StringVar(&req.End, "end", "", "end time: 2006-01-02 15:04:05")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := newClient(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := client.Stats(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return printJSON(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func signParams(cfg globalConfig, args []string) error {
|
||||||
|
if strings.TrimSpace(cfg.Key) == "" {
|
||||||
|
return errors.New("--key or PAY007_KEY is required")
|
||||||
|
}
|
||||||
|
params, err := parseKV(args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cfg.PID != "" {
|
||||||
|
params["pid"] = cfg.PID
|
||||||
|
}
|
||||||
|
params["sign_type"] = "MD5"
|
||||||
|
params["sign"] = pay007.BuildSign(params, cfg.Key)
|
||||||
|
return printJSON(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyParams(cfg globalConfig, args []string) error {
|
||||||
|
if strings.TrimSpace(cfg.Key) == "" {
|
||||||
|
return errors.New("--key or PAY007_KEY is required")
|
||||||
|
}
|
||||||
|
params, err := parseKV(args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return printJSON(map[string]any{
|
||||||
|
"ok": pay007.VerifySign(params, cfg.Key),
|
||||||
|
"sign": pay007.BuildSign(params, cfg.Key),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseKV(args []string) (map[string]string, error) {
|
||||||
|
params := map[string]string{}
|
||||||
|
for _, arg := range args {
|
||||||
|
k, v, ok := strings.Cut(arg, "=")
|
||||||
|
if !ok || strings.TrimSpace(k) == "" {
|
||||||
|
return nil, fmt.Errorf("invalid param %q, expected key=value", arg)
|
||||||
|
}
|
||||||
|
params[k] = v
|
||||||
|
}
|
||||||
|
return params, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func printJSON(v any) error {
|
||||||
|
data, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println(string(data))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func envAny(keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func usage() {
|
||||||
|
fmt.Fprintf(os.Stderr, `007Pay merchant SDK CLI
|
||||||
|
|
||||||
|
Global flags:
|
||||||
|
--base-url 007Pay service base URL, or PAY007_BASE_URL
|
||||||
|
--pid merchant app pid/token, or PAY007_PID
|
||||||
|
--key merchant app secret key, or PAY007_KEY
|
||||||
|
--timeout HTTP timeout, default 15s
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
submit create a payment order
|
||||||
|
query query and channel-check an order
|
||||||
|
refund refund an order
|
||||||
|
orders list merchant orders
|
||||||
|
manual create a manual paid order
|
||||||
|
stats query merchant stats
|
||||||
|
sign sign key=value params
|
||||||
|
verify verify key=value params with sign=...
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
007pay --base-url http://127.0.0.1:8081 --pid app-token --key secret submit --out-trade-no T1001 --name test --money 0.01 --type alipay
|
||||||
|
007pay query --out-trade-no T1001
|
||||||
|
007pay sign out_trade_no=T1001 name=test money=0.01 type=alipay
|
||||||
|
`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pay007 "gitea.s1f.ren/shiran/007pay-sdk.git"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
client, err := pay007.NewClient(
|
||||||
|
os.Getenv("PAY007_BASE_URL"),
|
||||||
|
os.Getenv("PAY007_PID"),
|
||||||
|
os.Getenv("PAY007_KEY"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Submit(context.Background(), pay007.SubmitRequest{
|
||||||
|
OutTradeNo: "demo-" + time.Now().Format("20060102150405"),
|
||||||
|
Name: "SDK demo order",
|
||||||
|
Money: "0.01",
|
||||||
|
Type: "alipay",
|
||||||
|
ClientType: "pc",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.MarshalIndent(resp, "", " ")
|
||||||
|
fmt.Println(string(data))
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package pay007
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ignoredSignKeys = map[string]struct{}{
|
||||||
|
"sign": {},
|
||||||
|
"sign_type": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildSign creates the 007Pay merchant MD5 signature.
|
||||||
|
//
|
||||||
|
// Empty values, sign, and sign_type are ignored. The remaining params are
|
||||||
|
// sorted by key, joined as "a=1&b=2", appended with the app secret, then MD5
|
||||||
|
// hashed and upper-cased.
|
||||||
|
func BuildSign(params map[string]string, secret string) string {
|
||||||
|
keys := make([]string, 0, len(params))
|
||||||
|
for k, v := range params {
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := ignoredSignKeys[k]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for i, k := range keys {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteByte('&')
|
||||||
|
}
|
||||||
|
b.WriteString(k)
|
||||||
|
b.WriteByte('=')
|
||||||
|
b.WriteString(params[k])
|
||||||
|
}
|
||||||
|
b.WriteString(secret)
|
||||||
|
|
||||||
|
sum := md5.Sum([]byte(b.String()))
|
||||||
|
return strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifySign checks params["sign"] with the 007Pay signing rule.
|
||||||
|
func VerifySign(params map[string]string, secret string) bool {
|
||||||
|
sign := params["sign"]
|
||||||
|
if sign == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(BuildSign(params, secret), sign)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package pay007
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestBuildSign(t *testing.T) {
|
||||||
|
params := map[string]string{
|
||||||
|
"pid": "1001",
|
||||||
|
"out_trade_no": "A100",
|
||||||
|
"name": "test",
|
||||||
|
"money": "1.00",
|
||||||
|
"type": "alipay",
|
||||||
|
"empty": "",
|
||||||
|
"sign_type": "MD5",
|
||||||
|
"sign": "ignored",
|
||||||
|
}
|
||||||
|
|
||||||
|
got := BuildSign(params, "secret")
|
||||||
|
const want = "59DD6586A5050050EB08A9806C6F501B"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("BuildSign() = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifySign(t *testing.T) {
|
||||||
|
params := map[string]string{
|
||||||
|
"pid": "1001",
|
||||||
|
"out_trade_no": "A100",
|
||||||
|
"name": "test",
|
||||||
|
"money": "1.00",
|
||||||
|
"type": "alipay",
|
||||||
|
}
|
||||||
|
params["sign"] = BuildSign(params, "secret")
|
||||||
|
|
||||||
|
if !VerifySign(params, "secret") {
|
||||||
|
t.Fatal("VerifySign() = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package pay007
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusPending = 0
|
||||||
|
StatusPaid = 1
|
||||||
|
StatusClosed = 2
|
||||||
|
StatusRefund = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// SubmitRequest creates a platform order and returns a cashier URL.
|
||||||
|
type SubmitRequest struct {
|
||||||
|
OutTradeNo string
|
||||||
|
Name string
|
||||||
|
Money string
|
||||||
|
Type string
|
||||||
|
ClientType string
|
||||||
|
NotifyURL string
|
||||||
|
ReturnURL string
|
||||||
|
Body string
|
||||||
|
Attach string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r SubmitRequest) params() map[string]string {
|
||||||
|
return compactParams(map[string]string{
|
||||||
|
"out_trade_no": r.OutTradeNo,
|
||||||
|
"name": r.Name,
|
||||||
|
"money": r.Money,
|
||||||
|
"type": r.Type,
|
||||||
|
"clienttype": r.ClientType,
|
||||||
|
"notify_url": r.NotifyURL,
|
||||||
|
"return_url": r.ReturnURL,
|
||||||
|
"body": r.Body,
|
||||||
|
"attach": r.Attach,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubmitResponse struct {
|
||||||
|
OrderNo string `json:"order_no"`
|
||||||
|
OutTradeNo string `json:"out_trade_no"`
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
CashierURL string `json:"cashier_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryRequest struct {
|
||||||
|
TradeNo string
|
||||||
|
OutTradeNo string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r QueryRequest) params() map[string]string {
|
||||||
|
return compactParams(map[string]string{
|
||||||
|
"trade_no": r.TradeNo,
|
||||||
|
"out_trade_no": r.OutTradeNo,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryResponse struct {
|
||||||
|
TradeNo string `json:"trade_no"`
|
||||||
|
OutTradeNo string `json:"out_trade_no"`
|
||||||
|
ChannelTradeNo string `json:"channel_trade_no"`
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
Completed bool `json:"completed"`
|
||||||
|
ChannelChecked bool `json:"channel_checked"`
|
||||||
|
PaidAt *time.Time `json:"paid_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RefundRequest struct {
|
||||||
|
TradeNo string
|
||||||
|
OutTradeNo string
|
||||||
|
Money string
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r RefundRequest) params() map[string]string {
|
||||||
|
return compactParams(map[string]string{
|
||||||
|
"trade_no": r.TradeNo,
|
||||||
|
"out_trade_no": r.OutTradeNo,
|
||||||
|
"money": r.Money,
|
||||||
|
"reason": r.Reason,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type RefundResponse struct {
|
||||||
|
RefundNo string `json:"refund_no"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrdersRequest struct {
|
||||||
|
OrderNo string
|
||||||
|
Status *int
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r OrdersRequest) params() map[string]string {
|
||||||
|
params := compactParams(map[string]string{
|
||||||
|
"order_no": r.OrderNo,
|
||||||
|
})
|
||||||
|
if r.Status != nil {
|
||||||
|
params["status"] = itoa(*r.Status)
|
||||||
|
}
|
||||||
|
if r.Page > 0 {
|
||||||
|
params["page"] = itoa(r.Page)
|
||||||
|
}
|
||||||
|
if r.PageSize > 0 {
|
||||||
|
params["page_size"] = itoa(r.PageSize)
|
||||||
|
}
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrdersResponse struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
List []Order `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Order struct {
|
||||||
|
TradeNo string `json:"trade_no"`
|
||||||
|
OutTradeNo string `json:"out_trade_no"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
Fee int64 `json:"fee"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
ChannelID uint `json:"channel_id"`
|
||||||
|
ChannelTradeNo string `json:"channel_trade_no"`
|
||||||
|
ClientType string `json:"client_type"`
|
||||||
|
IsManual bool `json:"is_manual"`
|
||||||
|
PaidAt *time.Time `json:"paid_at"`
|
||||||
|
CreatedAt *time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManualRequest struct {
|
||||||
|
OutTradeNo string
|
||||||
|
Name string
|
||||||
|
Money string
|
||||||
|
Type string
|
||||||
|
Attach string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r ManualRequest) params() map[string]string {
|
||||||
|
return compactParams(map[string]string{
|
||||||
|
"out_trade_no": r.OutTradeNo,
|
||||||
|
"name": r.Name,
|
||||||
|
"money": r.Money,
|
||||||
|
"type": r.Type,
|
||||||
|
"attach": r.Attach,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManualResponse struct {
|
||||||
|
TradeNo string `json:"trade_no"`
|
||||||
|
OutTradeNo string `json:"out_trade_no"`
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatsRequest struct {
|
||||||
|
Begin string
|
||||||
|
End string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r StatsRequest) params() map[string]string {
|
||||||
|
return compactParams(map[string]string{
|
||||||
|
"begin": r.Begin,
|
||||||
|
"end": r.End,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatsResponse struct {
|
||||||
|
OrderCount int64 `json:"order_count"`
|
||||||
|
TotalAmount int64 `json:"total_amount"`
|
||||||
|
TotalFee int64 `json:"total_fee"`
|
||||||
|
NetAmount int64 `json:"net_amount"`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user