修复店铺列表参数校验与企业权限
This commit is contained in:
@@ -4,19 +4,19 @@
|
|||||||
|
|
||||||
**Blocked by:** None — can start immediately.
|
**Blocked by:** None — can start immediately.
|
||||||
|
|
||||||
**Status:** ready-for-agent
|
**Status:** completed
|
||||||
|
|
||||||
**架构通道:** 主通道为现有读取链路 `Handler → Service → Store → GORM/DTO`;不迁移到新的 Query 或 DDD 模块。
|
**架构通道:** 主通道为现有读取链路 `Handler → Service → Store → GORM/DTO`;不迁移到新的 Query 或 DDD 模块。
|
||||||
|
|
||||||
**完整业务边界:** 仅收口店铺列表请求参数校验、错误脱敏和分页一致性。明确不修改其他 Handler 的校验方式,不迁移店铺模块,不改变列表响应结构、已有筛选语义、排序或数据权限。
|
**完整业务边界:** 仅收口店铺列表请求参数校验、错误脱敏和分页一致性。明确不修改其他 Handler 的校验方式,不迁移店铺模块,不改变列表响应结构、已有筛选语义、排序或数据权限。
|
||||||
|
|
||||||
- [ ] 店铺列表在查询参数解析后对整个请求 DTO 执行校验,覆盖分页、名称、编号、上级店铺、层级和状态的既有约束。
|
- [x] 店铺列表在查询参数解析后对整个请求 DTO 执行校验,覆盖分页、名称、编号、上级店铺、层级和状态的既有约束。
|
||||||
- [ ] 查询参数解析失败或任一字段校验失败时,不执行店铺查询;客户端收到 HTTP 400、`code=1001`、`msg=参数验证失败`、`data=null` 和时间戳,响应不包含解析器或 Validator 的具体错误文本。
|
- [x] 查询参数解析失败或任一字段校验失败时,不执行店铺查询;客户端收到 HTTP 400、`code=1001`、`msg=参数验证失败`、`data=null` 和时间戳,响应不包含解析器或 Validator 的具体错误文本。
|
||||||
- [ ] 服务端日志保留请求上下文和具体失败原因,但不记录数据库密码、JWT 密钥或其他敏感配置。
|
- [x] 服务端日志保留请求上下文和具体失败原因,但不记录数据库密码、JWT 密钥或其他敏感配置。
|
||||||
- [ ] 未传 `page` 和 `page_size` 时,查询前归一化为第 1 页、每页 20 条,并在响应中返回 `page=1`、`size=20`。
|
- [x] 未传 `page` 和 `page_size` 时,查询前归一化为第 1 页、每页 20 条,并在响应中返回 `page=1`、`size=20`。
|
||||||
- [ ] 显式提供的合法页码和每页数量原样生效,不因任何筛选条件被后端重置。
|
- [x] 显式提供的合法页码和每页数量原样生效,不因任何筛选条件被后端重置。
|
||||||
- [ ] HTTP 集成测试通过进程内 Fiber App、真实后台认证中间件、真实 PostgreSQL、Redis 和 JWT 配置验证成功及失败响应;测试数据和 Token 状态使用唯一标识并严格清理。
|
- [x] HTTP 集成测试通过进程内 Fiber App、真实后台认证中间件、真实 PostgreSQL、Redis 和 JWT 配置验证成功及失败响应;测试数据和 Token 状态使用唯一标识并严格清理。
|
||||||
- [ ] 集成测试覆盖非法分页、非法层级、非法状态、超长名称或编号、默认分页、显式分页、未认证及无效 Token 等关键行为。
|
- [x] 集成测试覆盖非法分页、非法层级、非法状态、超长名称或编号、默认分页、显式分页、未认证及无效 Token 等关键行为。
|
||||||
- [ ] OpenAPI 准确描述店铺列表已有字段的校验限制和分页契约,生成结果中不出现与本切片无关的漂移。
|
- [x] OpenAPI 准确描述店铺列表已有字段的校验限制和分页契约,生成结果中不出现与本切片无关的漂移。
|
||||||
- [ ] UR#60 中文总结及 README 索引记录本切片的参数错误协议、默认分页行为、验证证据和明确不迁移的旧代码范围。
|
- [x] UR#60 中文总结及 README 索引记录本切片的参数错误协议、默认分页行为、验证证据和明确不迁移的旧代码范围。
|
||||||
- [ ] 运行本切片目标测试、格式化、静态检查和相关构建,确认该行为可独立交付且不破坏原店铺列表语义。
|
- [x] 运行本切片目标测试、格式化、静态检查和相关构建,确认该行为可独立交付且不破坏原店铺列表语义。
|
||||||
|
|||||||
@@ -4,19 +4,19 @@
|
|||||||
|
|
||||||
**Blocked by:** None — can start immediately.
|
**Blocked by:** None — can start immediately.
|
||||||
|
|
||||||
**Status:** ready-for-agent
|
**Status:** completed
|
||||||
|
|
||||||
**架构通道:** 路由与中间件权限通道;列表读取仍沿用现有读取链路。
|
**架构通道:** 路由与中间件权限通道;列表读取仍沿用现有读取链路。
|
||||||
|
|
||||||
**完整业务边界:** 只收口五个核心店铺管理路由的企业账号访问限制。明确不重新设计店铺角色、代理商资金概况、提现、佣金、钱包流水等独立路由的权限,也不改变认证机制或其他账号类型的数据范围。
|
**完整业务边界:** 只收口五个核心店铺管理路由的企业账号访问限制。明确不重新设计店铺角色、代理商资金概况、提现、佣金、钱包流水等独立路由的权限,也不改变认证机制或其他账号类型的数据范围。
|
||||||
|
|
||||||
- [ ] 企业账号访问店铺列表、创建、更新、删除或联级查询时,统一收到 HTTP 403、`code=1005`、`msg=无权限访问店铺管理功能`、`data=null` 和时间戳。
|
- [x] 企业账号访问店铺列表、创建、更新、删除或联级查询时,统一收到 HTTP 403、`code=1005`、`msg=无权限访问店铺管理功能`、`data=null` 和时间戳。
|
||||||
- [ ] 企业账号在进入核心 Handler 和业务查询前被拒绝,不能因没有代理店铺范围而获得全局店铺数据。
|
- [x] 企业账号在进入核心 Handler 和业务查询前被拒绝,不能因没有代理店铺范围而获得全局店铺数据。
|
||||||
- [ ] 超级管理员和平台账号继续拥有原有核心店铺管理能力;代理账号继续遵循本店铺及全部下级店铺的数据范围。
|
- [x] 超级管理员和平台账号继续拥有原有核心店铺管理能力;代理账号继续遵循本店铺及全部下级店铺的数据范围。
|
||||||
- [ ] 访问限制只作用于五个核心店铺管理路由,不误拦截店铺角色、代理商资金概况、提现、佣金、钱包流水等独立路由。
|
- [x] 访问限制只作用于五个核心店铺管理路由,不误拦截店铺角色、代理商资金概况、提现、佣金、钱包流水等独立路由。
|
||||||
- [ ] 真实 HTTP 集成测试覆盖四类账号的核心列表行为,以及企业账号访问其余四个核心路由的 403 行为。
|
- [x] 真实 HTTP 集成测试覆盖四类账号的核心列表行为,以及企业账号访问其余四个核心路由的 403 行为。
|
||||||
- [ ] 回归测试证明独立 `/shops` 前缀路由仍由各自已有权限规则处理,未因路由分组调整产生权限漂移。
|
- [x] 回归测试证明独立 `/shops` 前缀路由仍由各自已有权限规则处理,未因路由分组调整产生权限漂移。
|
||||||
- [ ] 成功和拒绝响应均保持统一 `code`、`msg`、`data`、`timestamp` 外层结构,认证失败仍沿用既有认证错误契约。
|
- [x] 成功和拒绝响应均保持统一 `code`、`msg`、`data`、`timestamp` 外层结构,认证失败仍沿用既有认证错误契约。
|
||||||
- [ ] OpenAPI 或对应接口说明准确记录核心店铺管理的账号权限边界,不改变店铺角色、资金、佣金、提现和钱包流水的既有契约。
|
- [x] OpenAPI 或对应接口说明准确记录核心店铺管理的账号权限边界,不改变店铺角色、资金、佣金、提现和钱包流水的既有契约。
|
||||||
- [ ] UR#60 中文总结及 README 索引记录本切片的五个受限入口、未受影响路由、四类账号验证证据和明确不重新设计的授权范围。
|
- [x] UR#60 中文总结及 README 索引记录本切片的五个受限入口、未受影响路由、四类账号验证证据和明确不重新设计的授权范围。
|
||||||
- [ ] 运行本切片目标测试、格式化、静态检查和相关构建,确认权限切片能够独立发布和回滚。
|
- [x] 运行本切片目标测试、格式化、静态检查和相关构建,确认权限切片能够独立发布和回滚。
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ default:
|
|||||||
- **资产操作审计日志**:新增 `tb_asset_operation_log`,统一覆盖卡/设备敏感写操作与统一资产入口,记录 `success/failed/denied`、前后镜像、请求上下文、批量统计并支持敏感字段脱敏;详见 [功能总结](docs/add-asset-operation-audit-log/功能总结.md)、[接口回放示例](docs/add-asset-operation-audit-log/接口回放示例.md) 与 [SQL 验收脚本](docs/add-asset-operation-audit-log/手工验收脚本.sql)
|
- **资产操作审计日志**:新增 `tb_asset_operation_log`,统一覆盖卡/设备敏感写操作与统一资产入口,记录 `success/failed/denied`、前后镜像、请求上下文、批量统计并支持敏感字段脱敏;详见 [功能总结](docs/add-asset-operation-audit-log/功能总结.md)、[接口回放示例](docs/add-asset-operation-audit-log/接口回放示例.md) 与 [SQL 验收脚本](docs/add-asset-operation-audit-log/手工验收脚本.sql)
|
||||||
- **RBAC 权限系统**:完整的基于角色的访问控制,支持账号、角色、权限的多对多关联和层级关系;基于店铺层级的自动数据权限过滤,实现多租户数据隔离;使用 PostgreSQL WITH RECURSIVE 查询下级店铺并通过 Redis 缓存优化性能;完整的权限检查功能支持路由级别的细粒度权限控制,支持平台过滤(web/h5/all)和超级管理员自动跳过(详见 [功能总结](docs/004-rbac-data-permission/功能总结.md)、[使用指南](docs/004-rbac-data-permission/使用指南.md) 和 [权限检查使用指南](docs/permission-check-usage.md))
|
- **RBAC 权限系统**:完整的基于角色的访问控制,支持账号、角色、权限的多对多关联和层级关系;基于店铺层级的自动数据权限过滤,实现多租户数据隔离;使用 PostgreSQL WITH RECURSIVE 查询下级店铺并通过 Redis 缓存优化性能;完整的权限检查功能支持路由级别的细粒度权限控制,支持平台过滤(web/h5/all)和超级管理员自动跳过(详见 [功能总结](docs/004-rbac-data-permission/功能总结.md)、[使用指南](docs/004-rbac-data-permission/使用指南.md) 和 [权限检查使用指南](docs/permission-check-usage.md))
|
||||||
- **商户管理**:完整的商户(Shop)和商户账号管理功能,支持商户创建时自动创建初始坐席账号、删除商户时批量禁用关联账号、账号密码重置等功能(详见 [使用指南](docs/shop-management/使用指南.md) 和 [API 文档](docs/shop-management/API文档.md))
|
- **商户管理**:完整的商户(Shop)和商户账号管理功能,支持商户创建时自动创建初始坐席账号、删除商户时批量禁用关联账号、账号密码重置等功能(详见 [使用指南](docs/shop-management/使用指南.md) 和 [API 文档](docs/shop-management/API文档.md))
|
||||||
|
- **UR#60 店铺列表参数与权限修复**:店铺列表统一执行查询参数校验并返回一致的默认分页元数据,企业账号禁止访问五个核心店铺管理入口;详见 [功能总结](docs/ur60-shop-phone-search/功能总结.md)。
|
||||||
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制(Access Token 24h + Refresh Token 7天);包含登录、登出、Token 刷新、用户信息查询和密码修改功能;通过用户类型隔离确保后台(SuperAdmin、Platform、Agent)和 H5(Agent、Enterprise)的访问控制;**登录响应包含菜单树和按钮权限**(menus/buttons),前端无需二次处理直接渲染侧边栏和控制按钮显示;详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md)、[架构说明](docs/auth-architecture.md) 和 [菜单权限使用指南](docs/login-menu-button-response/使用指南.md)
|
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制(Access Token 24h + Refresh Token 7天);包含登录、登出、Token 刷新、用户信息查询和密码修改功能;通过用户类型隔离确保后台(SuperAdmin、Platform、Agent)和 H5(Agent、Enterprise)的访问控制;**登录响应包含菜单树和按钮权限**(menus/buttons),前端无需二次处理直接渲染侧边栏和控制按钮显示;详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md)、[架构说明](docs/auth-architecture.md) 和 [菜单权限使用指南](docs/login-menu-button-response/使用指南.md)
|
||||||
- **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)
|
- **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)
|
||||||
- **生命周期管理**:物联网卡/号卡的开卡、激活、停机、复机、销户
|
- **生命周期管理**:物联网卡/号卡的开卡、激活、停机、复机、销户
|
||||||
|
|||||||
@@ -22310,6 +22310,7 @@ paths:
|
|||||||
- 代理系列授权
|
- 代理系列授权
|
||||||
/api/admin/shops:
|
/api/admin/shops:
|
||||||
get:
|
get:
|
||||||
|
description: 仅超级管理员、平台账号和代理账号可访问,企业账号返回 403。分页默认第 1 页、每页 20 条;page 最小为 1,page_size 范围为 1 至 100。所有筛选条件在查询前统一校验。
|
||||||
parameters:
|
parameters:
|
||||||
- description: 页码
|
- description: 页码
|
||||||
in: query
|
in: query
|
||||||
@@ -22421,6 +22422,7 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- 店铺管理
|
- 店铺管理
|
||||||
post:
|
post:
|
||||||
|
description: 仅超级管理员、平台账号和代理账号可访问,企业账号返回 403。
|
||||||
requestBody:
|
requestBody:
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
@@ -22484,6 +22486,7 @@ paths:
|
|||||||
- 店铺管理
|
- 店铺管理
|
||||||
/api/admin/shops/{id}:
|
/api/admin/shops/{id}:
|
||||||
delete:
|
delete:
|
||||||
|
description: 仅超级管理员、平台账号和代理账号可访问,企业账号返回 403。
|
||||||
parameters:
|
parameters:
|
||||||
- description: ID
|
- description: ID
|
||||||
in: path
|
in: path
|
||||||
@@ -22524,6 +22527,7 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- 店铺管理
|
- 店铺管理
|
||||||
put:
|
put:
|
||||||
|
description: 仅超级管理员、平台账号和代理账号可访问,企业账号返回 403。
|
||||||
parameters:
|
parameters:
|
||||||
- description: ID
|
- description: ID
|
||||||
in: path
|
in: path
|
||||||
@@ -23345,6 +23349,7 @@ paths:
|
|||||||
- 代理商资金管理
|
- 代理商资金管理
|
||||||
/api/admin/shops/cascade:
|
/api/admin/shops/cascade:
|
||||||
get:
|
get:
|
||||||
|
description: 仅超级管理员、平台账号和代理账号可访问,企业账号返回 403。
|
||||||
parameters:
|
parameters:
|
||||||
- description: 店铺名称(模糊查询)
|
- description: 店铺名称(模糊查询)
|
||||||
in: query
|
in: query
|
||||||
|
|||||||
54
docs/ur60-shop-phone-search/功能总结.md
Normal file
54
docs/ur60-shop-phone-search/功能总结.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# UR#60 店铺列表参数与权限修复总结
|
||||||
|
|
||||||
|
## 本次交付范围
|
||||||
|
|
||||||
|
本次完成当前可由 Agent 执行的两个纵向切片:
|
||||||
|
|
||||||
|
- Ticket 01:修复 `GET /api/admin/shops` 的完整查询参数校验与默认分页一致性。
|
||||||
|
- Ticket 03:禁止企业账号访问店铺列表、创建、更新、删除和联级查询五个核心店铺管理入口。
|
||||||
|
|
||||||
|
联系电话精确查询、数据库索引和跨仓前端交付属于 Ticket 02。该票当前状态为 `ready-for-human`,本次未实现、未迁移,也未提前修改店铺查询条件或数据库结构。
|
||||||
|
|
||||||
|
## 参数与分页契约
|
||||||
|
|
||||||
|
店铺列表在查询参数解析后执行完整 DTO 校验。解析失败或任一字段违反约束时,客户端统一收到:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1001,
|
||||||
|
"msg": "参数验证失败",
|
||||||
|
"data": null,
|
||||||
|
"timestamp": "RFC3339 时间"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
具体解析或 Validator 错误只写入服务端日志,不返回给客户端。未传分页参数时,请求在进入 Service 前归一化为 `page=1`、`page_size=20`,数据库查询参数与响应中的 `page`、`size` 保持一致;显式合法分页值原样生效。
|
||||||
|
|
||||||
|
## 核心店铺管理权限
|
||||||
|
|
||||||
|
企业账号访问以下入口统一收到 HTTP 403、`code=1005`、`msg=无权限访问店铺管理功能`:
|
||||||
|
|
||||||
|
- `GET /api/admin/shops`
|
||||||
|
- `POST /api/admin/shops`
|
||||||
|
- `PUT /api/admin/shops/:id`
|
||||||
|
- `DELETE /api/admin/shops/:id`
|
||||||
|
- `GET /api/admin/shops/cascade`
|
||||||
|
|
||||||
|
限制通过逐路由 Handler 包装实现,不使用 `/shops` 前缀组中间件,避免误伤店铺角色、资金、佣金、提现和钱包流水等独立路由。超级管理员、平台账号和代理账号保留原有核心列表访问能力,代理账号继续使用既有店铺及下级范围过滤。
|
||||||
|
|
||||||
|
## 架构与迁移边界
|
||||||
|
|
||||||
|
实现继续沿用现有 `Handler → Service → Store → GORM/DTO` 读取链路,只修改请求校验、分页归一化和路由权限边界。未迁移店铺模块到 DDD 或 Query 目录,未修改其他 Handler 的校验方式,也未新增缓存、审计业务日志、幂等控制、异步任务或数据库迁移。
|
||||||
|
|
||||||
|
## 验证证据
|
||||||
|
|
||||||
|
HTTP 集成测试在进程内启动 Fiber App,通过真实 Redis Token 状态、后台认证中间件、真实 PostgreSQL、Handler、Service、Store、GORM 和统一错误处理验证:
|
||||||
|
|
||||||
|
- 非法分页、层级、状态、超长名称或编号及解析失败均返回统一参数错误。
|
||||||
|
- 默认分页返回第 1 页、每页 20 条,显式合法分页不被重置。
|
||||||
|
- 未认证和无效 Token 保持既有认证错误契约。
|
||||||
|
- 企业账号在五个核心入口进入 Handler 前被拒绝。
|
||||||
|
- 超级管理员、平台账号和代理账号保留列表访问能力。
|
||||||
|
- 店铺角色路由未被核心店铺管理权限包装误拦截。
|
||||||
|
|
||||||
|
测试使用唯一 Redis Token 并在结束后清理,不打印 `.env.local` 中的数据库、Redis 或 JWT 敏感配置。
|
||||||
@@ -84,7 +84,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
|||||||
ClientRealname: app.NewClientRealnameHandler(svc.Asset, svc.CustomerBinding, iotCardStore, deviceSimBindingStore, carrierStore, deps.GatewayClient, deps.Logger, svc.PollingManualTrigger),
|
ClientRealname: app.NewClientRealnameHandler(svc.Asset, svc.CustomerBinding, iotCardStore, deviceSimBindingStore, carrierStore, deps.GatewayClient, deps.Logger, svc.PollingManualTrigger),
|
||||||
ClientDevice: app.NewClientDeviceHandler(svc.Asset, svc.CustomerBinding, deviceStore, deviceSimBindingStore, iotCardStore, deps.GatewayClient, deps.Logger),
|
ClientDevice: app.NewClientDeviceHandler(svc.Asset, svc.CustomerBinding, deviceStore, deviceSimBindingStore, iotCardStore, deps.GatewayClient, deps.Logger),
|
||||||
ClientRechargeOrder: app.NewClientRechargeOrderHandler(rechargeOrderStore, paymentStore, deps.Logger),
|
ClientRechargeOrder: app.NewClientRechargeOrderHandler(rechargeOrderStore, paymentStore, deps.Logger),
|
||||||
Shop: admin.NewShopHandler(svc.Shop),
|
Shop: admin.NewShopHandler(svc.Shop, validate),
|
||||||
ShopRole: admin.NewShopRoleHandler(svc.Shop),
|
ShopRole: admin.NewShopRoleHandler(svc.Shop),
|
||||||
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
|
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
|
||||||
ShopCommission: admin.NewShopCommissionHandler(svc.ShopCommission),
|
ShopCommission: admin.NewShopCommissionHandler(svc.ShopCommission),
|
||||||
|
|||||||
@@ -3,27 +3,51 @@ package admin
|
|||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
shopService "github.com/break/junhong_cmp_fiber/internal/service/shop"
|
shopService "github.com/break/junhong_cmp_fiber/internal/service/shop"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ShopHandler 店铺管理处理器。
|
||||||
type ShopHandler struct {
|
type ShopHandler struct {
|
||||||
service *shopService.Service
|
service *shopService.Service
|
||||||
|
validator *validator.Validate
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewShopHandler(service *shopService.Service) *ShopHandler {
|
// NewShopHandler 创建店铺管理处理器。
|
||||||
return &ShopHandler{service: service}
|
func NewShopHandler(service *shopService.Service, validate *validator.Validate) *ShopHandler {
|
||||||
|
return &ShopHandler{service: service, validator: validate}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List 查询店铺列表。
|
||||||
|
// GET /api/admin/shops
|
||||||
func (h *ShopHandler) List(c *fiber.Ctx) error {
|
func (h *ShopHandler) List(c *fiber.Ctx) error {
|
||||||
var req dto.ShopListRequest
|
var req dto.ShopListRequest
|
||||||
if err := c.QueryParser(&req); err != nil {
|
if err := c.QueryParser(&req); err != nil {
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
h.logListValidationFailure(c, err)
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
}
|
}
|
||||||
|
if hasExplicitZeroPagination(c, &req) {
|
||||||
|
err := errors.New(errors.CodeInvalidParam)
|
||||||
|
h.logListValidationFailure(c, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if h.validator == nil {
|
||||||
|
return errors.New(errors.CodeInternalError, "店铺列表校验器未配置")
|
||||||
|
}
|
||||||
|
if err := h.validator.Struct(&req); err != nil {
|
||||||
|
h.logListValidationFailure(c, err)
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeShopListPagination(&req)
|
||||||
|
|
||||||
shops, total, err := h.service.ListShopResponses(c.UserContext(), &req)
|
shops, total, err := h.service.ListShopResponses(c.UserContext(), &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -33,6 +57,31 @@ func (h *ShopHandler) List(c *fiber.Ctx) error {
|
|||||||
return response.SuccessWithPagination(c, shops, total, req.Page, req.PageSize)
|
return response.SuccessWithPagination(c, shops, total, req.Page, req.PageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasExplicitZeroPagination(c *fiber.Ctx, req *dto.ShopListRequest) bool {
|
||||||
|
queryArgs := c.Context().QueryArgs()
|
||||||
|
return queryArgs.Has("page") && req.Page == 0 || queryArgs.Has("page_size") && req.PageSize == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ShopHandler) logListValidationFailure(c *fiber.Ctx, err error) {
|
||||||
|
logger.GetAppLogger().Warn("店铺列表参数验证失败",
|
||||||
|
zap.String("method", c.Method()),
|
||||||
|
zap.String("path", c.Path()),
|
||||||
|
zap.String("query", c.Context().QueryArgs().String()),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeShopListPagination(req *dto.ShopListRequest) {
|
||||||
|
if req.Page == 0 {
|
||||||
|
req.Page = 1
|
||||||
|
}
|
||||||
|
if req.PageSize == 0 {
|
||||||
|
req.PageSize = constants.DefaultPageSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建店铺。
|
||||||
|
// POST /api/admin/shops
|
||||||
func (h *ShopHandler) Create(c *fiber.Ctx) error {
|
func (h *ShopHandler) Create(c *fiber.Ctx) error {
|
||||||
var req dto.CreateShopRequest
|
var req dto.CreateShopRequest
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
@@ -47,6 +96,8 @@ func (h *ShopHandler) Create(c *fiber.Ctx) error {
|
|||||||
return response.Success(c, shop)
|
return response.Success(c, shop)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update 更新店铺。
|
||||||
|
// PUT /api/admin/shops/:id
|
||||||
func (h *ShopHandler) Update(c *fiber.Ctx) error {
|
func (h *ShopHandler) Update(c *fiber.Ctx) error {
|
||||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -66,6 +117,8 @@ func (h *ShopHandler) Update(c *fiber.Ctx) error {
|
|||||||
return response.Success(c, shop)
|
return response.Success(c, shop)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete 删除店铺。
|
||||||
|
// DELETE /api/admin/shops/:id
|
||||||
func (h *ShopHandler) Delete(c *fiber.Ctx) error {
|
func (h *ShopHandler) Delete(c *fiber.Ctx) error {
|
||||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import (
|
|||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,47 +15,61 @@ func registerShopRoutes(router fiber.Router, handler *admin.ShopHandler, doc *op
|
|||||||
shops := router.Group("/shops")
|
shops := router.Group("/shops")
|
||||||
groupPath := basePath + "/shops"
|
groupPath := basePath + "/shops"
|
||||||
|
|
||||||
Register(shops, doc, groupPath, "GET", "", handler.List, RouteSpec{
|
Register(shops, doc, groupPath, "GET", "", coreShopManagement(handler.List), RouteSpec{
|
||||||
Summary: "店铺列表",
|
Summary: "店铺列表",
|
||||||
Tags: []string{"店铺管理"},
|
Description: "仅超级管理员、平台账号和代理账号可访问,企业账号返回 403。分页默认第 1 页、每页 20 条;page 最小为 1,page_size 范围为 1 至 100。所有筛选条件在查询前统一校验。",
|
||||||
Input: new(dto.ShopListRequest),
|
Tags: []string{"店铺管理"},
|
||||||
Output: new(dto.ShopPageResult),
|
Input: new(dto.ShopListRequest),
|
||||||
Auth: true,
|
Output: new(dto.ShopPageResult),
|
||||||
|
Auth: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
Register(shops, doc, groupPath, "POST", "", handler.Create, RouteSpec{
|
Register(shops, doc, groupPath, "POST", "", coreShopManagement(handler.Create), RouteSpec{
|
||||||
Summary: "创建店铺",
|
Summary: "创建店铺",
|
||||||
Tags: []string{"店铺管理"},
|
Description: "仅超级管理员、平台账号和代理账号可访问,企业账号返回 403。",
|
||||||
Input: new(dto.CreateShopRequest),
|
Tags: []string{"店铺管理"},
|
||||||
Output: new(dto.ShopResponse),
|
Input: new(dto.CreateShopRequest),
|
||||||
Auth: true,
|
Output: new(dto.ShopResponse),
|
||||||
|
Auth: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
Register(shops, doc, groupPath, "PUT", "/:id", handler.Update, RouteSpec{
|
Register(shops, doc, groupPath, "PUT", "/:id", coreShopManagement(handler.Update), RouteSpec{
|
||||||
Summary: "更新店铺",
|
Summary: "更新店铺",
|
||||||
Tags: []string{"店铺管理"},
|
Description: "仅超级管理员、平台账号和代理账号可访问,企业账号返回 403。",
|
||||||
Input: new(dto.UpdateShopParams),
|
Tags: []string{"店铺管理"},
|
||||||
Output: new(dto.ShopResponse),
|
Input: new(dto.UpdateShopParams),
|
||||||
Auth: true,
|
Output: new(dto.ShopResponse),
|
||||||
|
Auth: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
Register(shops, doc, groupPath, "DELETE", "/:id", handler.Delete, RouteSpec{
|
Register(shops, doc, groupPath, "DELETE", "/:id", coreShopManagement(handler.Delete), RouteSpec{
|
||||||
Summary: "删除店铺",
|
Summary: "删除店铺",
|
||||||
Tags: []string{"店铺管理"},
|
Description: "仅超级管理员、平台账号和代理账号可访问,企业账号返回 403。",
|
||||||
Input: new(dto.IDReq),
|
Tags: []string{"店铺管理"},
|
||||||
Output: nil,
|
Input: new(dto.IDReq),
|
||||||
Auth: true,
|
Output: nil,
|
||||||
|
Auth: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
Register(shops, doc, groupPath, "GET", "/cascade", handler.Cascade, RouteSpec{
|
Register(shops, doc, groupPath, "GET", "/cascade", coreShopManagement(handler.Cascade), RouteSpec{
|
||||||
Summary: "店铺联级查询",
|
Summary: "店铺联级查询",
|
||||||
Tags: []string{"店铺管理"},
|
Description: "仅超级管理员、平台账号和代理账号可访问,企业账号返回 403。",
|
||||||
Input: new(dto.ShopCascadeRequest),
|
Tags: []string{"店铺管理"},
|
||||||
Output: new([]dto.ShopCascadeItem),
|
Input: new(dto.ShopCascadeRequest),
|
||||||
Auth: true,
|
Output: new([]dto.ShopCascadeItem),
|
||||||
|
Auth: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func coreShopManagement(handler fiber.Handler) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
if middleware.GetUserTypeFromContext(c.UserContext()) == constants.UserTypeEnterprise {
|
||||||
|
return errors.New(errors.CodeForbidden, "无权限访问店铺管理功能")
|
||||||
|
}
|
||||||
|
return handler(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func registerShopRoleRoutes(router fiber.Router, handler *admin.ShopRoleHandler, doc *openapi.Generator, basePath string) {
|
func registerShopRoleRoutes(router fiber.Router, handler *admin.ShopRoleHandler, doc *openapi.Generator, basePath string) {
|
||||||
shops := router.Group("/shops")
|
shops := router.Group("/shops")
|
||||||
groupPath := basePath + "/shops"
|
groupPath := basePath + "/shops"
|
||||||
|
|||||||
357
internal/routes/shop_integration_test.go
Normal file
357
internal/routes/shop_integration_test.go
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/bytedance/sonic"
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||||
|
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||||
|
shopService "github.com/break/junhong_cmp_fiber/internal/service/shop"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/auth"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/database"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
pkgMiddleware "github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
type shopTestResponse struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data shopTestPageData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type shopTestPageData struct {
|
||||||
|
Items []map[string]any `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type shopTestEnv struct {
|
||||||
|
app *fiber.App
|
||||||
|
db *gorm.DB
|
||||||
|
redis *redis.Client
|
||||||
|
tokenManager *auth.TokenManager
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShopListValidatesAllQueryParameters(t *testing.T) {
|
||||||
|
env := newShopTestEnv(t)
|
||||||
|
token := env.newToken(t, auth.TokenInfo{
|
||||||
|
UserID: uniqueTestID(),
|
||||||
|
UserType: constants.UserTypeSuperAdmin,
|
||||||
|
Username: "ur60-super-admin",
|
||||||
|
})
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
}{
|
||||||
|
{name: "页码小于一", query: "page=0"},
|
||||||
|
{name: "每页数量小于一", query: "page_size=0"},
|
||||||
|
{name: "每页数量超过上限", query: "page_size=101"},
|
||||||
|
{name: "店铺层级非法", query: "level=8"},
|
||||||
|
{name: "店铺状态非法", query: "status=2"},
|
||||||
|
{name: "店铺名称过长", query: "shop_name=" + strings.Repeat("店", 101)},
|
||||||
|
{name: "店铺编号过长", query: "shop_code=" + strings.Repeat("A", 51)},
|
||||||
|
{name: "参数解析失败", query: "parent_id=invalid"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
status, body := env.request(t, http.MethodGet, "/api/admin/shops?"+testCase.query, token)
|
||||||
|
if status != http.StatusBadRequest {
|
||||||
|
t.Fatalf("期望 HTTP 400,实际为 %d,响应:%s", status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
var response struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data any `json:"data"`
|
||||||
|
}
|
||||||
|
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||||
|
t.Fatalf("解析响应失败:%v", err)
|
||||||
|
}
|
||||||
|
if response.Code != errors.CodeInvalidParam || response.Msg != "参数验证失败" || response.Data != nil {
|
||||||
|
t.Fatalf("参数错误响应不符合契约:%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), "timestamp") {
|
||||||
|
t.Fatalf("参数错误响应缺少时间戳:%s", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(body), "validation") || strings.Contains(string(body), "strconv") {
|
||||||
|
t.Fatalf("参数错误响应泄露底层细节:%s", body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShopListUsesNormalizedAndExplicitPagination(t *testing.T) {
|
||||||
|
env := newShopTestEnv(t)
|
||||||
|
token := env.newToken(t, auth.TokenInfo{
|
||||||
|
UserID: uniqueTestID(),
|
||||||
|
UserType: constants.UserTypeSuperAdmin,
|
||||||
|
Username: "ur60-super-admin",
|
||||||
|
})
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
expectedPage int
|
||||||
|
expectedSize int
|
||||||
|
}{
|
||||||
|
{name: "默认分页", expectedPage: 1, expectedSize: constants.DefaultPageSize},
|
||||||
|
{name: "显式分页", query: "?page=2&page_size=7&shop_name=ur60-no-match", expectedPage: 2, expectedSize: 7},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
status, body := env.request(t, http.MethodGet, "/api/admin/shops"+testCase.query, token)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("期望 HTTP 200,实际为 %d,响应:%s", status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
var response shopTestResponse
|
||||||
|
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||||
|
t.Fatalf("解析响应失败:%v", err)
|
||||||
|
}
|
||||||
|
if response.Code != errors.CodeSuccess || response.Data.Page != testCase.expectedPage || response.Data.Size != testCase.expectedSize {
|
||||||
|
t.Fatalf("分页响应不符合契约:%s", body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShopListKeepsAuthenticationContract(t *testing.T) {
|
||||||
|
env := newShopTestEnv(t)
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
}{
|
||||||
|
{name: "未认证"},
|
||||||
|
{name: "无效令牌", token: "ur60-invalid-token"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
status, body := env.request(t, http.MethodGet, "/api/admin/shops", testCase.token)
|
||||||
|
if status != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("期望 HTTP 401,实际为 %d,响应:%s", status, body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), "timestamp") {
|
||||||
|
t.Fatalf("认证错误响应缺少时间戳:%s", body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnterpriseCannotAccessCoreShopManagementRoutes(t *testing.T) {
|
||||||
|
env := newShopTestEnv(t)
|
||||||
|
token := env.newToken(t, auth.TokenInfo{
|
||||||
|
UserID: uniqueTestID(),
|
||||||
|
UserType: constants.UserTypeEnterprise,
|
||||||
|
EnterpriseID: uniqueTestID(),
|
||||||
|
Username: "ur60-enterprise",
|
||||||
|
})
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
}{
|
||||||
|
{name: "店铺列表", method: http.MethodGet, path: "/api/admin/shops"},
|
||||||
|
{name: "创建店铺", method: http.MethodPost, path: "/api/admin/shops"},
|
||||||
|
{name: "更新店铺", method: http.MethodPut, path: "/api/admin/shops/1"},
|
||||||
|
{name: "删除店铺", method: http.MethodDelete, path: "/api/admin/shops/1"},
|
||||||
|
{name: "联级查询", method: http.MethodGet, path: "/api/admin/shops/cascade"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
status, body := env.request(t, testCase.method, testCase.path, token)
|
||||||
|
if status != http.StatusForbidden {
|
||||||
|
t.Fatalf("期望 HTTP 403,实际为 %d,响应:%s", status, body)
|
||||||
|
}
|
||||||
|
var response struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data any `json:"data"`
|
||||||
|
}
|
||||||
|
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||||
|
t.Fatalf("解析响应失败:%v", err)
|
||||||
|
}
|
||||||
|
if response.Code != errors.CodeForbidden || response.Msg != "无权限访问店铺管理功能" || response.Data != nil {
|
||||||
|
t.Fatalf("企业账号禁止响应不符合契约:%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), "timestamp") {
|
||||||
|
t.Fatalf("企业账号禁止响应缺少时间戳:%s", body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoreShopRestrictionDoesNotAffectOtherShopRoutes(t *testing.T) {
|
||||||
|
env := newShopTestEnv(t)
|
||||||
|
token := env.newToken(t, auth.TokenInfo{
|
||||||
|
UserID: uniqueTestID(),
|
||||||
|
UserType: constants.UserTypeEnterprise,
|
||||||
|
EnterpriseID: uniqueTestID(),
|
||||||
|
Username: "ur60-enterprise",
|
||||||
|
})
|
||||||
|
|
||||||
|
status, body := env.request(t, http.MethodGet, "/api/admin/shops/1/roles", token)
|
||||||
|
if status == http.StatusForbidden && strings.Contains(string(body), "无权限访问店铺管理功能") {
|
||||||
|
t.Fatalf("核心店铺管理拦截误伤店铺角色路由:%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNonEnterpriseAccountsKeepCoreShopListAccess(t *testing.T) {
|
||||||
|
env := newShopTestEnv(t)
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
userType int
|
||||||
|
shopID uint
|
||||||
|
}{
|
||||||
|
{name: "超级管理员", userType: constants.UserTypeSuperAdmin},
|
||||||
|
{name: "平台账号", userType: constants.UserTypePlatform},
|
||||||
|
{name: "代理账号", userType: constants.UserTypeAgent, shopID: 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
token := env.newToken(t, auth.TokenInfo{
|
||||||
|
UserID: uniqueTestID(),
|
||||||
|
UserType: testCase.userType,
|
||||||
|
ShopID: testCase.shopID,
|
||||||
|
Username: "ur60-allowed-user",
|
||||||
|
})
|
||||||
|
status, body := env.request(t, http.MethodGet, "/api/admin/shops?page=1&page_size=1", token)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("期望保留列表访问能力,实际 HTTP %d,响应:%s", status, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newShopTestEnv(t *testing.T) *shopTestEnv {
|
||||||
|
t.Helper()
|
||||||
|
if os.Getenv("JUNHONG_DATABASE_HOST") == "" || os.Getenv("JUNHONG_REDIS_ADDRESS") == "" {
|
||||||
|
t.Skip("未加载 .env.local,跳过依赖真实 PostgreSQL 和 Redis 的店铺 HTTP 集成测试")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("加载测试配置失败:%v", err)
|
||||||
|
}
|
||||||
|
logger := zap.NewNop()
|
||||||
|
db, err := database.InitPostgreSQL(&cfg.Database, logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("连接 PostgreSQL 失败:%v", err)
|
||||||
|
}
|
||||||
|
redisClient, err := database.NewRedisClient(database.RedisConfig{
|
||||||
|
Address: cfg.Redis.Address + ":" + strconv.Itoa(cfg.Redis.Port),
|
||||||
|
Password: cfg.Redis.Password,
|
||||||
|
DB: cfg.Redis.DB,
|
||||||
|
PoolSize: cfg.Redis.PoolSize,
|
||||||
|
MinIdleConns: cfg.Redis.MinIdleConns,
|
||||||
|
DialTimeout: cfg.Redis.DialTimeout,
|
||||||
|
ReadTimeout: cfg.Redis.ReadTimeout,
|
||||||
|
WriteTimeout: cfg.Redis.WriteTimeout,
|
||||||
|
}, logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("连接 Redis 失败:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = redisClient.Close()
|
||||||
|
if sqlDB, dbErr := db.DB(); dbErr == nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
shopStore := postgres.NewShopStore(db, redisClient)
|
||||||
|
service := shopService.New(shopStore, nil, nil, nil, nil, nil)
|
||||||
|
tokenManager := auth.NewTokenManager(redisClient, cfg.JWT.AccessTokenTTL, cfg.JWT.RefreshTokenTTL)
|
||||||
|
authMiddleware := pkgMiddleware.Auth(pkgMiddleware.AuthConfig{
|
||||||
|
TokenValidator: func(token string) (*pkgMiddleware.UserContextInfo, error) {
|
||||||
|
info, validateErr := tokenManager.ValidateAccessToken(context.Background(), token)
|
||||||
|
if validateErr != nil {
|
||||||
|
return nil, errors.New(errors.CodeInvalidToken, "认证令牌无效或已过期")
|
||||||
|
}
|
||||||
|
return &pkgMiddleware.UserContextInfo{
|
||||||
|
UserID: info.UserID,
|
||||||
|
UserType: info.UserType,
|
||||||
|
Username: info.Username,
|
||||||
|
ShopID: info.ShopID,
|
||||||
|
EnterpriseID: info.EnterpriseID,
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
ShopStore: shopStore,
|
||||||
|
})
|
||||||
|
|
||||||
|
app := fiber.New(fiber.Config{
|
||||||
|
JSONEncoder: sonic.Marshal,
|
||||||
|
JSONDecoder: sonic.Unmarshal,
|
||||||
|
ErrorHandler: internalMiddleware.ErrorHandler(logger),
|
||||||
|
})
|
||||||
|
RegisterAdminRoutes(app.Group("/api/admin"), &bootstrap.Handlers{
|
||||||
|
Shop: admin.NewShopHandler(service, validator.New()),
|
||||||
|
ShopRole: admin.NewShopRoleHandler(service),
|
||||||
|
}, &bootstrap.Middlewares{AdminAuth: authMiddleware}, nil, "/api/admin")
|
||||||
|
|
||||||
|
return &shopTestEnv{app: app, db: db, redis: redisClient, tokenManager: tokenManager}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *shopTestEnv) newToken(t *testing.T, info auth.TokenInfo) string {
|
||||||
|
t.Helper()
|
||||||
|
accessToken, refreshToken, err := e.tokenManager.GenerateTokenPair(context.Background(), &info)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建测试令牌失败:%v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = e.tokenManager.RevokeToken(context.Background(), accessToken)
|
||||||
|
_ = e.tokenManager.RevokeToken(context.Background(), refreshToken)
|
||||||
|
_ = e.redis.Del(context.Background(), constants.RedisUserTokensKey(info.UserID)).Err()
|
||||||
|
})
|
||||||
|
return accessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *shopTestEnv) request(t *testing.T, method, path, token string) (int, []byte) {
|
||||||
|
t.Helper()
|
||||||
|
request, err := http.NewRequest(method, path, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建 HTTP 请求失败:%v", err)
|
||||||
|
}
|
||||||
|
if token != "" {
|
||||||
|
request.Header.Set(fiber.HeaderAuthorization, "Bearer "+token)
|
||||||
|
}
|
||||||
|
response, err := e.app.Test(request, -1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("执行 HTTP 请求失败:%v", err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
body, err := io.ReadAll(response.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取 HTTP 响应失败:%v", err)
|
||||||
|
}
|
||||||
|
return response.StatusCode, body
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueTestID() uint {
|
||||||
|
return uint(time.Now().UnixNano() % 1_000_000_000)
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ func BuildDocHandlers() *bootstrap.Handlers {
|
|||||||
ClientRealname: app.NewClientRealnameHandler(nil, nil, nil, nil, nil, nil, nil, nil),
|
ClientRealname: app.NewClientRealnameHandler(nil, nil, nil, nil, nil, nil, nil, nil),
|
||||||
ClientDevice: app.NewClientDeviceHandler(nil, nil, nil, nil, nil, nil, nil),
|
ClientDevice: app.NewClientDeviceHandler(nil, nil, nil, nil, nil, nil, nil),
|
||||||
ClientRechargeOrder: app.NewClientRechargeOrderHandler(nil, nil, nil),
|
ClientRechargeOrder: app.NewClientRechargeOrderHandler(nil, nil, nil),
|
||||||
Shop: admin.NewShopHandler(nil),
|
Shop: admin.NewShopHandler(nil, nil),
|
||||||
ShopRole: admin.NewShopRoleHandler(nil),
|
ShopRole: admin.NewShopRoleHandler(nil),
|
||||||
AdminAuth: admin.NewAuthHandler(nil, nil),
|
AdminAuth: admin.NewAuthHandler(nil, nil),
|
||||||
ShopCommission: admin.NewShopCommissionHandler(nil),
|
ShopCommission: admin.NewShopCommissionHandler(nil),
|
||||||
|
|||||||
Reference in New Issue
Block a user