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