This commit is contained in:
61
tests/agent_open_api/README.md
Normal file
61
tests/agent_open_api/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# 代理开放接口第三方对接示例
|
||||
|
||||
本目录是按 Apifox 文档实现的第三方调用示例,不依赖当前项目内部包。
|
||||
|
||||
## 默认配置
|
||||
|
||||
- 服务地址:`https://cmp-api.boss160.cn`
|
||||
- 账号:`15571055000`
|
||||
- 密码:`Admin@123456`
|
||||
|
||||
也可以通过环境变量覆盖:
|
||||
|
||||
```bash
|
||||
AGENT_OPEN_API_BASE_URL=https://cmp-api.boss160.cn \
|
||||
AGENT_OPEN_API_ACCOUNT=15571055000 \
|
||||
AGENT_OPEN_API_PASSWORD='Admin@123456' \
|
||||
go run ./tests/agent_open_api -action wallet-balance
|
||||
```
|
||||
|
||||
## 支持的 action
|
||||
|
||||
```bash
|
||||
# 查询预充值钱包余额
|
||||
go run ./tests/agent_open_api -action wallet-balance
|
||||
|
||||
# 查询套餐列表
|
||||
go run ./tests/agent_open_api -action packages -page 1 -page-size 20
|
||||
|
||||
# 查询单卡实名状态
|
||||
go run ./tests/agent_open_api -action realname-status -card-no <ICCID或虚拟号或MSISDN>
|
||||
|
||||
# 查询单卡状态
|
||||
go run ./tests/agent_open_api -action card-status -card-no <ICCID或虚拟号或MSISDN>
|
||||
|
||||
# 查询单卡流量
|
||||
go run ./tests/agent_open_api -action card-traffic -card-no <ICCID或虚拟号或MSISDN>
|
||||
|
||||
# 查询预充值钱包流水
|
||||
go run ./tests/agent_open_api -action wallet-transactions -page 1 -page-size 20
|
||||
|
||||
# 钱包套餐购买,会真实创建订单,必须显式传入该 action
|
||||
go run ./tests/agent_open_api -action package-order -card-nos <卡1,卡2> -package-code <套餐编码>
|
||||
```
|
||||
|
||||
## 签名规则
|
||||
|
||||
每个请求携带 `X-Agent-Account`、`X-Agent-Password`、`X-Agent-Timestamp`、`X-Agent-Nonce`、`X-Agent-Sign`。
|
||||
|
||||
签名原文按以下 7 行拼接,最后一行后不追加换行:
|
||||
|
||||
```text
|
||||
METHOD
|
||||
PATH
|
||||
canonical_query
|
||||
body_sha256
|
||||
timestamp
|
||||
nonce
|
||||
account
|
||||
```
|
||||
|
||||
`canonical_query` 会排除 `sign` 参数,参数名升序,同名多值按值升序,并使用 URL QueryEscape 编码。`body_sha256` 使用最终发送请求体字节计算,GET 和空 body 使用空字符串的 SHA256。
|
||||
401
tests/agent_open_api/main.go
Normal file
401
tests/agent_open_api/main.go
Normal file
@@ -0,0 +1,401 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBaseURL = "https://cmp-api.boss160.cn"
|
||||
defaultAccount = "15571055000"
|
||||
defaultPassword = "Admin@123456"
|
||||
)
|
||||
|
||||
// apiResponse 是文档定义的统一响应外壳,data 保持原始 JSON 便于兼容列表或分页结构。
|
||||
type apiResponse struct {
|
||||
Code int `json:"code"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Msg string `json:"msg"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// walletPackageOrderRequest 是“钱包套餐购买”的请求体。
|
||||
type walletPackageOrderRequest struct {
|
||||
CardNos []string `json:"card_nos"`
|
||||
PackageCode string `json:"package_code"`
|
||||
}
|
||||
|
||||
// client 是第三方调用方视角的独立客户端,不依赖当前项目内部代码。
|
||||
type client struct {
|
||||
baseURL string
|
||||
account string
|
||||
password string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func main() {
|
||||
baseURL := flag.String("base-url", envOrDefault("AGENT_OPEN_API_BASE_URL", defaultBaseURL), "接口服务地址")
|
||||
account := flag.String("account", envOrDefault("AGENT_OPEN_API_ACCOUNT", defaultAccount), "代理账号用户名或手机号")
|
||||
password := flag.String("password", envOrDefault("AGENT_OPEN_API_PASSWORD", defaultPassword), "代理账号登录密码")
|
||||
action := flag.String("action", "wallet-balance", "调用动作:realname-status、card-status、card-traffic、packages、wallet-balance、package-order、wallet-transactions")
|
||||
cardNo := flag.String("card-no", "", "卡标识,支持 ICCID、虚拟号、MSISDN")
|
||||
cardNos := flag.String("card-nos", "", "卡标识列表,多个值用英文逗号分隔")
|
||||
packageCode := flag.String("package-code", "", "套餐编码")
|
||||
page := flag.Int("page", 0, "页码,不传或 0 表示省略")
|
||||
pageSize := flag.Int("page-size", 0, "每页数量,不传或 0 表示省略")
|
||||
packageType := flag.String("package-type", "", "套餐类型:formal 或 addon")
|
||||
seriesID := flag.Int("series-id", -1, "套餐系列 ID,不传或 -1 表示省略")
|
||||
packageName := flag.String("package-name", "", "套餐名称,模糊搜索")
|
||||
transactionType := flag.String("transaction-type", "", "交易类型:recharge、deduct、refund")
|
||||
startDate := flag.String("start-date", "", "开始日期,格式 YYYY-MM-DD")
|
||||
endDate := flag.String("end-date", "", "结束日期,格式 YYYY-MM-DD")
|
||||
timeout := flag.Duration("timeout", 15*time.Second, "请求超时时间")
|
||||
flag.Parse()
|
||||
|
||||
c := &client{
|
||||
baseURL: strings.TrimRight(*baseURL, "/"),
|
||||
account: *account,
|
||||
password: *password,
|
||||
httpClient: &http.Client{
|
||||
Timeout: *timeout,
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
statusCode, responseBody, err := dispatch(ctx, c, runOptions{
|
||||
action: *action,
|
||||
cardNo: *cardNo,
|
||||
cardNos: *cardNos,
|
||||
packageCode: *packageCode,
|
||||
page: *page,
|
||||
pageSize: *pageSize,
|
||||
packageType: *packageType,
|
||||
seriesID: *seriesID,
|
||||
packageName: *packageName,
|
||||
transactionType: *transactionType,
|
||||
startDate: *startDate,
|
||||
endDate: *endDate,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "调用失败:%v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("HTTP %d\n", statusCode)
|
||||
printJSON(responseBody)
|
||||
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// runOptions 保存命令行参数,避免把主流程写成过长函数。
|
||||
type runOptions struct {
|
||||
action string
|
||||
cardNo string
|
||||
cardNos string
|
||||
packageCode string
|
||||
page int
|
||||
pageSize int
|
||||
packageType string
|
||||
seriesID int
|
||||
packageName string
|
||||
transactionType string
|
||||
startDate string
|
||||
endDate string
|
||||
}
|
||||
|
||||
func dispatch(ctx context.Context, c *client, opts runOptions) (int, []byte, error) {
|
||||
switch opts.action {
|
||||
case "realname-status":
|
||||
if opts.cardNo == "" {
|
||||
return 0, nil, errors.New("realname-status 需要传入 -card-no")
|
||||
}
|
||||
return c.queryRealnameStatus(ctx, opts.cardNo)
|
||||
case "card-status":
|
||||
if opts.cardNo == "" {
|
||||
return 0, nil, errors.New("card-status 需要传入 -card-no")
|
||||
}
|
||||
return c.queryCardStatus(ctx, opts.cardNo)
|
||||
case "card-traffic":
|
||||
if opts.cardNo == "" {
|
||||
return 0, nil, errors.New("card-traffic 需要传入 -card-no")
|
||||
}
|
||||
return c.queryCardTraffic(ctx, opts.cardNo)
|
||||
case "packages":
|
||||
return c.queryPackages(ctx, packageQuery(opts))
|
||||
case "wallet-balance":
|
||||
return c.queryWalletBalance(ctx)
|
||||
case "package-order":
|
||||
requestBody, err := buildPackageOrderRequest(opts.cardNos, opts.packageCode)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return c.createWalletPackageOrder(ctx, requestBody)
|
||||
case "wallet-transactions":
|
||||
if err := validateDateRange(opts.startDate, opts.endDate); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return c.queryWalletTransactions(ctx, walletTransactionQuery(opts))
|
||||
default:
|
||||
return 0, nil, fmt.Errorf("未知 action:%s", opts.action)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) queryRealnameStatus(ctx context.Context, cardNo string) (int, []byte, error) {
|
||||
query := url.Values{}
|
||||
query.Add("card_no", cardNo)
|
||||
return c.get(ctx, "/api/open/v1/cards/realname-status", query)
|
||||
}
|
||||
|
||||
func (c *client) queryCardStatus(ctx context.Context, cardNo string) (int, []byte, error) {
|
||||
query := url.Values{}
|
||||
query.Add("card_no", cardNo)
|
||||
return c.get(ctx, "/api/open/v1/cards/status", query)
|
||||
}
|
||||
|
||||
func (c *client) queryCardTraffic(ctx context.Context, cardNo string) (int, []byte, error) {
|
||||
query := url.Values{}
|
||||
query.Add("card_no", cardNo)
|
||||
return c.get(ctx, "/api/open/v1/cards/traffic", query)
|
||||
}
|
||||
|
||||
func (c *client) queryPackages(ctx context.Context, query url.Values) (int, []byte, error) {
|
||||
return c.get(ctx, "/api/open/v1/packages", query)
|
||||
}
|
||||
|
||||
func (c *client) queryWalletBalance(ctx context.Context) (int, []byte, error) {
|
||||
return c.get(ctx, "/api/open/v1/wallet/balance", nil)
|
||||
}
|
||||
|
||||
func (c *client) createWalletPackageOrder(ctx context.Context, body walletPackageOrderRequest) (int, []byte, error) {
|
||||
return c.postJSON(ctx, "/api/open/v1/wallet/package-orders", body)
|
||||
}
|
||||
|
||||
func (c *client) queryWalletTransactions(ctx context.Context, query url.Values) (int, []byte, error) {
|
||||
return c.get(ctx, "/api/open/v1/wallet/transactions", query)
|
||||
}
|
||||
|
||||
func (c *client) get(ctx context.Context, path string, query url.Values) (int, []byte, error) {
|
||||
return c.do(ctx, http.MethodGet, path, query, nil)
|
||||
}
|
||||
|
||||
func (c *client) postJSON(ctx context.Context, path string, body any) (int, []byte, error) {
|
||||
bodyBytes, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("序列化请求体失败:%w", err)
|
||||
}
|
||||
return c.do(ctx, http.MethodPost, path, nil, bodyBytes)
|
||||
}
|
||||
|
||||
func (c *client) do(ctx context.Context, method string, path string, query url.Values, body []byte) (int, []byte, error) {
|
||||
endpoint, err := url.Parse(c.baseURL)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("解析 base-url 失败:%w", err)
|
||||
}
|
||||
endpoint.Path = strings.TrimRight(endpoint.Path, "/") + path
|
||||
endpoint.RawQuery = canonicalQuery(query)
|
||||
|
||||
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
nonce, err := newNonce()
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
sign := buildAgentSign(method, path, query, body, timestamp, nonce, c.account, c.password)
|
||||
|
||||
var requestBody io.Reader
|
||||
if len(body) > 0 {
|
||||
requestBody = bytes.NewReader(body)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, method, endpoint.String(), requestBody)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("创建 HTTP 请求失败:%w", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("X-Agent-Account", c.account)
|
||||
request.Header.Set("X-Agent-Password", c.password)
|
||||
request.Header.Set("X-Agent-Timestamp", timestamp)
|
||||
request.Header.Set("X-Agent-Nonce", nonce)
|
||||
request.Header.Set("X-Agent-Sign", sign)
|
||||
if len(body) > 0 {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("发送 HTTP 请求失败:%w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
responseBody, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return response.StatusCode, nil, fmt.Errorf("读取响应失败:%w", err)
|
||||
}
|
||||
return response.StatusCode, responseBody, nil
|
||||
}
|
||||
|
||||
func buildAgentSign(method string, path string, query url.Values, body []byte, timestamp string, nonce string, account string, password string) string {
|
||||
bodyHash := sha256.Sum256(body)
|
||||
signPayload := strings.Join([]string{
|
||||
strings.ToUpper(method),
|
||||
path,
|
||||
canonicalQuery(query),
|
||||
hex.EncodeToString(bodyHash[:]),
|
||||
timestamp,
|
||||
nonce,
|
||||
account,
|
||||
}, "\n")
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(password))
|
||||
mac.Write([]byte(signPayload))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func canonicalQuery(query url.Values) string {
|
||||
if len(query) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(query))
|
||||
for key := range query {
|
||||
if strings.EqualFold(key, "sign") {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
parts := make([]string, 0)
|
||||
for _, key := range keys {
|
||||
values := append([]string(nil), query[key]...)
|
||||
sort.Strings(values)
|
||||
for _, value := range values {
|
||||
parts = append(parts, url.QueryEscape(key)+"="+url.QueryEscape(value))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
func newNonce() (string, error) {
|
||||
bytes := make([]byte, 16)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", fmt.Errorf("生成 nonce 失败:%w", err)
|
||||
}
|
||||
return strconv.FormatInt(time.Now().UnixNano(), 10) + "-" + hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func packageQuery(opts runOptions) url.Values {
|
||||
query := url.Values{}
|
||||
addPositiveInt(query, "page", opts.page)
|
||||
addPositiveInt(query, "page_size", opts.pageSize)
|
||||
addString(query, "package_type", opts.packageType)
|
||||
if opts.seriesID >= 0 {
|
||||
query.Add("series_id", strconv.Itoa(opts.seriesID))
|
||||
}
|
||||
addString(query, "package_name", opts.packageName)
|
||||
return query
|
||||
}
|
||||
|
||||
func walletTransactionQuery(opts runOptions) url.Values {
|
||||
query := url.Values{}
|
||||
addPositiveInt(query, "page", opts.page)
|
||||
addPositiveInt(query, "page_size", opts.pageSize)
|
||||
addString(query, "transaction_type", opts.transactionType)
|
||||
addString(query, "start_date", opts.startDate)
|
||||
addString(query, "end_date", opts.endDate)
|
||||
return query
|
||||
}
|
||||
|
||||
func addPositiveInt(query url.Values, key string, value int) {
|
||||
if value > 0 {
|
||||
query.Add(key, strconv.Itoa(value))
|
||||
}
|
||||
}
|
||||
|
||||
func addString(query url.Values, key string, value string) {
|
||||
if value != "" {
|
||||
query.Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
func buildPackageOrderRequest(cardNos string, packageCode string) (walletPackageOrderRequest, error) {
|
||||
if strings.TrimSpace(cardNos) == "" {
|
||||
return walletPackageOrderRequest{}, errors.New("package-order 需要传入 -card-nos")
|
||||
}
|
||||
if strings.TrimSpace(packageCode) == "" {
|
||||
return walletPackageOrderRequest{}, errors.New("package-order 需要传入 -package-code")
|
||||
}
|
||||
|
||||
items := strings.Split(cardNos, ",")
|
||||
cards := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
cardNo := strings.TrimSpace(item)
|
||||
if cardNo != "" {
|
||||
cards = append(cards, cardNo)
|
||||
}
|
||||
}
|
||||
if len(cards) == 0 {
|
||||
return walletPackageOrderRequest{}, errors.New("package-order 的 -card-nos 不能为空")
|
||||
}
|
||||
if len(cards) > 100 {
|
||||
return walletPackageOrderRequest{}, errors.New("package-order 的 -card-nos 最多支持 100 张卡")
|
||||
}
|
||||
|
||||
return walletPackageOrderRequest{
|
||||
CardNos: cards,
|
||||
PackageCode: strings.TrimSpace(packageCode),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateDateRange(startDate string, endDate string) error {
|
||||
if startDate != "" {
|
||||
if _, err := time.Parse(time.DateOnly, startDate); err != nil {
|
||||
return fmt.Errorf("start-date 格式必须为 YYYY-MM-DD:%w", err)
|
||||
}
|
||||
}
|
||||
if endDate != "" {
|
||||
if _, err := time.Parse(time.DateOnly, endDate); err != nil {
|
||||
return fmt.Errorf("end-date 格式必须为 YYYY-MM-DD:%w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func envOrDefault(key string, fallback string) string {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func printJSON(body []byte) {
|
||||
var raw json.RawMessage
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
fmt.Println(string(body))
|
||||
return
|
||||
}
|
||||
|
||||
var pretty bytes.Buffer
|
||||
if err := json.Indent(&pretty, raw, "", " "); err != nil {
|
||||
fmt.Println(string(body))
|
||||
return
|
||||
}
|
||||
fmt.Println(pretty.String())
|
||||
}
|
||||
Reference in New Issue
Block a user