From b6d21b81e3419fe366cf433a38e04d34ae20fe69 Mon Sep 17 00:00:00 2001 From: huang Date: Mon, 11 May 2026 11:59:36 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=87=E6=A1=A3=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/model/dto/agent_open_api_dto.go | 4 +- internal/routes/open.go | 145 ++++++++++++++++------- internal/routes/registry.go | 19 +-- pkg/openapi/generator.go | 63 +++++++++- 4 files changed, 174 insertions(+), 57 deletions(-) diff --git a/internal/model/dto/agent_open_api_dto.go b/internal/model/dto/agent_open_api_dto.go index 5221c09..f6a7eca 100644 --- a/internal/model/dto/agent_open_api_dto.go +++ b/internal/model/dto/agent_open_api_dto.go @@ -66,8 +66,8 @@ type AgentOpenAPIPackageItem struct { SeriesName string `json:"series_name" description:"套餐系列名称"` PackageType string `json:"package_type" description:"套餐类型 (formal:正式套餐, addon:附加套餐)"` PackageTypeName string `json:"package_type_name" description:"套餐类型名称(中文)"` - CostPrice int64 `json:"cost_price" description:"成本价(分)"` - RetailPrice int64 `json:"retail_price" description:"生效零售价(分)"` + CostPrice int64 `json:"cost_price" description:"套餐结算价(分)"` + RetailPrice int64 `json:"retail_price" description:"套餐销售价(分)"` TotalFlowMB int64 `json:"total_flow_mb" description:"总流量(MB)"` ValidDays int `json:"valid_days" description:"有效天数"` } diff --git a/internal/routes/open.go b/internal/routes/open.go index b7577c8..243742a 100644 --- a/internal/routes/open.go +++ b/internal/routes/open.go @@ -11,61 +11,122 @@ import ( // RegisterOpenAPIRoutes 注册代理开放接口路由 func RegisterOpenAPIRoutes(router fiber.Router, handler *openapiHandler.Handler, doc *openapi.Generator, basePath string) { const tag = "代理开放接口" - const authDescription = "认证 Header:X-Agent-Account、X-Agent-Password、X-Agent-Timestamp、X-Agent-Nonce、X-Agent-Sign。签名算法为 hex(HMAC-SHA256(password, sign_payload))。所有启用代理账号默认可调用,不需要开放接口账号授权表。" + 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,最后一行后不追加换行: +METHOD +PATH +canonical_query +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 的原始值,结果使用小写十六进制。 + +Node.js 签名示例: + +` + "```javascript\n" + `const crypto = require("crypto"); + +function queryEscape(value) { + return new URLSearchParams({ v: String(value) }).toString().slice(2); +} + +function canonicalQuery(params) { + return Object.entries(params || {}) + .filter(([key]) => key.toLowerCase() !== "sign") + .flatMap(([key, value]) => { + const values = Array.isArray(value) ? value : [value]; + return values.sort().map((item) => [key, item]); + }) + .sort(([aKey, aValue], [bKey, bValue]) => { + if (aKey === bKey) return String(aValue).localeCompare(String(bValue)); + return aKey.localeCompare(bKey); + }) + .map(([key, value]) => queryEscape(key) + "=" + queryEscape(value)) + .join("&"); +} + +function buildAgentSign({ method, path, query, body, timestamp, nonce, account, password }) { + const bodyText = body || ""; + const bodyHash = crypto.createHash("sha256").update(bodyText).digest("hex"); + const signPayload = [ + method.toUpperCase(), + path, + canonicalQuery(query), + bodyHash, + timestamp, + nonce, + account, + ].join("\n"); + return crypto.createHmac("sha256", password).update(signPayload).digest("hex"); +} +` + "```" Register(router, doc, basePath, "GET", "/cards/traffic", handler.GetCardTraffic, RouteSpec{ - Summary: "查询单卡流量", - Description: authDescription + "\n\n按 card_no 查询代理权限范围内单卡的当前生效套餐、待生效套餐和真实业务口径流量。不返回虚流量、倍率、停机阈值等内部字段。", - Input: new(dto.AgentOpenAPICardQueryRequest), - Output: new(dto.AgentOpenAPICardTrafficResponse), - Tags: []string{tag}, - Auth: true, + Summary: "查询单卡流量", + Description: authDescription + "\n\n按 card_no 查询单卡流量、当前套餐和待生效套餐。card_no 支持 ICCID、虚拟号、MSISDN。", + Input: new(dto.AgentOpenAPICardQueryRequest), + Output: new(dto.AgentOpenAPICardTrafficResponse), + Tags: []string{tag}, + Auth: true, + SecurityScheme: openapi.SecuritySchemeAgentOpenAPI, }) Register(router, doc, basePath, "GET", "/cards/status", handler.GetCardStatus, RouteSpec{ - Summary: "查询单卡状态", - Description: authDescription + "\n\n按 card_no 查询代理权限范围内单卡状态,network_status=1 返回正常,network_status=0 返回停机和停机原因。", - Input: new(dto.AgentOpenAPICardQueryRequest), - Output: new(dto.AgentOpenAPICardStatusResponse), - Tags: []string{tag}, - Auth: true, + Summary: "查询单卡状态", + Description: authDescription + "\n\n按 card_no 查询单卡状态,返回正常或停机以及停机原因。", + Input: new(dto.AgentOpenAPICardQueryRequest), + Output: new(dto.AgentOpenAPICardStatusResponse), + Tags: []string{tag}, + Auth: true, + SecurityScheme: openapi.SecuritySchemeAgentOpenAPI, }) Register(router, doc, basePath, "GET", "/cards/realname-status", handler.GetRealnameStatus, RouteSpec{ - Summary: "查询单卡实名状态", - Description: authDescription + "\n\n按 card_no 查询代理权限范围内单卡实名状态。", - Input: new(dto.AgentOpenAPICardQueryRequest), - Output: new(dto.AgentOpenAPIRealnameStatusResponse), - Tags: []string{tag}, - Auth: true, + Summary: "查询单卡实名状态", + Description: authDescription + "\n\n按 card_no 查询单卡实名状态。", + Input: new(dto.AgentOpenAPICardQueryRequest), + Output: new(dto.AgentOpenAPIRealnameStatusResponse), + Tags: []string{tag}, + Auth: true, + SecurityScheme: openapi.SecuritySchemeAgentOpenAPI, }) Register(router, doc, basePath, "GET", "/packages", handler.ListPackages, RouteSpec{ - Summary: "查询代理套餐列表", - Description: authDescription + "\n\n分页查询当前代理已分配、启用、上架且非赠送的可购买套餐,返回代理生效零售价。", - Input: new(dto.AgentOpenAPIPackageListRequest), - Output: new(dto.AgentOpenAPIPackageItem), - Tags: []string{tag}, - Auth: true, + Summary: "查询套餐列表", + Description: authDescription + "\n\n分页查询当前账号可购买的套餐,返回套餐基础信息和价格。", + Input: new(dto.AgentOpenAPIPackageListRequest), + Output: new(dto.AgentOpenAPIPackageItem), + Tags: []string{tag}, + Auth: true, + SecurityScheme: openapi.SecuritySchemeAgentOpenAPI, }) Register(router, doc, basePath, "POST", "/wallet/package-orders", handler.CreateWalletPackageOrders, RouteSpec{ - Summary: "钱包套餐购买", - Description: authDescription + "\n\n使用代理预充值主钱包为多张卡购买同一个套餐编码。每张卡独立下单,允许部分成功。", - Input: new(dto.AgentOpenAPIWalletPackageOrderRequest), - Output: new(dto.AgentOpenAPIWalletPackageOrderResponse), - Tags: []string{tag}, - Auth: true, + Summary: "钱包套餐购买", + Description: authDescription + "\n\n使用预充值钱包为多张卡购买同一个套餐编码。每张卡独立下单,允许部分成功。", + Input: new(dto.AgentOpenAPIWalletPackageOrderRequest), + Output: new(dto.AgentOpenAPIWalletPackageOrderResponse), + Tags: []string{tag}, + Auth: true, + SecurityScheme: openapi.SecuritySchemeAgentOpenAPI, }) Register(router, doc, basePath, "GET", "/wallet/balance", handler.GetWalletBalance, RouteSpec{ - Summary: "查询预充值钱包余额", - Description: authDescription + "\n\n查询当前代理预充值主钱包余额。主钱包不存在时返回 0。", - Output: new(dto.AgentOpenAPIWalletBalanceResponse), - Tags: []string{tag}, - Auth: true, + Summary: "查询预充值钱包余额", + Description: authDescription + "\n\n查询当前账号预充值钱包余额。未开通钱包时返回 0。", + Output: new(dto.AgentOpenAPIWalletBalanceResponse), + Tags: []string{tag}, + Auth: true, + SecurityScheme: openapi.SecuritySchemeAgentOpenAPI, }) Register(router, doc, basePath, "GET", "/wallet/transactions", handler.ListWalletTransactions, RouteSpec{ - Summary: "查询预充值钱包流水", - Description: authDescription + "\n\n分页查询当前代理预充值主钱包流水,字段与后台主钱包流水接口保持一致。", - Input: new(dto.AgentOpenAPIWalletTransactionListRequest), - Output: new(dto.MainWalletTransactionItem), - Tags: []string{tag}, - Auth: true, + Summary: "查询预充值钱包流水", + Description: authDescription + "\n\n分页查询当前账号预充值钱包流水。", + Input: new(dto.AgentOpenAPIWalletTransactionListRequest), + Output: new(dto.MainWalletTransactionItem), + Tags: []string{tag}, + Auth: true, + SecurityScheme: openapi.SecuritySchemeAgentOpenAPI, }) } diff --git a/internal/routes/registry.go b/internal/routes/registry.go index eeec90e..a1b0551 100644 --- a/internal/routes/registry.go +++ b/internal/routes/registry.go @@ -17,14 +17,15 @@ type FileUploadField struct { // RouteSpec 定义接口文档元数据 type RouteSpec struct { - Summary string // 简短摘要(中文,一行) - Description string // 详细说明,支持 Markdown 语法(可选) - Input interface{} // 请求参数结构体 (Query/Path/Body) - Body interface{} // 显式 JSON 请求体(用于 DELETE 等方法,库不自动生成 requestBody 的场景) - Output interface{} // 响应参数结构体 - Tags []string // 分类标签 - Auth bool // 是否需要认证图标 (预留) - FileUploads []FileUploadField // 文件上传字段列表(设置此字段时请求类型为 multipart/form-data) + Summary string // 简短摘要(中文,一行) + Description string // 详细说明,支持 Markdown 语法(可选) + Input interface{} // 请求参数结构体 (Query/Path/Body) + Body interface{} // 显式 JSON 请求体(用于 DELETE 等方法,库不自动生成 requestBody 的场景) + Output interface{} // 响应参数结构体 + Tags []string // 分类标签 + Auth bool // 是否需要认证响应和认证标记 + SecurityScheme string // OpenAPI 认证方案,空值表示 BearerAuth + FileUploads []FileUploadField // 文件上传字段列表(设置此字段时请求类型为 multipart/form-data) } // pathParamRegex 用于匹配 Fiber 的路径参数格式 /:param @@ -55,7 +56,7 @@ func Register(router fiber.Router, doc *openapi.Generator, basePath, method, pat } doc.AddMultipartOperation(method, openapiPath, spec.Summary, spec.Description, spec.Input, spec.Output, spec.Auth, fileFields, spec.Tags...) } else { - doc.AddOperation(method, openapiPath, spec.Summary, spec.Description, spec.Input, spec.Body, spec.Output, spec.Auth, spec.Tags...) + doc.AddOperationWithSecurity(method, openapiPath, spec.Summary, spec.Description, spec.Input, spec.Body, spec.Output, spec.Auth, spec.SecurityScheme, spec.Tags...) } } } diff --git a/pkg/openapi/generator.go b/pkg/openapi/generator.go index f3ea5f8..43f8a08 100644 --- a/pkg/openapi/generator.go +++ b/pkg/openapi/generator.go @@ -17,6 +17,17 @@ type Generator struct { Reflector *openapi3.Reflector } +// SecuritySchemeAgentOpenAPI 表示代理开放接口 Header 签名认证方案 +const SecuritySchemeAgentOpenAPI = "agent_open_api" + +const ( + agentOpenAPIAccountSecurityName = "AgentOpenAPIAccount" + agentOpenAPIPasswordSecurityName = "AgentOpenAPIPassword" + agentOpenAPITimestampSecurityName = "AgentOpenAPITimestamp" + agentOpenAPINonceSecurityName = "AgentOpenAPINonce" + agentOpenAPISignSecurityName = "AgentOpenAPISign" +) + // NewGenerator 创建一个新的生成器实例 func NewGenerator(title, version string) *Generator { reflector := openapi3.Reflector{} @@ -30,6 +41,7 @@ func NewGenerator(title, version string) *Generator { g := &Generator{Reflector: &reflector} g.addBearerAuth() + g.addAgentOpenAPIAuth() return g } @@ -50,6 +62,31 @@ func (g *Generator) addBearerAuth() { g.addErrorResponseSchema() } +// 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", "请求签名") +} + +// addAPIKeyHeaderAuth 添加一个 Header 类型的 API Key 认证定义 +func (g *Generator) addAPIKeyHeaderAuth(name, header, description string) { + g.Reflector.Spec.ComponentsEns().SecuritySchemesEns().WithMapOfSecuritySchemeOrRefValuesItem( + name, + openapi3.SecuritySchemeOrRef{ + SecurityScheme: &openapi3.SecurityScheme{ + APIKeySecurityScheme: &openapi3.APIKeySecurityScheme{ + Name: header, + In: openapi3.APIKeySecuritySchemeInHeader, + Description: &description, + }, + }, + }, + ) +} + // addErrorResponseSchema 添加错误响应 Schema 定义 func (g *Generator) addErrorResponseSchema() { objectType := openapi3.SchemaType("object") @@ -121,6 +158,11 @@ type FileUploadField struct { // - tags: 标签列表 // - requiresAuth: 是否需要认证 func (g *Generator) AddOperation(method, path, summary, description string, input interface{}, body interface{}, output interface{}, requiresAuth bool, tags ...string) { + g.AddOperationWithSecurity(method, path, summary, description, input, body, output, requiresAuth, "", tags...) +} + +// AddOperationWithSecurity 添加操作并支持指定 OpenAPI 认证方案 +func (g *Generator) AddOperationWithSecurity(method, path, summary, description string, input interface{}, body interface{}, output interface{}, requiresAuth bool, securityScheme string, tags ...string) { op := openapi3.Operation{ Summary: &summary, Tags: tags, @@ -138,7 +180,7 @@ func (g *Generator) AddOperation(method, path, summary, description string, inpu } } - // u663eu5f0fu4f20u5165u7684 JSON bodyuff0cu7528u4e8e DELETE u7b49u65b9u6cd5u4e0du81eau52a8u751fu6210 requestBody u7684u573au666f + // 显式传入的 JSON body,用于 DELETE 等方法不自动生成 requestBody 的场景 if body != nil { tempOp := openapi3.Operation{} if err := g.Reflector.SetRequest(&tempOp, body, "POST"); err != nil { @@ -157,7 +199,7 @@ func (g *Generator) AddOperation(method, path, summary, description string, inpu // 添加认证要求 if requiresAuth { - g.addSecurityRequirement(&op) + g.addSecurityRequirement(&op, securityScheme) } // 添加标准错误响应 @@ -251,7 +293,7 @@ func (g *Generator) AddMultipartOperation(method, path, summary, description str } if requiresAuth { - g.addSecurityRequirement(&op) + g.addSecurityRequirement(&op, "") } g.addStandardErrorResponses(&op, requiresAuth) @@ -398,7 +440,20 @@ func (g *Generator) setEnvelopeResponse(op *openapi3.Operation, output interface } // addSecurityRequirement 为操作添加认证要求 -func (g *Generator) addSecurityRequirement(op *openapi3.Operation) { +func (g *Generator) addSecurityRequirement(op *openapi3.Operation, securityScheme string) { + if securityScheme == SecuritySchemeAgentOpenAPI { + op.Security = []map[string][]string{ + { + agentOpenAPIAccountSecurityName: {}, + agentOpenAPIPasswordSecurityName: {}, + agentOpenAPITimestampSecurityName: {}, + agentOpenAPINonceSecurityName: {}, + agentOpenAPISignSecurityName: {}, + }, + } + return + } + op.Security = []map[string][]string{ {"BearerAuth": {}}, }