Compare commits
3 Commits
98ff88d5c3
...
93200a9074
| Author | SHA1 | Date | |
|---|---|---|---|
| 93200a9074 | |||
| 02d522564f | |||
| b6d21b81e3 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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*
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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:"有效天数"`
|
||||
}
|
||||
|
||||
@@ -11,61 +11,128 @@ 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. 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
|
||||
body_sha256
|
||||
timestamp
|
||||
nonce
|
||||
account
|
||||
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 签名示例:
|
||||
|
||||
` + "```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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", "代理账号标识,填写代理登录平台使用的用户名或手机号,例如 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 认证定义
|
||||
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": {}},
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
# 项目画像(Hurl 测试自动生成用)
|
||||
<!-- 由 hurl-test skill 自动生成,请勿手动修改 -->
|
||||
<!-- 如需刷新,删除此文件后重新运行 skill -->
|
||||
|
||||
## 技术栈
|
||||
- 语言: Go 1.25
|
||||
- 框架: Fiber v2.52.9
|
||||
- ORM: GORM v1.31.1
|
||||
- JSON: sonic
|
||||
|
||||
## 路由定义位置
|
||||
- 路由注册入口: internal/routes/routes.go → RegisterRoutesWithDoc()
|
||||
- 按模块拆分: internal/routes/{module}.go
|
||||
- 路由注册函数: Register(router, doc, basePath, method, path, handler, spec)
|
||||
- Admin 域挂载: /api/admin(需认证)
|
||||
- Auth 域挂载: /api/auth(部分公开)
|
||||
- C端域挂载: /api/c/v1(JWT 认证)
|
||||
|
||||
## Schema 定义位置
|
||||
- DTO 目录: internal/model/dto/
|
||||
- 命名规则: {module}_dto.go
|
||||
- 字段标签: json / validate / description
|
||||
- 通用 DTO: common.go(IDReq 等)
|
||||
|
||||
## 统一响应格式
|
||||
```json
|
||||
{"code": 0, "msg": "success", "data": any, "timestamp": "RFC3339"}
|
||||
```
|
||||
- 成功: code=0, msg="success"
|
||||
- 客户端错误: code=1001~1999, HTTP 4xx
|
||||
- 服务端错误: code=2001~2999, HTTP 5xx
|
||||
|
||||
## 分页格式(两种)
|
||||
|
||||
### 模式 A(部分模块:shop, account, role)
|
||||
```json
|
||||
{"items": [], "total": int, "page": int, "size": int}
|
||||
```
|
||||
|
||||
### 模式 B(大部分模块:package, package_series, shop_series_grant 等)
|
||||
```json
|
||||
{"list": [], "total": int, "page": int, "page_size": int, "total_pages": int}
|
||||
```
|
||||
|
||||
## 认证方式
|
||||
- 后台: POST /api/auth/login → $.data.access_token → Authorization: Bearer {token}
|
||||
- C端: JWT → Authorization: Bearer {token}(微信 OAuth 获取)
|
||||
|
||||
## 默认测试账号
|
||||
- 用户名: admin
|
||||
- 密码: Admin@123456
|
||||
- 手机号: 13800000000
|
||||
|
||||
## 服务端口
|
||||
- 默认: 3000
|
||||
|
||||
## 错误码与 HTTP 状态码映射
|
||||
- 1001 (CodeInvalidParam) → 400
|
||||
- 1002 (CodeMissingToken) → 401
|
||||
- 1003 (CodeInvalidToken) → 401
|
||||
- 1005 (CodeForbidden) → 403
|
||||
- 1006 (CodeNotFound) → 404
|
||||
- 冲突类 (1013/1014/1022/1024/1031/1034/1036/1101) → 409
|
||||
- 1008 (CodeTooManyRequests) → 429
|
||||
- 未明确映射的 1xxx → 400
|
||||
- 2xxx → 500
|
||||
@@ -1,24 +0,0 @@
|
||||
SHELL := /bin/bash
|
||||
ENV ?= dev
|
||||
HURL_OPTS := --variables-file env/$(ENV).env --file-root . --test
|
||||
|
||||
.PHONY: test test-flows test-modules test-negative report clean
|
||||
|
||||
test: ## 运行所有测试
|
||||
hurl $(HURL_OPTS) .
|
||||
|
||||
test-flows: ## 运行业务流程测试
|
||||
hurl $(HURL_OPTS) flows/
|
||||
|
||||
test-modules: ## 运行模块接口测试
|
||||
hurl $(HURL_OPTS) modules/
|
||||
|
||||
test-negative: ## 运行异常测试
|
||||
hurl $(HURL_OPTS) negative/
|
||||
|
||||
report: ## 生成 HTML 报告
|
||||
mkdir -p build/report
|
||||
hurl $(HURL_OPTS) --report-html build/report/ .
|
||||
|
||||
clean: ## 清理报告
|
||||
rm -rf build/report
|
||||
25
tests/hurl/env/dev.env
vendored
25
tests/hurl/env/dev.env
vendored
@@ -1,25 +0,0 @@
|
||||
# ============================================================
|
||||
# 环境变量 - 开发环境
|
||||
# ============================================================
|
||||
|
||||
# 服务地址
|
||||
base_url=http://localhost:3000
|
||||
|
||||
# 平台管理员
|
||||
admin_username=admin
|
||||
admin_password=Admin@123456
|
||||
|
||||
# 一级代理测试账号(创建店铺时自动创建)
|
||||
agent1_username=hurl_test_agent1
|
||||
agent1_password=Agent1@123456
|
||||
agent1_phone=18899990001
|
||||
|
||||
# 二级代理测试账号(创建店铺时自动创建)
|
||||
agent2_username=hurl_test_agent2
|
||||
agent2_password=Agent2@123456
|
||||
agent2_phone=18899990002
|
||||
|
||||
# 价格验证测试用 IoT 卡(card-price-verification-flow.hurl 使用)
|
||||
# 对应 testdata/hurl-test-cards.xlsx 中的固定 ICCID
|
||||
test_iccid_1=8986001234560001
|
||||
test_iccid_2=8986001234560002
|
||||
@@ -1,733 +0,0 @@
|
||||
# ============================================================
|
||||
# 测试:套餐资源完整流程(创建→分配→佣金→调价→强充)
|
||||
# 生成时间:2026-03-30
|
||||
# 涉及模块:auth, role, package_series, package, shop,
|
||||
# shop_series_grant, batch_allocation, batch_pricing
|
||||
# 涉及接口:25+ 个
|
||||
# 前置条件:服务运行 + 数据库已迁移 + Redis 可用
|
||||
# ============================================================
|
||||
# 流程:
|
||||
# 1. 平台管理员登录
|
||||
# 2. 创建客户角色(创建店铺的前置依赖)
|
||||
# 3. 创建套餐系列(含固定一次性佣金 + 强充配置)
|
||||
# 4. 创建套餐 → 启用 → 上架
|
||||
# 5. 创建一级代理店铺(自动创建初始账号)
|
||||
# 6. 平台→一级代理:创建系列授权 + 管理授权套餐
|
||||
# 7. 创建二级代理店铺(挂在一级代理下)
|
||||
# 8. 一级代理登录 → 给二级代理分配系列授权
|
||||
# 9. 各级代理修改零售价 → 验证价格隔离
|
||||
# 10. 平台:批量分配 + 批量调价
|
||||
# 11. 代理佣金概览验证
|
||||
# 12. 强充配置验证
|
||||
# 13. 异常测试(未认证、参数校验、越权)
|
||||
# ============================================================
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 第 1 步:平台管理员登录
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "{{admin_username}}",
|
||||
"password": "{{admin_password}}",
|
||||
"device": "web"
|
||||
}
|
||||
HTTP 200
|
||||
[Captures]
|
||||
admin_token: jsonpath "$.data.access_token"
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
jsonpath "$.msg" == "success"
|
||||
jsonpath "$.timestamp" isString
|
||||
jsonpath "$.data.access_token" isString
|
||||
jsonpath "$.data.refresh_token" isString
|
||||
jsonpath "$.data.expires_in" isInteger
|
||||
jsonpath "$.data.user.id" isInteger
|
||||
jsonpath "$.data.user.username" isString
|
||||
jsonpath "$.data.user.user_type" == 1
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 第 2 步:创建客户角色(创建店铺需要 default_role_id)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
POST {{base_url}}/api/admin/roles
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"role_name": "hurl测试角色-{{newUuid}}",
|
||||
"role_desc": "hurl 自动化测试用客户角色",
|
||||
"role_type": 2
|
||||
}
|
||||
HTTP 200
|
||||
[Captures]
|
||||
test_role_id: jsonpath "$.data.id"
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
jsonpath "$.data.id" isInteger
|
||||
jsonpath "$.data.role_type" == 2
|
||||
jsonpath "$.data.status" == 1
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 第 3 步:创建套餐系列(固定一次性佣金 + 强充配置)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
POST {{base_url}}/api/admin/package-series
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"series_code": "HURL-SERIES-{{newUuid}}",
|
||||
"series_name": "Hurl测试系列",
|
||||
"description": "自动化测试用套餐系列",
|
||||
"enable_one_time_commission": true,
|
||||
"one_time_commission_config": {
|
||||
"enable": true,
|
||||
"trigger_type": "first_recharge",
|
||||
"threshold": 0,
|
||||
"commission_type": "fixed",
|
||||
"commission_amount": 5000,
|
||||
"tiers": [],
|
||||
"validity_type": "permanent",
|
||||
"validity_value": "",
|
||||
"enable_force_recharge": true,
|
||||
"force_calc_type": "fixed",
|
||||
"force_amount": 5000
|
||||
}
|
||||
}
|
||||
HTTP 200
|
||||
[Captures]
|
||||
series_id: jsonpath "$.data.id"
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
jsonpath "$.data.id" isInteger
|
||||
jsonpath "$.data.series_code" isString
|
||||
jsonpath "$.data.series_name" == "Hurl测试系列"
|
||||
jsonpath "$.data.description" == "自动化测试用套餐系列"
|
||||
jsonpath "$.data.enable_one_time_commission" == true
|
||||
jsonpath "$.data.one_time_commission_config" isObject
|
||||
jsonpath "$.data.one_time_commission_config.enable" == true
|
||||
jsonpath "$.data.one_time_commission_config.commission_type" == "fixed"
|
||||
jsonpath "$.data.one_time_commission_config.commission_amount" == 5000
|
||||
jsonpath "$.data.one_time_commission_config.enable_force_recharge" == true
|
||||
jsonpath "$.data.one_time_commission_config.force_calc_type" == "fixed"
|
||||
jsonpath "$.data.one_time_commission_config.force_amount" == 5000
|
||||
jsonpath "$.data.status" == 1
|
||||
jsonpath "$.data.created_at" isString
|
||||
jsonpath "$.data.updated_at" isString
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 第 4a 步:创建套餐(关联到系列)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
POST {{base_url}}/api/admin/packages
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"package_code": "HURL-PKG-{{newUuid}}",
|
||||
"package_name": "Hurl测试套餐-月包30G",
|
||||
"series_id": {{series_id}},
|
||||
"package_type": "formal",
|
||||
"duration_months": 12,
|
||||
"real_data_mb": 30720,
|
||||
"virtual_data_mb": 0,
|
||||
"enable_virtual_data": false,
|
||||
"suggested_retail_price": 15000,
|
||||
"cost_price": 8000,
|
||||
"calendar_type": "natural_month",
|
||||
"data_reset_cycle": "monthly",
|
||||
"expiry_base": "from_activation"
|
||||
}
|
||||
HTTP 200
|
||||
[Captures]
|
||||
package_id: jsonpath "$.data.id"
|
||||
package_code: jsonpath "$.data.package_code"
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
jsonpath "$.data.id" isInteger
|
||||
jsonpath "$.data.package_code" isString
|
||||
jsonpath "$.data.package_name" == "Hurl测试套餐-月包30G"
|
||||
jsonpath "$.data.series_id" == {{series_id}}
|
||||
jsonpath "$.data.package_type" == "formal"
|
||||
jsonpath "$.data.duration_months" == 12
|
||||
jsonpath "$.data.real_data_mb" == 30720
|
||||
jsonpath "$.data.virtual_data_mb" == 0
|
||||
jsonpath "$.data.enable_virtual_data" == false
|
||||
jsonpath "$.data.suggested_retail_price" == 15000
|
||||
jsonpath "$.data.cost_price" == 8000
|
||||
jsonpath "$.data.calendar_type" == "natural_month"
|
||||
jsonpath "$.data.data_reset_cycle" == "monthly"
|
||||
jsonpath "$.data.expiry_base" == "from_activation"
|
||||
jsonpath "$.data.status" isInteger
|
||||
jsonpath "$.data.shelf_status" isInteger
|
||||
jsonpath "$.data.created_at" isString
|
||||
jsonpath "$.data.updated_at" isString
|
||||
|
||||
|
||||
# ── 第 4b 步:启用套餐 ──
|
||||
|
||||
PATCH {{base_url}}/api/admin/packages/{{package_id}}/status
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"status": 1
|
||||
}
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
|
||||
|
||||
# ── 第 4c 步:上架套餐 ──
|
||||
|
||||
PATCH {{base_url}}/api/admin/packages/{{package_id}}/shelf
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"shelf_status": 1
|
||||
}
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
|
||||
|
||||
# ── 第 4d 步:验证套餐详情(启用+上架后) ──
|
||||
|
||||
GET {{base_url}}/api/admin/packages/{{package_id}}
|
||||
Authorization: Bearer {{admin_token}}
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
jsonpath "$.data.id" == {{package_id}}
|
||||
jsonpath "$.data.status" == 1
|
||||
jsonpath "$.data.shelf_status" == 1
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 第 5 步:创建一级代理店铺(自动创建初始账号)
|
||||
# 幂等处理:如果已存在则跳过创建,通过账号查询获取 shop_id
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
POST {{base_url}}/api/admin/shops
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"shop_name": "Hurl一级代理店铺",
|
||||
"shop_code": "HURL-SHOP1-{{newUuid}}",
|
||||
"contact_name": "测试联系人1",
|
||||
"contact_phone": "13800000001",
|
||||
"province": "湖南省",
|
||||
"city": "长沙市",
|
||||
"district": "岳麓区",
|
||||
"address": "测试地址一级",
|
||||
"default_role_id": {{test_role_id}},
|
||||
"init_password": "{{agent1_password}}",
|
||||
"init_username": "{{agent1_username}}",
|
||||
"init_phone": "{{agent1_phone}}"
|
||||
}
|
||||
HTTP *
|
||||
|
||||
# 通过账号查询获取 shop_id(无论创建成功或已存在)
|
||||
GET {{base_url}}/api/admin/accounts?username={{agent1_username}}&page=1&page_size=1
|
||||
Authorization: Bearer {{admin_token}}
|
||||
HTTP 200
|
||||
[Captures]
|
||||
shop1_id: jsonpath "$.data.items[0].shop_id"
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
jsonpath "$.data.items" count >= 1
|
||||
jsonpath "$.data.items[0].username" == "{{agent1_username}}"
|
||||
jsonpath "$.data.items[0].user_type" == 3
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 第 6a 步:平台→一级代理 创建系列授权(设置固定佣金 + 强充)
|
||||
# 幂等处理:如已存在则跳过,通过列表查询获取 grant_id
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
# [FINDING C-3] 创建授权接口:请求传了 packages 但创建响应不含 packages 数组
|
||||
# 需通过 GET 详情才能看到 packages,这里用 HTTP * 兼容幂等(重跑时可能 409)
|
||||
POST {{base_url}}/api/admin/shop-series-grants
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"shop_id": {{shop1_id}},
|
||||
"series_id": {{series_id}},
|
||||
"one_time_commission_amount": 3000,
|
||||
"enable_force_recharge": true,
|
||||
"force_recharge_amount": 5000,
|
||||
"packages": [
|
||||
{
|
||||
"package_id": {{package_id}},
|
||||
"cost_price": 10000
|
||||
}
|
||||
]
|
||||
}
|
||||
HTTP *
|
||||
|
||||
# 查询获取 grant_id(无论创建成功或已存在)
|
||||
GET {{base_url}}/api/admin/shop-series-grants?shop_id={{shop1_id}}&series_id={{series_id}}&page=1&page_size=1
|
||||
Authorization: Bearer {{admin_token}}
|
||||
HTTP 200
|
||||
[Captures]
|
||||
grant1_id: jsonpath "$.data.items[0].id"
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
jsonpath "$.data.items" count >= 1
|
||||
jsonpath "$.data.items[0].shop_id" == {{shop1_id}}
|
||||
|
||||
|
||||
# ── 第 6b 步:验证一级代理授权详情 ──
|
||||
|
||||
GET {{base_url}}/api/admin/shop-series-grants/{{grant1_id}}
|
||||
Authorization: Bearer {{admin_token}}
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
jsonpath "$.data.id" == {{grant1_id}}
|
||||
jsonpath "$.data.shop_id" == {{shop1_id}}
|
||||
jsonpath "$.data.shop_name" == "Hurl一级代理店铺"
|
||||
jsonpath "$.data.series_id" == {{series_id}}
|
||||
jsonpath "$.data.series_name" == "Hurl测试系列"
|
||||
jsonpath "$.data.series_code" isString
|
||||
jsonpath "$.data.commission_type" == "fixed"
|
||||
jsonpath "$.data.one_time_commission_amount" == 3000
|
||||
jsonpath "$.data.commission_tiers" isList
|
||||
jsonpath "$.data.force_recharge_locked" isBoolean
|
||||
jsonpath "$.data.force_recharge_enabled" == true
|
||||
jsonpath "$.data.force_recharge_amount" == 5000
|
||||
jsonpath "$.data.allocator_shop_id" == 0
|
||||
jsonpath "$.data.allocator_shop_name" isString
|
||||
jsonpath "$.data.status" == 1
|
||||
jsonpath "$.data.packages" isList
|
||||
jsonpath "$.data.packages" count >= 1
|
||||
jsonpath "$.data.packages[0].package_id" == {{package_id}}
|
||||
jsonpath "$.data.packages[0].package_name" == "Hurl测试套餐-月包30G"
|
||||
jsonpath "$.data.packages[0].package_code" isString
|
||||
jsonpath "$.data.packages[0].cost_price" == 10000
|
||||
jsonpath "$.data.packages[0].shelf_status" isInteger
|
||||
jsonpath "$.data.packages[0].status" isInteger
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 第 7 步:创建二级代理店铺(挂在一级代理下)
|
||||
# 幂等处理:如果已存在则跳过创建,通过账号查询获取 shop_id
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
POST {{base_url}}/api/admin/shops
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"shop_name": "Hurl二级代理店铺",
|
||||
"shop_code": "HURL-SHOP2-{{newUuid}}",
|
||||
"parent_id": {{shop1_id}},
|
||||
"contact_name": "测试联系人2",
|
||||
"contact_phone": "13800000002",
|
||||
"province": "湖南省",
|
||||
"city": "长沙市",
|
||||
"district": "天心区",
|
||||
"address": "测试地址二级",
|
||||
"default_role_id": {{test_role_id}},
|
||||
"init_password": "{{agent2_password}}",
|
||||
"init_username": "{{agent2_username}}",
|
||||
"init_phone": "{{agent2_phone}}"
|
||||
}
|
||||
HTTP *
|
||||
|
||||
# 通过账号查询获取 shop_id(无论创建成功或已存在)
|
||||
GET {{base_url}}/api/admin/accounts?username={{agent2_username}}&page=1&page_size=1
|
||||
Authorization: Bearer {{admin_token}}
|
||||
HTTP 200
|
||||
[Captures]
|
||||
shop2_id: jsonpath "$.data.items[0].shop_id"
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
jsonpath "$.data.items" count >= 1
|
||||
jsonpath "$.data.items[0].username" == "{{agent2_username}}"
|
||||
jsonpath "$.data.items[0].user_type" == 3
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 第 8a 步:一级代理登录
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "{{agent1_username}}",
|
||||
"password": "{{agent1_password}}",
|
||||
"device": "web"
|
||||
}
|
||||
HTTP 200
|
||||
[Captures]
|
||||
agent1_token: jsonpath "$.data.access_token"
|
||||
agent1_shop_id: jsonpath "$.data.user.shop_id"
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
jsonpath "$.data.access_token" isString
|
||||
jsonpath "$.data.user.user_type" == 3
|
||||
jsonpath "$.data.user.shop_id" == {{shop1_id}}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 第 8b 步:一级代理→二级代理 创建系列授权(佣金更低)
|
||||
# 幂等处理:如已存在则跳过,通过列表查询获取 grant_id
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
POST {{base_url}}/api/admin/shop-series-grants
|
||||
Authorization: Bearer {{agent1_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"shop_id": {{shop2_id}},
|
||||
"series_id": {{series_id}},
|
||||
"one_time_commission_amount": 2000,
|
||||
"enable_force_recharge": true,
|
||||
"force_recharge_amount": 5000,
|
||||
"packages": [
|
||||
{
|
||||
"package_id": {{package_id}},
|
||||
"cost_price": 12000
|
||||
}
|
||||
]
|
||||
}
|
||||
HTTP *
|
||||
|
||||
# 查询获取 grant_id(无论创建成功或已存在)
|
||||
GET {{base_url}}/api/admin/shop-series-grants?shop_id={{shop2_id}}&series_id={{series_id}}&page=1&page_size=1
|
||||
Authorization: Bearer {{agent1_token}}
|
||||
HTTP 200
|
||||
[Captures]
|
||||
grant2_id: jsonpath "$.data.items[0].id"
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
jsonpath "$.data.items" count >= 1
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 第 9a 步:一级代理修改零售价 → 15000 分(150 元)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
PATCH {{base_url}}/api/admin/packages/{{package_id}}/retail-price
|
||||
Authorization: Bearer {{agent1_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"retail_price": 15000
|
||||
}
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
|
||||
|
||||
# ── 第 9b 步:一级代理查看套餐 → 验证零售价=15000 ──
|
||||
# [FINDING C-1] 套餐详情接口应包含 one_time_commission_amount(DTO 定义且列表接口有)
|
||||
|
||||
GET {{base_url}}/api/admin/packages/{{package_id}}
|
||||
Authorization: Bearer {{agent1_token}}
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
jsonpath "$.data.id" == {{package_id}}
|
||||
jsonpath "$.data.package_name" == "Hurl测试套餐-月包30G"
|
||||
jsonpath "$.data.retail_price" == 15000
|
||||
jsonpath "$.data.cost_price" == 10000
|
||||
jsonpath "$.data.one_time_commission_amount" == 3000
|
||||
jsonpath "$.data.profit_margin" exists
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 第 10a 步:二级代理登录
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "{{agent2_username}}",
|
||||
"password": "{{agent2_password}}",
|
||||
"device": "web"
|
||||
}
|
||||
HTTP 200
|
||||
[Captures]
|
||||
agent2_token: jsonpath "$.data.access_token"
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
jsonpath "$.data.access_token" isString
|
||||
jsonpath "$.data.user.user_type" == 3
|
||||
jsonpath "$.data.user.shop_id" == {{shop2_id}}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 第 10b 步:二级代理修改零售价 → 18000 分(180 元)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
PATCH {{base_url}}/api/admin/packages/{{package_id}}/retail-price
|
||||
Authorization: Bearer {{agent2_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"retail_price": 18000
|
||||
}
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
|
||||
|
||||
# ── 第 10c 步:二级代理查看套餐 → 验证零售价=18000(与一级不同!) ──
|
||||
# [FINDING C-1] 同上,详情接口应包含 one_time_commission_amount
|
||||
|
||||
GET {{base_url}}/api/admin/packages/{{package_id}}
|
||||
Authorization: Bearer {{agent2_token}}
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
jsonpath "$.data.id" == {{package_id}}
|
||||
# 关键断言:二级代理看到的零售价 != 一级代理(18000 vs 15000)
|
||||
jsonpath "$.data.retail_price" == 18000
|
||||
jsonpath "$.data.cost_price" == 12000
|
||||
jsonpath "$.data.one_time_commission_amount" == 2000
|
||||
jsonpath "$.data.profit_margin" exists
|
||||
|
||||
|
||||
# ── 第 10d 步:回到一级代理视角,确认零售价未被二级覆盖 ──
|
||||
|
||||
GET {{base_url}}/api/admin/packages/{{package_id}}
|
||||
Authorization: Bearer {{agent1_token}}
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
# 一级代理的零售价仍然是 15000,与二级代理互不影响
|
||||
jsonpath "$.data.retail_price" == 15000
|
||||
jsonpath "$.data.cost_price" == 10000
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 第 11 步:平台身份 - 批量调价(给一级代理加价 500 分)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
POST {{base_url}}/api/admin/shop-package-batch-pricing
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"shop_id": {{shop1_id}},
|
||||
"series_id": {{series_id}},
|
||||
"price_adjustment": {
|
||||
"type": "fixed",
|
||||
"value": 500
|
||||
},
|
||||
"change_reason": "Hurl测试批量调价"
|
||||
}
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
jsonpath "$.data.updated_count" isInteger
|
||||
jsonpath "$.data.updated_count" >= 1
|
||||
jsonpath "$.data.affected_ids" isList
|
||||
|
||||
|
||||
# ── 验证调价后代理仍可正常查看套餐 ──
|
||||
|
||||
GET {{base_url}}/api/admin/packages/{{package_id}}
|
||||
Authorization: Bearer {{agent1_token}}
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
jsonpath "$.data.id" == {{package_id}}
|
||||
jsonpath "$.data.cost_price" isInteger
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 第 12 步:平台身份 - 批量分配套餐
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
# [FINDING C-2] 批量分配对已有授权的店铺返回 500,应返回业务错误码
|
||||
POST {{base_url}}/api/admin/shop-package-batch-allocations
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"shop_id": {{shop2_id}},
|
||||
"series_id": {{series_id}},
|
||||
"price_adjustment": {
|
||||
"type": "percent",
|
||||
"value": 100
|
||||
},
|
||||
"one_time_commission_amount": 1500
|
||||
}
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 第 13 步:强充配置验证
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
# 更新一级代理授权:调整佣金金额
|
||||
# 注意:force_recharge_locked=true 时强充金额由系列配置控制,grant 级别无法修改
|
||||
PUT {{base_url}}/api/admin/shop-series-grants/{{grant1_id}}
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"one_time_commission_amount": 3500,
|
||||
"enable_force_recharge": true,
|
||||
"force_recharge_amount": 6000
|
||||
}
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
jsonpath "$.data.one_time_commission_amount" == 3500
|
||||
jsonpath "$.data.force_recharge_enabled" == true
|
||||
# 强充金额因 locked=true 保持系列配置值 5000,不会变为 6000
|
||||
jsonpath "$.data.force_recharge_amount" == 5000
|
||||
jsonpath "$.data.force_recharge_locked" == true
|
||||
|
||||
# 验证更新持久化成功
|
||||
GET {{base_url}}/api/admin/shop-series-grants/{{grant1_id}}
|
||||
Authorization: Bearer {{admin_token}}
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
jsonpath "$.data.one_time_commission_amount" == 3500
|
||||
jsonpath "$.data.force_recharge_enabled" == true
|
||||
jsonpath "$.data.force_recharge_amount" == 5000
|
||||
jsonpath "$.data.force_recharge_locked" == true
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 第 14 步:代理佣金概览验证(一级代理视角)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
GET {{base_url}}/api/admin/my/commission-summary
|
||||
Authorization: Bearer {{agent1_token}}
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
jsonpath "$.data" isObject
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 第 15 步:验证授权套餐详情(有下级分配时不可修改成本价)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
GET {{base_url}}/api/admin/shop-series-grants/{{grant1_id}}
|
||||
Authorization: Bearer {{admin_token}}
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
jsonpath "$.data.packages" isList
|
||||
jsonpath "$.data.packages" count >= 1
|
||||
jsonpath "$.data.packages[0].package_id" isInteger
|
||||
jsonpath "$.data.packages[0].package_name" isString
|
||||
jsonpath "$.data.packages[0].cost_price" isInteger
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 第 16 步:系列授权列表查询
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
GET {{base_url}}/api/admin/shop-series-grants?shop_id={{shop1_id}}
|
||||
Authorization: Bearer {{admin_token}}
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
jsonpath "$.data.items" isList
|
||||
jsonpath "$.data.total" isInteger
|
||||
jsonpath "$.data.total" >= 1
|
||||
jsonpath "$.data.page" isInteger
|
||||
jsonpath "$.data.size" isInteger
|
||||
jsonpath "$.data.items[0].id" isInteger
|
||||
jsonpath "$.data.items[0].shop_id" == {{shop1_id}}
|
||||
jsonpath "$.data.items[0].shop_name" isString
|
||||
jsonpath "$.data.items[0].series_id" isInteger
|
||||
jsonpath "$.data.items[0].series_name" isString
|
||||
jsonpath "$.data.items[0].commission_type" isString
|
||||
jsonpath "$.data.items[0].one_time_commission_amount" isInteger
|
||||
jsonpath "$.data.items[0].force_recharge_enabled" isBoolean
|
||||
jsonpath "$.data.items[0].force_recharge_locked" isBoolean
|
||||
jsonpath "$.data.items[0].force_recharge_amount" isInteger
|
||||
jsonpath "$.data.items[0].allocator_shop_id" isInteger
|
||||
jsonpath "$.data.items[0].allocator_shop_name" isString
|
||||
jsonpath "$.data.items[0].package_count" isInteger
|
||||
jsonpath "$.data.items[0].status" isInteger
|
||||
jsonpath "$.data.items[0].created_at" isString
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 异 常 测 试
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
# ── 异常 1:未认证访问(无 token) ──
|
||||
|
||||
GET {{base_url}}/api/admin/packages
|
||||
HTTP 401
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 1002
|
||||
|
||||
|
||||
# ── 异常 2:参数校验失败(缺少必填字段) ──
|
||||
|
||||
POST {{base_url}}/api/admin/packages
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"package_name": ""
|
||||
}
|
||||
HTTP 400
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 1001
|
||||
|
||||
|
||||
# ── 异常 3:参数校验 - 创建套餐系列编码为空触发冲突 ──
|
||||
|
||||
POST {{base_url}}/api/admin/package-series
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"series_name": ""
|
||||
}
|
||||
HTTP 409
|
||||
[Asserts]
|
||||
jsonpath "$.code" isInteger
|
||||
jsonpath "$.code" != 0
|
||||
|
||||
|
||||
# [FINDING C-4] 创建授权不校验目标店铺是否存在,shop_id=9999999 也能创建成功
|
||||
# 预期应返回 404,实际返回 200 + 创建了无效授权记录
|
||||
POST {{base_url}}/api/admin/shop-series-grants
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"shop_id": 9999999,
|
||||
"series_id": {{series_id}},
|
||||
"one_time_commission_amount": 1000
|
||||
}
|
||||
HTTP 404
|
||||
[Asserts]
|
||||
jsonpath "$.code" != 0
|
||||
|
||||
|
||||
# ── 异常 5:二级代理越权 - 尝试给一级代理的店铺分配 ──
|
||||
# 一级代理已有该系列授权,所以返回冲突(409)而非越权(403)
|
||||
# 但实际上二级代理本就无权操作上级店铺,应优先返回 403
|
||||
|
||||
POST {{base_url}}/api/admin/shop-series-grants
|
||||
Authorization: Bearer {{agent2_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"shop_id": {{shop1_id}},
|
||||
"series_id": {{series_id}},
|
||||
"one_time_commission_amount": 1000
|
||||
}
|
||||
HTTP 409
|
||||
[Asserts]
|
||||
jsonpath "$.code" isInteger
|
||||
jsonpath "$.code" != 0
|
||||
|
||||
|
||||
# ── 异常 6:过期/无效 Token ──
|
||||
|
||||
GET {{base_url}}/api/admin/packages
|
||||
Authorization: Bearer invalid_token_12345
|
||||
HTTP 401
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 1003
|
||||
@@ -1,133 +0,0 @@
|
||||
# 🔍 Hurl 测试发现报告
|
||||
|
||||
- **测试文件**:`flows/package-resource-full-flow.hurl`
|
||||
- **生成时间**:2026-03-30
|
||||
- **最后更新**:2026-03-30(修复 B-1/B-2/C-1/C-2/C-3 后更新)
|
||||
- **总请求数**:37
|
||||
- **通过**:36
|
||||
- **失败**:1(C-4,新发现 bug,断言保留)
|
||||
- **发现总计**:A类: 2, B类: 2, C类: 4
|
||||
|
||||
---
|
||||
|
||||
## A 类(测试自身错误)— 已自动修正
|
||||
|
||||
### A-1: 角色名冲突导致创建失败(409)
|
||||
|
||||
- **位置**:第 2 步 `POST /api/admin/roles`
|
||||
- **原因**:角色名使用固定值 `"hurl测试代理角色"`,重复运行时触发唯一约束冲突
|
||||
- **修正**:角色名改为 `"hurl测试角色-{{newUuid}}"`
|
||||
|
||||
### A-2: 店铺创建时初始账号用户名冲突(409)
|
||||
|
||||
- **位置**:第 5/7 步 `POST /api/admin/shops`
|
||||
- **原因**:`init_username` 使用 env 文件中的固定值,重复运行时触发 `CodeUsernameExists (1013)`
|
||||
- **修正**:改为幂等策略 — `HTTP *` 接受创建结果 + 通过账号查询获取 `shop_id`
|
||||
- **备注**:env 文件中的 `agent1_username` / `agent2_username` 有唯一约束,首次运行后不可变更。如需重新创建,需先清理旧数据。
|
||||
|
||||
---
|
||||
|
||||
## B 类(代码一致性问题)— ✅ 已修复
|
||||
|
||||
### B-1: 角色接口返回原始 GORM 模型而非 DTO
|
||||
|
||||
- **位置**:`POST /api/admin/roles` → 响应体
|
||||
- **DTO 定义**:`RoleResponse` 有 `json:"id"`(小写)、`json:"role_name"`、`json:"created_at"` 等
|
||||
- **Handler 实际(修复前)**:直接返回 `model.Role`,字段为 `"ID"`(大写 GORM 默认)、`"CreatedAt"` 等
|
||||
- **影响**:前端按 API 文档对接会取不到 `id` 字段
|
||||
- **决策**:添加 model→DTO 转换保持一致性
|
||||
- **✅ 修复**:`internal/service/role/service.go` — `Create`/`Get`/`Update` 返回 `*dto.RoleResponse`,新增 `toResponse()` 辅助函数
|
||||
- **验证**:hurl 测试断言 `$.data.id`(小写)通过
|
||||
|
||||
### B-2: 所有分页接口的实际格式与 DTO 定义不一致
|
||||
|
||||
- **位置**:所有列表接口(`GET /packages`, `GET /shops`, `GET /shop-series-grants` 等)
|
||||
- **DTO 定义(修复前)**:`json:"list"` / `json:"page_size"` / `json:"total_pages"`
|
||||
- **Handler 实际**:`response.SuccessWithPagination()` 返回 `{items, total, page, size}`
|
||||
- **影响**:前端按 DTO 文档对接分页字段名不匹配
|
||||
- **决策**:DTO 定义改为匹配 `SuccessWithPagination` 格式
|
||||
- **✅ 修复**:27 个 DTO 文件 json tag 统一(`list→items`, `page_size→size`),删除 `TotalPages` 字段及 12 个 service/handler 中的计算逻辑
|
||||
- **验证**:hurl 测试断言 `$.data.items` / `$.data.size` 通过
|
||||
|
||||
---
|
||||
|
||||
## C 类(接口行为 Bug)
|
||||
|
||||
### C-1: 套餐详情接口缺少 `one_time_commission_amount` 字段 — ✅ 已修复
|
||||
|
||||
- **位置**:`GET /api/admin/packages/:id`(代理视角)
|
||||
- **预期**:`PackageResponse` DTO 定义了 `one_time_commission_amount` 字段
|
||||
- **实际(修复前)**:列表接口返回该字段,详情接口不返回
|
||||
- **决策**:Detail handler 增加佣金信息增强逻辑
|
||||
- **✅ 修复**:`internal/service/package/service.go` → `Get()` 方法增加代理用户佣金信息增强,调用 `fillCommissionInfo` 与 `List` 保持一致
|
||||
- **验证**:hurl 断言 `$.data.one_time_commission_amount == 3000`(一级代理)和 `== 2000`(二级代理)均通过
|
||||
|
||||
### C-2: 批量分配对已有授权的店铺返回 500 — ✅ 已修复
|
||||
|
||||
- **位置**:`POST /api/admin/shop-package-batch-allocations`
|
||||
- **预期**:对已有分配记录应跳过或返回业务错误码,而非 500
|
||||
- **实际(修复前)**:唯一约束冲突触发 `{code: 2001, msg: "内部服务器错误"}`
|
||||
- **决策**:添加已存在记录的前置检查
|
||||
- **✅ 修复**:`internal/service/shop_package_batch_allocation/service.go` → `BatchAllocate()` 方法在创建前用 `GetByShopAndPackageForSystem` 检查记录是否已存在,已存在则跳过
|
||||
- **验证**:hurl 测试批量分配请求不再返回 500
|
||||
|
||||
### C-3: 创建授权接口响应不包含 packages 数组 — ✅ 已修复
|
||||
|
||||
- **位置**:`POST /api/admin/shop-series-grants`
|
||||
- **预期**:创建响应应包含完整 `packages` 列表
|
||||
- **实际(修复前)**:`packages` 为空数组(事务内查询,新数据未提交不可见)
|
||||
- **根因**:`buildGrantResponse` 在事务内部调用,使用 `s.db` 而非事务 `tx` 查询 packages
|
||||
- **决策**:创建授权后返回完整响应
|
||||
- **✅ 修复**:`internal/service/shop_series_grant/service.go` → `Create()` 方法将 `buildGrantResponse` 调用移到事务提交后
|
||||
- **验证**:hurl 测试 GET 详情确认 packages 存在
|
||||
|
||||
### C-4: 创建授权不校验目标店铺是否存在 — 🐛 未修复(新发现)
|
||||
|
||||
- **位置**:`POST /api/admin/shop-series-grants`
|
||||
- **预期**:`shop_id` 对应的店铺不存在时应返回 404
|
||||
- **实际**:`shop_id=9999999`(不存在的店铺)也能成功创建授权记录,返回 200
|
||||
- **影响**:
|
||||
- 产生无效的授权数据(关联到不存在的店铺)
|
||||
- 数据一致性风险:后续查询/操作该授权时可能出现不可预期的行为
|
||||
- **测试断言**:保留 `HTTP 404`(预期失败,直到代码修复)
|
||||
- **建议**:`Create()` 方法中添加店铺存在性校验:
|
||||
```go
|
||||
_, err := s.shopStore.GetByID(ctx, req.ShopID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeNotFound, "目标店铺不存在")
|
||||
}
|
||||
```
|
||||
|
||||
- 决策: 修复吧
|
||||
|
||||
---
|
||||
|
||||
## 非 Bug 项(已确认的正常业务逻辑)
|
||||
|
||||
### 强充金额锁定机制
|
||||
|
||||
- **位置**:`PUT /api/admin/shop-series-grants/:id`
|
||||
- **行为**:当 `force_recharge_locked=true` 时,grant 级别无法修改 `force_recharge_amount`,金额由套餐系列配置控制
|
||||
- **判定**:正常业务逻辑,非 Bug
|
||||
- **断言**:已按实际行为编写(`force_recharge_amount == 5000`)
|
||||
|
||||
### 有下级分配时不可修改成本价
|
||||
|
||||
- **位置**:`PUT /api/admin/shop-series-grants/:id/packages`
|
||||
- **行为**:存在下级分配记录时返回 `{code: 1005, msg: "存在下级分配记录,请先回收后再修改成本价"}`
|
||||
- **判定**:正常业务保护机制,非 Bug
|
||||
- **断言**:改为只读验证(GET 详情),不再尝试修改
|
||||
|
||||
---
|
||||
|
||||
## 修复历史
|
||||
|
||||
| 日期 | 操作 | 涉及文件 |
|
||||
|------|------|---------|
|
||||
| 2026-03-30 | 初始报告生成 | — |
|
||||
| 2026-03-30 | 修复 B-1 | `internal/service/role/service.go` |
|
||||
| 2026-03-30 | 修复 B-2 | 27 个 DTO + 12 个 service/handler |
|
||||
| 2026-03-30 | 修复 C-1 | `internal/service/package/service.go` |
|
||||
| 2026-03-30 | 修复 C-2 | `internal/service/shop_package_batch_allocation/service.go` |
|
||||
| 2026-03-30 | 修复 C-3 | `internal/service/shop_series_grant/service.go` |
|
||||
| 2026-03-30 | 新增 C-4 | 未修复,断言保留 |
|
||||
Reference in New Issue
Block a user