diff --git a/.gitignore b/.gitignore index 4651095..6e68cd6 100644 --- a/.gitignore +++ b/.gitignore @@ -106,3 +106,4 @@ docs/admin-openapi.yaml .omx/state/sessions/omx-1777517489966-oo3g37/hud-state.json .omx/state/sessions/omx-1777517489966-oo3g37/notify-hook-state.json .omx/state/tmux-extended-keys/private-tmp-tmux-501-default.json +.aider* diff --git a/README.md b/README.md index a402cf3..e4dbc39 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,7 @@ default: - **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制(Access Token 24h + Refresh Token 7天);包含登录、登出、Token 刷新、用户信息查询和密码修改功能;通过用户类型隔离确保后台(SuperAdmin、Platform、Agent)和 H5(Agent、Enterprise)的访问控制;详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md) 和 [架构说明](docs/auth-architecture.md) - **生命周期管理**:物联网卡/号卡的开卡、激活、停机、复机、销户 - **代理商体系**:层级管理和分佣结算,支持差价佣金和一次性佣金两种佣金类型,详见 [套餐与佣金业务模型](docs/commission-package-model.md) +- **代理开放接口**:新增 `/api/open/v1` 签名接口,代理店铺第三方系统可调用卡流量、卡状态、实名状态、套餐列表、预充值钱包余额/流水和钱包套餐购买能力。详见 [对接说明](docs/agent-open-api/功能总结.md) - **批量同步**:卡状态、实名状态、流量使用情况 - **轮询系统**:IoT 卡实名状态、流量使用、套餐余额的定时轮询检查;支持配置化轮询策略、动态并发控制、告警系统、数据清理和手动触发功能;详见 [轮询系统文档](docs/polling-system/README.md) - **套餐系统升级**:完整的套餐生命周期管理,支持主套餐排队激活、加油包绑定主套餐、囤货待实名激活、流量按优先级扣减、自然月/按天有效期计算、日/月/年流量重置、客户端流量查询和套餐流量详单;详见 [套餐系统升级文档](docs/package-system-upgrade/) diff --git a/internal/routes/open.go b/internal/routes/open.go index 243742a..2c27b4f 100644 --- a/internal/routes/open.go +++ b/internal/routes/open.go @@ -13,9 +13,15 @@ func RegisterOpenAPIRoutes(router fiber.Router, handler *openapiHandler.Handler, const tag = "代理开放接口" const authDescription = `认证方式: 1. 每个请求必须携带 Header:X-Agent-Account、X-Agent-Password、X-Agent-Timestamp、X-Agent-Nonce、X-Agent-Sign。 -2. X-Agent-Timestamp 支持 Unix 秒、Unix 毫秒或 RFC3339 时间,服务端允许 5 分钟时间误差。 -3. X-Agent-Nonce 为随机串,同一账号在 5 分钟内不可重复。 -4. 签名原文按 7 行拼接,行尾使用 \n,最后一行后不追加换行: +2. Header 字段说明: + - X-Agent-Account:代理账号标识,填写代理登录平台使用的用户名或手机号,例如 agent001;该值由平台开通代理账号时提供,必须与签名原文 account 行完全一致。 + - X-Agent-Password:代理账号当前登录密码,由代理账号持有人提供;该值同时作为 HMAC-SHA256 签名密钥,密码变更后必须使用新密码签名。 + - X-Agent-Timestamp:请求发起时间,由调用方生成,支持 Unix 秒、Unix 毫秒或 RFC3339 时间,例如 1715400000、1715400000000 或 2024-05-11T10:00:00+08:00;必须与签名原文 timestamp 行完全一致。 + - X-Agent-Nonce:请求随机串,由调用方每次请求自行生成,例如 UUID、雪花 ID 或“时间戳+随机数”;不需要平台提前分配,同一账号在 5 分钟内不可重复。 + - X-Agent-Sign:请求签名,由调用方按下方规则实时计算得到,不是平台分配的固定值;只要 method、path、query、body、timestamp、nonce、account 或 password 任意一个变化,签名都会变化。 +3. X-Agent-Timestamp 超出允许时间窗口会认证失败;客户端服务器时间应保持同步。 +4. X-Agent-Nonce 重复会被判定为重放请求。 +5. 签名原文按 7 行拼接,行尾使用 \n,最后一行后不追加换行: METHOD PATH canonical_query @@ -23,10 +29,10 @@ body_sha256 timestamp nonce account -5. METHOD 使用大写 HTTP 方法;PATH 只取请求路径,例如 /api/open/v1/cards/status,不包含域名和 query。 -6. canonical_query 为 query 参数规范化结果:排除 sign 字段,参数名升序;同名多值按值升序;key 和 value 使用 URL QueryEscape 编码后用 key=value 拼接,多个参数用 & 连接;无 query 时为空字符串。 -7. body_sha256 为原始请求 body 字节的 SHA256 小写十六进制值;GET 或空 body 使用空字符串的 SHA256;POST JSON 请求必须用最终发送的 body 字符串计算,签名和请求发送的 body 必须完全一致。 -8. X-Agent-Sign = hex(HMAC-SHA256(password, sign_payload)),password 为 X-Agent-Password 的原始值,结果使用小写十六进制。 +6. METHOD 使用大写 HTTP 方法;PATH 只取请求路径,例如 /api/open/v1/cards/status,不包含域名和 query。 +7. canonical_query 为 query 参数规范化结果:排除 sign 字段,参数名升序;同名多值按值升序;key 和 value 使用 URL QueryEscape 编码后用 key=value 拼接,多个参数用 & 连接;无 query 时为空字符串。 +8. body_sha256 为原始请求 body 字节的 SHA256 小写十六进制值;GET 或空 body 使用空字符串的 SHA256;POST JSON 请求必须用最终发送的 body 字符串计算,签名和请求发送的 body 必须完全一致。 +9. X-Agent-Sign = hex(HMAC-SHA256(password, sign_payload)),password 为 X-Agent-Password 的原始值,结果使用小写十六进制。 Node.js 签名示例: diff --git a/pkg/openapi/generator.go b/pkg/openapi/generator.go index 43f8a08..cbc705f 100644 --- a/pkg/openapi/generator.go +++ b/pkg/openapi/generator.go @@ -64,11 +64,11 @@ func (g *Generator) addBearerAuth() { // addAgentOpenAPIAuth 添加代理开放接口 Header 签名认证定义 func (g *Generator) addAgentOpenAPIAuth() { - g.addAPIKeyHeaderAuth(agentOpenAPIAccountSecurityName, "X-Agent-Account", "代理账号用户名或手机号") - g.addAPIKeyHeaderAuth(agentOpenAPIPasswordSecurityName, "X-Agent-Password", "代理账号登录密码") - g.addAPIKeyHeaderAuth(agentOpenAPITimestampSecurityName, "X-Agent-Timestamp", "Unix 秒、Unix 毫秒或 RFC3339 时间") - g.addAPIKeyHeaderAuth(agentOpenAPINonceSecurityName, "X-Agent-Nonce", "随机串,同一账号在 5 分钟内不可重复") - g.addAPIKeyHeaderAuth(agentOpenAPISignSecurityName, "X-Agent-Sign", "请求签名") + g.addAPIKeyHeaderAuth(agentOpenAPIAccountSecurityName, "X-Agent-Account", "代理账号标识,填写代理登录平台使用的用户名或手机号,例如 agent001。") + g.addAPIKeyHeaderAuth(agentOpenAPIPasswordSecurityName, "X-Agent-Password", "代理账号当前登录密码,同时作为 HMAC-SHA256 签名密钥;密码变更后必须使用新密码签名。") + g.addAPIKeyHeaderAuth(agentOpenAPITimestampSecurityName, "X-Agent-Timestamp", "调用方生成的请求发起时间,支持 Unix 秒、Unix 毫秒或 RFC3339 时间,必须与签名原文 timestamp 行一致。") + g.addAPIKeyHeaderAuth(agentOpenAPINonceSecurityName, "X-Agent-Nonce", "调用方每次请求自行生成的随机串,不需要平台提前分配;同一账号在 5 分钟内不可重复。") + g.addAPIKeyHeaderAuth(agentOpenAPISignSecurityName, "X-Agent-Sign", "调用方按签名规则实时计算的 HMAC-SHA256 小写十六进制签名,不是固定值。") } // addAPIKeyHeaderAuth 添加一个 Header 类型的 API Key 认证定义 diff --git a/tests/agent_open_api/README.md b/tests/agent_open_api/README.md new file mode 100644 index 0000000..c7d18c8 --- /dev/null +++ b/tests/agent_open_api/README.md @@ -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 + +# 查询单卡状态 +go run ./tests/agent_open_api -action card-status -card-no + +# 查询单卡流量 +go run ./tests/agent_open_api -action card-traffic -card-no + +# 查询预充值钱包流水 +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。 diff --git a/tests/agent_open_api/main.go b/tests/agent_open_api/main.go new file mode 100644 index 0000000..53bde1c --- /dev/null +++ b/tests/agent_open_api/main.go @@ -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()) +}