实现店铺联系电话精确查询与索引
This commit is contained in:
@@ -10,18 +10,18 @@
|
||||
|
||||
**完整业务边界:** 完整收口店铺列表联系电话精确查询这一用户行为,包括后端、数据库、前端、接口契约和验收证据。明确不修改店铺创建或更新接口的电话校验,不清洗历史号码,不建立唯一约束,不新增缓存、聚合、Repository 抽象或 Query 目录,也不改变前端既有页码状态策略。
|
||||
|
||||
- [ ] 店铺列表接受可选 `contact_phone` 参数;非空值必须完整满足 11 位 ASCII 数字规则,空字符串与未传参数均视为不启用电话筛选。
|
||||
- [ ] 联系电话使用数据库等值匹配,不接受空格、全角数字、国家码、连字符、字母或长度不符的输入,也不执行 trim 或号码格式修复。
|
||||
- [ ] 联系电话与所有已有筛选条件按 AND 组合,计数查询与分页数据查询使用完全一致的过滤条件。
|
||||
- [ ] 相同联系电话的多个可见店铺均可返回;无匹配项返回成功空分页,不返回资源不存在错误。
|
||||
- [ ] 查询保持 `created_at DESC` 排序并排除软删除记录;代理账号无法通过相同联系电话看到本店铺及下级范围之外的店铺。
|
||||
- [ ] 超级管理员、平台账号和代理账号的真实 HTTP 集成测试覆盖精确匹配、非模糊匹配、重复号码、组合筛选、空参数、非法格式、空结果、分页、排序、软删除和数据隔离。
|
||||
- [ ] 数据库查询失败时返回脱敏的 HTTP 500、`code=2001`、`msg=内部服务器错误`,响应不泄露 SQL、主机、库名或驱动错误。
|
||||
- [ ] 数据库迁移创建名为 `idx_shop_contact_phone` 的非唯一部分 B-tree 索引,仅覆盖未软删除店铺的 `contact_phone`;回滚只删除该索引,不修改业务数据。
|
||||
- [ ] 在可控开发环境验证向上迁移、向下回滚和再次向上迁移,确认索引类型、列、非唯一属性及 `deleted_at IS NULL` 条件均符合契约。
|
||||
- [ ] OpenAPI 出现 `contact_phone`,准确描述 11 位 ASCII 数字、精确匹配、空值行为、AND 组合和分页限制;复用现有 Handler,不新增文档生成器实例。
|
||||
- [x] 店铺列表接受可选 `contact_phone` 参数;非空值必须完整满足 11 位 ASCII 数字规则,空字符串与未传参数均视为不启用电话筛选。
|
||||
- [x] 联系电话使用数据库等值匹配,不接受空格、全角数字、国家码、连字符、字母或长度不符的输入,也不执行 trim 或号码格式修复。
|
||||
- [x] 联系电话与所有已有筛选条件按 AND 组合,计数查询与分页数据查询使用完全一致的过滤条件。
|
||||
- [x] 相同联系电话的多个可见店铺均可返回;无匹配项返回成功空分页,不返回资源不存在错误。
|
||||
- [x] 查询保持 `created_at DESC` 排序并排除软删除记录;代理账号无法通过相同联系电话看到本店铺及下级范围之外的店铺。
|
||||
- [x] 超级管理员、平台账号和代理账号的真实 HTTP 集成测试覆盖精确匹配、非模糊匹配、重复号码、组合筛选、空参数、非法格式、空结果、分页、排序、软删除和数据隔离。
|
||||
- [x] 数据库查询失败时返回脱敏的 HTTP 500、`code=2001`、`msg=内部服务器错误`,响应不泄露 SQL、主机、库名或驱动错误。
|
||||
- [x] 数据库迁移创建名为 `idx_shop_contact_phone` 的非唯一部分 B-tree 索引,仅覆盖未软删除店铺的 `contact_phone`;回滚只删除该索引,不修改业务数据。
|
||||
- [x] 在可控开发环境验证向上迁移、向下回滚和再次向上迁移,确认索引类型、列、非唯一属性及 `deleted_at IS NULL` 条件均符合契约。
|
||||
- [x] OpenAPI 出现 `contact_phone`,准确描述 11 位 ASCII 数字、精确匹配、空值行为、AND 组合和分页限制;复用现有 Handler,不新增文档生成器实例。
|
||||
- [ ] 后台店铺列表筛选区提供“联系电话”输入、查询和清空能力;非法值展示中文提示并阻止请求,空值不提交该参数,合法值与其他筛选一并提交。
|
||||
- [ ] 前端查询期间展示加载状态,无匹配项展示空态,请求失败展示可重试反馈且不保留伪装成新结果的旧数据。
|
||||
- [ ] 浏览器网络请求与真实接口响应共同证明精确匹配、AND 组合、重复号码、空结果、合法分页和代理隔离,而不是只依据页面展示验收。
|
||||
- [ ] UR#60 中文总结及 README 索引记录本切片的前后端契约、索引上线与回滚核验、测试证据以及明确排除的历史数据清理范围。
|
||||
- [ ] 运行前后端各自的目标测试、格式化、静态检查和构建;本切片通过人工验收后可独立演示完整联系电话查询行为。
|
||||
- [x] UR#60 中文总结及 README 索引记录本切片的前后端契约、索引上线与回滚核验、测试证据以及明确排除的历史数据清理范围。
|
||||
- [ ] 运行前后端各自的目标测试、格式化、静态检查和构建;本切片通过人工验收后可独立演示完整联系电话查询行为。(后端已完成;当前仓库无前端源码,待前端仓库执行并人工验收。)
|
||||
|
||||
@@ -224,7 +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)
|
||||
- **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))
|
||||
- **UR#60 店铺列表参数与权限修复**:店铺列表统一执行查询参数校验并返回一致的默认分页元数据,企业账号禁止访问五个核心店铺管理入口;详见 [功能总结](docs/ur60-shop-phone-search/功能总结.md)。
|
||||
- **UR#60 店铺联系电话精确查询**:店铺列表支持 11 位 ASCII 联系电话精确筛选,统一执行查询参数校验并返回一致的默认分页元数据,企业账号禁止访问五个核心店铺管理入口;详见 [功能总结](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)的访问控制;详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md) 和 [架构说明](docs/auth-architecture.md)
|
||||
- **生命周期管理**:物联网卡/号卡的开卡、激活、停机、复机、销户
|
||||
|
||||
@@ -399,7 +399,7 @@ components:
|
||||
DtoAgentOpenAPIWalletPackageOrderRequest:
|
||||
properties:
|
||||
card_nos:
|
||||
description: 卡标识列表(支持 ICCID、虚拟号、MSISDN)
|
||||
description: 卡标识列表(支持 ICCID、虚拟号、MSISDN、设备 IMEI;传入设备 IMEI 时自动转为设备维度购买)
|
||||
items:
|
||||
type: string
|
||||
maxItems: 100
|
||||
@@ -460,6 +460,14 @@ components:
|
||||
description: 总记录数
|
||||
type: integer
|
||||
type: object
|
||||
DtoAgentRechargeRejectParams:
|
||||
properties:
|
||||
rejection_reason:
|
||||
description: 驳回原因(必填,最多500字)
|
||||
type: string
|
||||
required:
|
||||
- rejection_reason
|
||||
type: object
|
||||
DtoAgentRechargeResponse:
|
||||
properties:
|
||||
agent_wallet_id:
|
||||
@@ -499,11 +507,21 @@ components:
|
||||
description: 第三方支付流水号
|
||||
type: string
|
||||
payment_voucher_key:
|
||||
description: 支付凭证对象存储Key(线下支付时存在)
|
||||
type: string
|
||||
description: 支付凭证对象存储Key列表(线下支付时存在,最多5个)
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
recharge_no:
|
||||
description: 充值单号(ARCH前缀)
|
||||
type: string
|
||||
rejection_reason:
|
||||
description: 驳回原因,仅 status=6 时有值
|
||||
nullable: true
|
||||
type: string
|
||||
remark:
|
||||
description: 运营备注
|
||||
type: string
|
||||
shop_id:
|
||||
description: 店铺ID
|
||||
minimum: 0
|
||||
@@ -512,7 +530,7 @@ components:
|
||||
description: 店铺名称
|
||||
type: string
|
||||
status:
|
||||
description: 状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款)
|
||||
description: 状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)
|
||||
type: integer
|
||||
status_name:
|
||||
description: 状态名称(中文)
|
||||
@@ -1722,6 +1740,14 @@ components:
|
||||
switch_mode:
|
||||
description: 切卡模式:0=自动,1=手动(设备类型时有效)
|
||||
type: string
|
||||
total_virtual_remaining_mb:
|
||||
description: 当前世代所有套餐虚剩余之和(MB),未请求时为 null
|
||||
nullable: true
|
||||
type: number
|
||||
total_virtual_used_mb:
|
||||
description: 当前世代所有套餐虚已用之和(MB),未请求时为 null
|
||||
nullable: true
|
||||
type: number
|
||||
updated_at:
|
||||
description: 更新时间
|
||||
format: date-time
|
||||
@@ -3112,9 +3138,12 @@ components:
|
||||
description: 支付方式 (wallet:钱包支付, offline:线下支付)
|
||||
type: string
|
||||
payment_voucher_key:
|
||||
description: 线下支付凭证对象存储file_key(payment_method=offline时必填,通过/storage/upload-url上传图片后获得)
|
||||
maxLength: 500
|
||||
type: string
|
||||
description: 线下支付凭证对象存储file_key列表(payment_method=offline时至少1个,最多5个,通过/storage/upload-url上传图片后获得)
|
||||
items:
|
||||
type: string
|
||||
maxItems: 5
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- identifier
|
||||
- package_ids
|
||||
@@ -3131,7 +3160,15 @@ components:
|
||||
description: 支付方式 (wechat:微信在线支付, offline:线下转账仅平台可用)
|
||||
type: string
|
||||
payment_voucher_key:
|
||||
description: 支付凭证对象存储Key(payment_method=offline 时必填,微信支付时忽略)
|
||||
description: 支付凭证对象存储Key列表(payment_method=offline 时至少1个,最多5个,微信支付时忽略)
|
||||
items:
|
||||
type: string
|
||||
maxItems: 5
|
||||
nullable: true
|
||||
type: array
|
||||
remark:
|
||||
description: 运营备注(可选,创建后只读)
|
||||
maxLength: 1000
|
||||
type: string
|
||||
shop_id:
|
||||
description: 目标店铺ID,代理只能填自己店铺
|
||||
@@ -3399,6 +3436,26 @@ components:
|
||||
description: 提现单号
|
||||
type: string
|
||||
type: object
|
||||
DtoCreateOrderPackageInvalidateTaskRequest:
|
||||
properties:
|
||||
file_key:
|
||||
description: CSV文件对象存储Key(通过/storage/upload-url上传后获得;CSV单列 order_no)
|
||||
maxLength: 500
|
||||
type: string
|
||||
remark:
|
||||
description: 运营备注(可选,创建后只读)
|
||||
maxLength: 1000
|
||||
type: string
|
||||
voucher_keys:
|
||||
description: 支付凭证对象存储Key列表(可选,最多5个)
|
||||
items:
|
||||
type: string
|
||||
maxItems: 5
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- file_key
|
||||
type: object
|
||||
DtoCreatePackageRequest:
|
||||
properties:
|
||||
calendar_type:
|
||||
@@ -3641,10 +3698,13 @@ components:
|
||||
maxLength: 1000
|
||||
type: string
|
||||
refund_voucher_key:
|
||||
description: 退款凭证对象存储file_key(通过/storage/upload-url上传图片后获得)
|
||||
maxLength: 500
|
||||
minLength: 1
|
||||
type: string
|
||||
description: 退款凭证对象存储file_key列表(至少1个,最多5个,通过/storage/upload-url上传图片后获得)
|
||||
items:
|
||||
type: string
|
||||
maxItems: 5
|
||||
minItems: 1
|
||||
nullable: true
|
||||
type: array
|
||||
requested_refund_amount:
|
||||
description: 申请退款金额(分)
|
||||
minimum: 1
|
||||
@@ -4368,6 +4428,9 @@ components:
|
||||
description: 创建时间
|
||||
format: date-time
|
||||
type: string
|
||||
creator_name:
|
||||
description: 操作人姓名
|
||||
type: string
|
||||
error_message:
|
||||
description: 错误信息
|
||||
type: string
|
||||
@@ -4443,6 +4506,9 @@ components:
|
||||
description: 创建时间
|
||||
format: date-time
|
||||
type: string
|
||||
creator_name:
|
||||
description: 操作人姓名
|
||||
type: string
|
||||
error_message:
|
||||
description: 错误信息
|
||||
type: string
|
||||
@@ -5600,6 +5666,9 @@ components:
|
||||
description: 创建时间
|
||||
format: date-time
|
||||
type: string
|
||||
creator_name:
|
||||
description: 操作人姓名
|
||||
type: string
|
||||
error_message:
|
||||
description: 错误信息
|
||||
type: string
|
||||
@@ -5679,6 +5748,9 @@ components:
|
||||
description: 创建时间
|
||||
format: date-time
|
||||
type: string
|
||||
creator_name:
|
||||
description: 操作人姓名
|
||||
type: string
|
||||
error_message:
|
||||
description: 错误信息
|
||||
type: string
|
||||
@@ -5719,6 +5791,18 @@ components:
|
||||
description: 总数
|
||||
type: integer
|
||||
type: object
|
||||
DtoInvalidateFailedItem:
|
||||
properties:
|
||||
line:
|
||||
description: CSV 行号
|
||||
type: integer
|
||||
order_no:
|
||||
description: 订单号
|
||||
type: string
|
||||
reason:
|
||||
description: 失败原因
|
||||
type: string
|
||||
type: object
|
||||
DtoJSSDKConfigResponse:
|
||||
properties:
|
||||
app_id:
|
||||
@@ -6179,6 +6263,144 @@ components:
|
||||
description: 总数
|
||||
type: integer
|
||||
type: object
|
||||
DtoOrderPackageInvalidateTaskDetailResponse:
|
||||
properties:
|
||||
completed_at:
|
||||
description: 完成时间
|
||||
nullable: true
|
||||
type: string
|
||||
created_at:
|
||||
description: 创建时间
|
||||
type: string
|
||||
creator_name:
|
||||
description: 操作人姓名
|
||||
type: string
|
||||
error_message:
|
||||
description: 任务级错误信息
|
||||
type: string
|
||||
fail_count:
|
||||
description: 失败数
|
||||
type: integer
|
||||
failed_items:
|
||||
description: 失败记录列表
|
||||
items:
|
||||
$ref: '#/components/schemas/DtoInvalidateFailedItem'
|
||||
nullable: true
|
||||
type: array
|
||||
file_name:
|
||||
description: 原始文件名
|
||||
type: string
|
||||
id:
|
||||
description: 任务ID
|
||||
minimum: 0
|
||||
type: integer
|
||||
remark:
|
||||
description: 运营备注
|
||||
type: string
|
||||
started_at:
|
||||
description: 开始处理时间
|
||||
nullable: true
|
||||
type: string
|
||||
status:
|
||||
description: 任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)
|
||||
type: integer
|
||||
status_name:
|
||||
description: 状态名称(中文)
|
||||
type: string
|
||||
success_count:
|
||||
description: 成功数
|
||||
type: integer
|
||||
task_no:
|
||||
description: 任务编号
|
||||
type: string
|
||||
total_count:
|
||||
description: 总行数
|
||||
type: integer
|
||||
updated_at:
|
||||
description: 更新时间
|
||||
type: string
|
||||
voucher_keys:
|
||||
description: 支付凭证Key列表
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
DtoOrderPackageInvalidateTaskListResponse:
|
||||
properties:
|
||||
items:
|
||||
description: 任务列表
|
||||
items:
|
||||
$ref: '#/components/schemas/DtoOrderPackageInvalidateTaskResponse'
|
||||
nullable: true
|
||||
type: array
|
||||
page:
|
||||
description: 当前页码
|
||||
type: integer
|
||||
size:
|
||||
description: 每页条数
|
||||
type: integer
|
||||
total:
|
||||
description: 总记录数
|
||||
type: integer
|
||||
type: object
|
||||
DtoOrderPackageInvalidateTaskResponse:
|
||||
properties:
|
||||
completed_at:
|
||||
description: 完成时间
|
||||
nullable: true
|
||||
type: string
|
||||
created_at:
|
||||
description: 创建时间
|
||||
type: string
|
||||
creator_name:
|
||||
description: 操作人姓名
|
||||
type: string
|
||||
error_message:
|
||||
description: 任务级错误信息
|
||||
type: string
|
||||
fail_count:
|
||||
description: 失败数
|
||||
type: integer
|
||||
file_name:
|
||||
description: 原始文件名
|
||||
type: string
|
||||
id:
|
||||
description: 任务ID
|
||||
minimum: 0
|
||||
type: integer
|
||||
remark:
|
||||
description: 运营备注
|
||||
type: string
|
||||
started_at:
|
||||
description: 开始处理时间
|
||||
nullable: true
|
||||
type: string
|
||||
status:
|
||||
description: 任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)
|
||||
type: integer
|
||||
status_name:
|
||||
description: 状态名称(中文)
|
||||
type: string
|
||||
success_count:
|
||||
description: 成功数
|
||||
type: integer
|
||||
task_no:
|
||||
description: 任务编号
|
||||
type: string
|
||||
total_count:
|
||||
description: 总行数
|
||||
type: integer
|
||||
updated_at:
|
||||
description: 更新时间
|
||||
type: string
|
||||
voucher_keys:
|
||||
description: 支付凭证Key列表
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
DtoOrderResponse:
|
||||
properties:
|
||||
actual_paid_amount:
|
||||
@@ -6291,8 +6513,11 @@ components:
|
||||
description: 支付状态文本
|
||||
type: string
|
||||
payment_voucher_key:
|
||||
description: 线下支付凭证对象存储file_key(仅线下支付订单有值)
|
||||
type: string
|
||||
description: 线下支付凭证对象存储file_key列表(线下支付订单有值,最多5个)
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
purchase_remark:
|
||||
description: 购买备注
|
||||
type: string
|
||||
@@ -7355,8 +7580,11 @@ components:
|
||||
description: 退款原因
|
||||
type: string
|
||||
refund_voucher_key:
|
||||
description: 退款凭证对象存储file_key
|
||||
type: string
|
||||
description: 退款凭证对象存储file_key列表(最多5个)
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
reject_reason:
|
||||
description: 拒绝原因
|
||||
type: string
|
||||
@@ -7425,10 +7653,12 @@ components:
|
||||
nullable: true
|
||||
type: string
|
||||
refund_voucher_key:
|
||||
description: 退款凭证对象存储file_key(重新提交时可替换;历史记录缺失时必填)
|
||||
maxLength: 500
|
||||
description: 退款凭证对象存储file_key列表(重新提交时可替换;历史记录缺失时必填,最多5个)
|
||||
items:
|
||||
type: string
|
||||
maxItems: 5
|
||||
nullable: true
|
||||
type: string
|
||||
type: array
|
||||
requested_refund_amount:
|
||||
description: 申请退款金额(分)
|
||||
minimum: 1
|
||||
@@ -10356,11 +10586,11 @@ paths:
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
- description: 按状态过滤 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款)
|
||||
- description: 按状态过滤 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)
|
||||
in: query
|
||||
name: status
|
||||
schema:
|
||||
description: 按状态过滤 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款)
|
||||
description: 按状态过滤 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)
|
||||
nullable: true
|
||||
type: integer
|
||||
- description: 创建时间起始日期(YYYY-MM-DD)
|
||||
@@ -10632,6 +10862,52 @@ paths:
|
||||
summary: 确认线下充值
|
||||
tags:
|
||||
- 代理预充值
|
||||
/api/admin/agent-recharges/{id}/reject:
|
||||
post:
|
||||
parameters:
|
||||
- description: ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
description: ID
|
||||
minimum: 0
|
||||
type: integer
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DtoAgentRechargeRejectParams'
|
||||
responses:
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 请求参数错误
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 未认证或认证已过期
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 无权访问
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 服务器内部错误
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 驳回代理充值订单
|
||||
tags:
|
||||
- 代理预充值
|
||||
/api/admin/asset-allocation-records:
|
||||
get:
|
||||
parameters:
|
||||
@@ -11990,6 +12266,12 @@ paths:
|
||||
get:
|
||||
description: 通过虚拟号/ICCID/IMEI/SN/MSISDN 解析设备或卡的完整详情。企业账号禁止调用。
|
||||
parameters:
|
||||
- description: 是否返回当前世代流量汇总字段(total_virtual_used_mb / total_virtual_remaining_mb),默认 false
|
||||
in: query
|
||||
name: include_usage_summary
|
||||
schema:
|
||||
description: 是否返回当前世代流量汇总字段(total_virtual_used_mb / total_virtual_remaining_mb),默认 false
|
||||
type: boolean
|
||||
- description: 资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN)
|
||||
in: path
|
||||
name: identifier
|
||||
@@ -17426,6 +17708,219 @@ paths:
|
||||
summary: 批量回收单卡
|
||||
tags:
|
||||
- IoT卡管理
|
||||
/api/admin/order-package-invalidate-tasks:
|
||||
get:
|
||||
parameters:
|
||||
- description: 页码(默认1)
|
||||
in: query
|
||||
name: page
|
||||
schema:
|
||||
description: 页码(默认1)
|
||||
minimum: 1
|
||||
type: integer
|
||||
- description: 每页条数(默认20,最大100)
|
||||
in: query
|
||||
name: page_size
|
||||
schema:
|
||||
description: 每页条数(默认20,最大100)
|
||||
maximum: 100
|
||||
minimum: 1
|
||||
type: integer
|
||||
- description: 按状态过滤 (1:待处理, 2:处理中, 3:已完成, 4:失败)
|
||||
in: query
|
||||
name: status
|
||||
schema:
|
||||
description: 按状态过滤 (1:待处理, 2:处理中, 3:已完成, 4:失败)
|
||||
maximum: 4
|
||||
minimum: 1
|
||||
nullable: true
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
code:
|
||||
description: 响应码
|
||||
example: 0
|
||||
type: integer
|
||||
data:
|
||||
$ref: '#/components/schemas/DtoOrderPackageInvalidateTaskListResponse'
|
||||
msg:
|
||||
description: 响应消息
|
||||
example: success
|
||||
type: string
|
||||
timestamp:
|
||||
description: 时间戳
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- code
|
||||
- msg
|
||||
- data
|
||||
- timestamp
|
||||
type: object
|
||||
description: 成功
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 请求参数错误
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 未认证或认证已过期
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 无权访问
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 服务器内部错误
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 查询订单套餐失效任务列表
|
||||
tags:
|
||||
- 订单套餐失效
|
||||
post:
|
||||
description: 上传单列 CSV 文件(列名 order_no),批量将订单下的非终态套餐标记为已失效(status=4)。
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DtoCreateOrderPackageInvalidateTaskRequest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
code:
|
||||
description: 响应码
|
||||
example: 0
|
||||
type: integer
|
||||
data:
|
||||
$ref: '#/components/schemas/DtoOrderPackageInvalidateTaskResponse'
|
||||
msg:
|
||||
description: 响应消息
|
||||
example: success
|
||||
type: string
|
||||
timestamp:
|
||||
description: 时间戳
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- code
|
||||
- msg
|
||||
- data
|
||||
- timestamp
|
||||
type: object
|
||||
description: 成功
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 请求参数错误
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 未认证或认证已过期
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 无权访问
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 服务器内部错误
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 创建订单套餐批量失效任务
|
||||
tags:
|
||||
- 订单套餐失效
|
||||
/api/admin/order-package-invalidate-tasks/{id}:
|
||||
get:
|
||||
parameters:
|
||||
- description: ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
description: ID
|
||||
minimum: 0
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
code:
|
||||
description: 响应码
|
||||
example: 0
|
||||
type: integer
|
||||
data:
|
||||
$ref: '#/components/schemas/DtoOrderPackageInvalidateTaskDetailResponse'
|
||||
msg:
|
||||
description: 响应消息
|
||||
example: success
|
||||
type: string
|
||||
timestamp:
|
||||
description: 时间戳
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- code
|
||||
- msg
|
||||
- data
|
||||
- timestamp
|
||||
type: object
|
||||
description: 成功
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 请求参数错误
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 未认证或认证已过期
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 无权访问
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 服务器内部错误
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 查询订单套餐失效任务详情
|
||||
tags:
|
||||
- 订单套餐失效
|
||||
/api/admin/orders:
|
||||
get:
|
||||
parameters:
|
||||
@@ -22334,13 +22829,22 @@ paths:
|
||||
description: 店铺名称模糊查询
|
||||
maxLength: 100
|
||||
type: string
|
||||
- description: 店铺编号模糊查询
|
||||
- description: 店铺编号精确查询
|
||||
in: query
|
||||
name: shop_code
|
||||
schema:
|
||||
description: 店铺编号模糊查询
|
||||
description: 店铺编号精确查询
|
||||
maxLength: 50
|
||||
type: string
|
||||
- description: 联系电话精确查询(11位 ASCII 数字;空值不启用筛选;与其他条件按 AND 组合)
|
||||
in: query
|
||||
name: contact_phone
|
||||
schema:
|
||||
description: 联系电话精确查询(11位 ASCII 数字;空值不启用筛选;与其他条件按 AND 组合)
|
||||
maxLength: 11
|
||||
minLength: 11
|
||||
pattern: ^[0-9]{11}$
|
||||
type: string
|
||||
- description: 上级店铺ID
|
||||
in: query
|
||||
name: parent_id
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
# UR#60 店铺列表参数与权限修复总结
|
||||
# UR#60 店铺联系电话精确查询功能总结
|
||||
|
||||
## 本次交付范围
|
||||
|
||||
本次完成当前可由 Agent 执行的两个纵向切片:
|
||||
本次完成当前后端仓库内可执行的三个纵向切片:
|
||||
|
||||
- Ticket 01:修复 `GET /api/admin/shops` 的完整查询参数校验与默认分页一致性。
|
||||
- Ticket 02:交付联系电话精确查询、数据库部分索引、OpenAPI 和真实 HTTP 集成验证。
|
||||
- Ticket 03:禁止企业账号访问店铺列表、创建、更新、删除和联级查询五个核心店铺管理入口。
|
||||
|
||||
联系电话精确查询、数据库索引和跨仓前端交付属于 Ticket 02。该票当前状态为 `ready-for-human`,本次未实现、未迁移,也未提前修改店铺查询条件或数据库结构。
|
||||
Ticket 02 中的后台页面筛选区、浏览器联调和人工验收需要前端仓库。本仓库未包含前端源码,因此后端交付完成后该票转为 `ready-for-human`,仅等待跨仓前端实施与人工验收。
|
||||
|
||||
## 联系电话查询契约
|
||||
|
||||
店铺列表新增可选查询参数 `contact_phone`:
|
||||
|
||||
- 非空值必须完整匹配 `^[0-9]{11}$`,只接受 11 位 ASCII 数字。
|
||||
- 空字符串和未传参数均不启用电话筛选。
|
||||
- 不 trim,不接受空格、全角数字、`+86`、连字符、字母或长度不符的输入。
|
||||
- Store 使用 `contact_phone = ?` 等值条件,与店铺名称、编号、上级店铺、层级和状态按 AND 组合。
|
||||
- 相同电话允许返回多个可见店铺;无匹配项返回成功空分页。
|
||||
- 保持 `created_at DESC`、软删除排除和代理店铺层级数据范围。
|
||||
|
||||
前端接入时应新增“联系电话”输入框。非法值应在前端阻止请求并显示中文提示;合法值以 `contact_phone` 与其他筛选参数一并提交;空值不提交该参数。加载、空态和失败重试沿用现有列表交互,失败后不得将旧结果伪装成新查询结果。
|
||||
|
||||
## 参数与分页契约
|
||||
|
||||
@@ -38,7 +52,17 @@
|
||||
|
||||
## 架构与迁移边界
|
||||
|
||||
实现继续沿用现有 `Handler → Service → Store → GORM/DTO` 读取链路,只修改请求校验、分页归一化和路由权限边界。未迁移店铺模块到 DDD 或 Query 目录,未修改其他 Handler 的校验方式,也未新增缓存、审计业务日志、幂等控制、异步任务或数据库迁移。
|
||||
实现继续沿用现有 `Handler → Service → Store → GORM/DTO` 读取链路,只修改请求校验、筛选条件、分页归一化和路由权限边界。未迁移店铺模块到 DDD 或 Query 目录,未修改店铺创建/更新电话规则,也未新增缓存、审计业务日志、幂等控制或异步任务。
|
||||
|
||||
数据库迁移 `000160_add_shop_contact_phone_index` 创建非唯一部分 B-tree 索引 `idx_shop_contact_phone`:
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_shop_contact_phone
|
||||
ON tb_shop USING btree (contact_phone)
|
||||
WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
回滚只删除该索引,不清洗、回填或删除历史联系电话,也不改变业务数据。
|
||||
|
||||
## 验证证据
|
||||
|
||||
@@ -49,6 +73,17 @@ HTTP 集成测试在进程内启动 Fiber App,通过真实 Redis Token 状态
|
||||
- 未认证和无效 Token 保持既有认证错误契约。
|
||||
- 企业账号在五个核心入口进入 Handler 前被拒绝。
|
||||
- 超级管理员、平台账号和代理账号保留列表访问能力。
|
||||
- 店铺角色路由未被核心店铺管理权限包装误拦截。
|
||||
- 代理账号的联系电话结果只包含自身及全部下级店铺,范围外同电话店铺不可见。
|
||||
- 店铺角色、资金概况、提现、佣金和钱包流水路由未被核心店铺管理权限包装误拦截。
|
||||
- 联系电话精确匹配、重复号码、AND 组合、空参数、非法格式、空结果、显式分页、排序和软删除语义均通过真实 HTTP 验证。
|
||||
- 非法联系电话在执行店铺查询前被拒绝;数据库查询失败返回脱敏的 HTTP 500。
|
||||
- 索引已在开发库完成 `160 up → 159 down → 160 up` 演练,并通过 PostgreSQL 系统目录确认 B-tree、非唯一、列和 `deleted_at IS NULL` 条件。
|
||||
- OpenAPI 由 `go run ./cmd/gendocs` 生成并核验 `contact_phone`、正则、精确查询、AND/空值和权限说明。
|
||||
|
||||
测试使用唯一 Redis Token 并在结束后清理,不打印 `.env.local` 中的数据库、Redis 或 JWT 敏感配置。
|
||||
|
||||
## 剩余人工交付
|
||||
|
||||
- 在后台前端仓库实现联系电话输入、校验、查询、清空、加载、空态和失败重试。
|
||||
- 使用浏览器网络面板与真实接口共同核验请求参数和响应。
|
||||
- 完成超级管理员、平台、代理和企业四类账号人工验收后更新需求状态。
|
||||
|
||||
@@ -22,8 +22,8 @@ type ShopHandler struct {
|
||||
}
|
||||
|
||||
// NewShopHandler 创建店铺管理处理器。
|
||||
func NewShopHandler(service *shopService.Service, validate *validator.Validate) *ShopHandler {
|
||||
return &ShopHandler{service: service, validator: validate}
|
||||
func NewShopHandler(service *shopService.Service, validator *validator.Validate) *ShopHandler {
|
||||
return &ShopHandler{service: service, validator: validator}
|
||||
}
|
||||
|
||||
// List 查询店铺列表。
|
||||
@@ -73,7 +73,7 @@ func (h *ShopHandler) logListValidationFailure(c *fiber.Ctx, err error) {
|
||||
|
||||
func normalizeShopListPagination(req *dto.ShopListRequest) {
|
||||
if req.Page == 0 {
|
||||
req.Page = 1
|
||||
req.Page = constants.DefaultPage
|
||||
}
|
||||
if req.PageSize == 0 {
|
||||
req.PageSize = constants.DefaultPageSize
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
package dto
|
||||
|
||||
type ShopListRequest struct {
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||
ShopName string `json:"shop_name" query:"shop_name" validate:"omitempty,max=100" maxLength:"100" description:"店铺名称模糊查询"`
|
||||
ShopCode string `json:"shop_code" query:"shop_code" validate:"omitempty,max=50" maxLength:"50" description:"店铺编号模糊查询"`
|
||||
ParentID *uint `json:"parent_id" query:"parent_id" validate:"omitempty,min=1" minimum:"1" description:"上级店铺ID"`
|
||||
Level *int `json:"level" query:"level" validate:"omitempty,min=1,max=7" minimum:"1" maximum:"7" description:"店铺层级 (1-7级)"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1" description:"状态 (0:禁用, 1:启用)"`
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||
ShopName string `json:"shop_name" query:"shop_name" validate:"omitempty,max=100" maxLength:"100" description:"店铺名称模糊查询"`
|
||||
ShopCode string `json:"shop_code" query:"shop_code" validate:"omitempty,max=50" maxLength:"50" description:"店铺编号精确查询"`
|
||||
ContactPhone string `json:"contact_phone" query:"contact_phone" validate:"omitempty,len=11,numeric,ascii" minLength:"11" maxLength:"11" pattern:"^[0-9]{11}$" description:"联系电话精确查询(11位 ASCII 数字;空值不启用筛选;与其他条件按 AND 组合)"`
|
||||
ParentID *uint `json:"parent_id" query:"parent_id" validate:"omitempty,min=1" minimum:"1" description:"上级店铺ID"`
|
||||
Level *int `json:"level" query:"level" validate:"omitempty,min=1,max=7" minimum:"1" maximum:"7" description:"店铺层级 (1-7级)"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1" description:"状态 (0:禁用, 1:启用)"`
|
||||
}
|
||||
|
||||
type CreateShopRequest struct {
|
||||
|
||||
@@ -17,7 +17,7 @@ func registerShopRoutes(router fiber.Router, handler *admin.ShopHandler, doc *op
|
||||
|
||||
Register(shops, doc, groupPath, "GET", "", coreShopManagement(handler.List), RouteSpec{
|
||||
Summary: "店铺列表",
|
||||
Description: "仅超级管理员、平台账号和代理账号可访问,企业账号返回 403。分页默认第 1 页、每页 20 条;page 最小为 1,page_size 范围为 1 至 100。所有筛选条件在查询前统一校验。",
|
||||
Description: constants.ShopManagementAccessDescription + constants.ShopListPaginationDescription,
|
||||
Tags: []string{"店铺管理"},
|
||||
Input: new(dto.ShopListRequest),
|
||||
Output: new(dto.ShopPageResult),
|
||||
@@ -26,7 +26,7 @@ func registerShopRoutes(router fiber.Router, handler *admin.ShopHandler, doc *op
|
||||
|
||||
Register(shops, doc, groupPath, "POST", "", coreShopManagement(handler.Create), RouteSpec{
|
||||
Summary: "创建店铺",
|
||||
Description: "仅超级管理员、平台账号和代理账号可访问,企业账号返回 403。",
|
||||
Description: constants.ShopManagementAccessDescription,
|
||||
Tags: []string{"店铺管理"},
|
||||
Input: new(dto.CreateShopRequest),
|
||||
Output: new(dto.ShopResponse),
|
||||
@@ -35,7 +35,7 @@ func registerShopRoutes(router fiber.Router, handler *admin.ShopHandler, doc *op
|
||||
|
||||
Register(shops, doc, groupPath, "PUT", "/:id", coreShopManagement(handler.Update), RouteSpec{
|
||||
Summary: "更新店铺",
|
||||
Description: "仅超级管理员、平台账号和代理账号可访问,企业账号返回 403。",
|
||||
Description: constants.ShopManagementAccessDescription,
|
||||
Tags: []string{"店铺管理"},
|
||||
Input: new(dto.UpdateShopParams),
|
||||
Output: new(dto.ShopResponse),
|
||||
@@ -44,7 +44,7 @@ func registerShopRoutes(router fiber.Router, handler *admin.ShopHandler, doc *op
|
||||
|
||||
Register(shops, doc, groupPath, "DELETE", "/:id", coreShopManagement(handler.Delete), RouteSpec{
|
||||
Summary: "删除店铺",
|
||||
Description: "仅超级管理员、平台账号和代理账号可访问,企业账号返回 403。",
|
||||
Description: constants.ShopManagementAccessDescription,
|
||||
Tags: []string{"店铺管理"},
|
||||
Input: new(dto.IDReq),
|
||||
Output: nil,
|
||||
@@ -53,7 +53,7 @@ func registerShopRoutes(router fiber.Router, handler *admin.ShopHandler, doc *op
|
||||
|
||||
Register(shops, doc, groupPath, "GET", "/cascade", coreShopManagement(handler.Cascade), RouteSpec{
|
||||
Summary: "店铺联级查询",
|
||||
Description: "仅超级管理员、平台账号和代理账号可访问,企业账号返回 403。",
|
||||
Description: constants.ShopManagementAccessDescription,
|
||||
Tags: []string{"店铺管理"},
|
||||
Input: new(dto.ShopCascadeRequest),
|
||||
Output: new([]dto.ShopCascadeItem),
|
||||
@@ -64,7 +64,7 @@ func registerShopRoutes(router fiber.Router, handler *admin.ShopHandler, doc *op
|
||||
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 errors.New(errors.CodeForbidden, constants.ShopManagementForbiddenMessage)
|
||||
}
|
||||
return handler(c)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@ package routes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -20,7 +23,9 @@ import (
|
||||
"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"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
shopService "github.com/break/junhong_cmp_fiber/internal/service/shop"
|
||||
shopCommissionService "github.com/break/junhong_cmp_fiber/internal/service/shop_commission"
|
||||
"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"
|
||||
@@ -50,6 +55,7 @@ type shopTestEnv struct {
|
||||
tokenManager *auth.TokenManager
|
||||
}
|
||||
|
||||
// TestShopListValidatesAllQueryParameters 验证店铺列表会完整校验所有查询参数。
|
||||
func TestShopListValidatesAllQueryParameters(t *testing.T) {
|
||||
env := newShopTestEnv(t)
|
||||
token := env.newToken(t, auth.TokenInfo{
|
||||
@@ -100,6 +106,7 @@ func TestShopListValidatesAllQueryParameters(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestShopListUsesNormalizedAndExplicitPagination 验证默认分页归一化且显式分页保持不变。
|
||||
func TestShopListUsesNormalizedAndExplicitPagination(t *testing.T) {
|
||||
env := newShopTestEnv(t)
|
||||
token := env.newToken(t, auth.TokenInfo{
|
||||
@@ -114,7 +121,7 @@ func TestShopListUsesNormalizedAndExplicitPagination(t *testing.T) {
|
||||
expectedPage int
|
||||
expectedSize int
|
||||
}{
|
||||
{name: "默认分页", expectedPage: 1, expectedSize: constants.DefaultPageSize},
|
||||
{name: "默认分页", expectedPage: constants.DefaultPage, expectedSize: constants.DefaultPageSize},
|
||||
{name: "显式分页", query: "?page=2&page_size=7&shop_name=ur60-no-match", expectedPage: 2, expectedSize: 7},
|
||||
}
|
||||
|
||||
@@ -136,6 +143,7 @@ func TestShopListUsesNormalizedAndExplicitPagination(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestShopListKeepsAuthenticationContract 验证店铺列表保持既有认证错误契约。
|
||||
func TestShopListKeepsAuthenticationContract(t *testing.T) {
|
||||
env := newShopTestEnv(t)
|
||||
|
||||
@@ -160,6 +168,7 @@ func TestShopListKeepsAuthenticationContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnterpriseCannotAccessCoreShopManagementRoutes 验证企业账号无法访问五个核心店铺管理入口。
|
||||
func TestEnterpriseCannotAccessCoreShopManagementRoutes(t *testing.T) {
|
||||
env := newShopTestEnv(t)
|
||||
token := env.newToken(t, auth.TokenInfo{
|
||||
@@ -195,7 +204,7 @@ func TestEnterpriseCannotAccessCoreShopManagementRoutes(t *testing.T) {
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("解析响应失败:%v", err)
|
||||
}
|
||||
if response.Code != errors.CodeForbidden || response.Msg != "无权限访问店铺管理功能" || response.Data != nil {
|
||||
if response.Code != errors.CodeForbidden || response.Msg != constants.ShopManagementForbiddenMessage || response.Data != nil {
|
||||
t.Fatalf("企业账号禁止响应不符合契约:%s", body)
|
||||
}
|
||||
if !strings.Contains(string(body), "timestamp") {
|
||||
@@ -205,6 +214,7 @@ func TestEnterpriseCannotAccessCoreShopManagementRoutes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCoreShopRestrictionDoesNotAffectOtherShopRoutes 验证核心店铺权限限制不影响其他店铺前缀路由。
|
||||
func TestCoreShopRestrictionDoesNotAffectOtherShopRoutes(t *testing.T) {
|
||||
env := newShopTestEnv(t)
|
||||
token := env.newToken(t, auth.TokenInfo{
|
||||
@@ -214,12 +224,22 @@ func TestCoreShopRestrictionDoesNotAffectOtherShopRoutes(t *testing.T) {
|
||||
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)
|
||||
paths := []string{
|
||||
"/api/admin/shops/1/roles",
|
||||
"/api/admin/shops/fund-summary",
|
||||
"/api/admin/shops/1/withdrawal-requests",
|
||||
"/api/admin/shops/1/commission-records",
|
||||
"/api/admin/shops/1/main-wallet/transactions",
|
||||
}
|
||||
for _, path := range paths {
|
||||
status, body := env.request(t, http.MethodGet, path, token)
|
||||
if status == http.StatusForbidden && strings.Contains(string(body), constants.ShopManagementForbiddenMessage) {
|
||||
t.Fatalf("核心店铺管理拦截误伤独立路由 %s:%s", path, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNonEnterpriseAccountsKeepCoreShopListAccess 验证非企业账号保留核心店铺列表访问能力。
|
||||
func TestNonEnterpriseAccountsKeepCoreShopListAccess(t *testing.T) {
|
||||
env := newShopTestEnv(t)
|
||||
testCases := []struct {
|
||||
@@ -248,6 +268,233 @@ func TestNonEnterpriseAccountsKeepCoreShopListAccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestShopListFiltersContactPhoneExactly 验证联系电话精确匹配、AND 组合、重复号码、排序及软删除语义。
|
||||
func TestShopListFiltersContactPhoneExactly(t *testing.T) {
|
||||
env := newShopTestEnv(t)
|
||||
phone := "13800000000"
|
||||
parent := env.createShop(t, "UR60父店", phone, nil, 1, time.Now().Add(-4*time.Minute))
|
||||
newer := env.createShop(t, "UR60目标新店", phone, &parent.ID, 2, time.Now().Add(-time.Minute))
|
||||
older := env.createShop(t, "UR60目标旧店", phone, &parent.ID, 2, time.Now().Add(-2*time.Minute))
|
||||
env.createShop(t, "UR60相似号码", "13800000001", &parent.ID, 2, time.Now())
|
||||
deleted := env.createShop(t, "UR60软删除店", phone, &parent.ID, 2, time.Now().Add(time.Minute))
|
||||
if err := env.db.Delete(deleted).Error; err != nil {
|
||||
t.Fatalf("软删除测试店铺失败:%v", err)
|
||||
}
|
||||
|
||||
token := env.newToken(t, auth.TokenInfo{
|
||||
UserID: uniqueTestID(),
|
||||
UserType: constants.UserTypeSuperAdmin,
|
||||
Username: "ur60-super-admin",
|
||||
})
|
||||
status, body := env.request(t, http.MethodGet, "/api/admin/shops?contact_phone="+phone+"&shop_name=UR60目标&parent_id="+strconv.FormatUint(uint64(parent.ID), 10)+"&level=2&status=1", token)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("联系电话组合查询失败,HTTP %d:%s", status, body)
|
||||
}
|
||||
|
||||
var response shopTestResponse
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("解析联系电话查询响应失败:%v", err)
|
||||
}
|
||||
if response.Data.Total != 2 || len(response.Data.Items) != 2 {
|
||||
t.Fatalf("联系电话组合查询应返回两条可见记录:%s", body)
|
||||
}
|
||||
if uint(response.Data.Items[0]["id"].(float64)) != newer.ID || uint(response.Data.Items[1]["id"].(float64)) != older.ID {
|
||||
t.Fatalf("联系电话查询未保持 created_at DESC:%s", body)
|
||||
}
|
||||
|
||||
status, body = env.request(t, http.MethodGet, "/api/admin/shops?contact_phone=13800000001&shop_code="+newer.ShopCode, token)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("联系电话精确性查询失败,HTTP %d:%s", status, body)
|
||||
}
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("解析联系电话精确性响应失败:%v", err)
|
||||
}
|
||||
if response.Data.Total != 0 {
|
||||
t.Fatalf("联系电话必须与其他条件按 AND 精确匹配:%s", body)
|
||||
}
|
||||
|
||||
platformToken := env.newToken(t, auth.TokenInfo{
|
||||
UserID: uniqueTestID(),
|
||||
UserType: constants.UserTypePlatform,
|
||||
Username: "ur60-platform",
|
||||
})
|
||||
status, body = env.request(t, http.MethodGet, "/api/admin/shops?contact_phone="+phone+"&shop_name=UR60目标", platformToken)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("平台账号联系电话查询失败,HTTP %d:%s", status, body)
|
||||
}
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("解析平台账号联系电话响应失败:%v", err)
|
||||
}
|
||||
if response.Data.Total != 2 {
|
||||
t.Fatalf("平台账号联系电话查询结果不符合契约:%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShopListContactPhoneKeepsEmptyAndPaginationSemantics 验证空电话、空结果和显式分页契约。
|
||||
func TestShopListContactPhoneKeepsEmptyAndPaginationSemantics(t *testing.T) {
|
||||
env := newShopTestEnv(t)
|
||||
token := env.newToken(t, auth.TokenInfo{UserID: uniqueTestID(), UserType: constants.UserTypeSuperAdmin, Username: "ur60-super-admin"})
|
||||
|
||||
status, body := env.request(t, http.MethodGet, "/api/admin/shops?contact_phone=&page=2&page_size=3", token)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("空联系电话不应改变列表语义,HTTP %d:%s", status, body)
|
||||
}
|
||||
var response shopTestResponse
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("解析空联系电话响应失败:%v", err)
|
||||
}
|
||||
if response.Data.Page != 2 || response.Data.Size != 3 {
|
||||
t.Fatalf("联系电话筛选不应重置显式分页:%s", body)
|
||||
}
|
||||
|
||||
status, body = env.request(t, http.MethodGet, "/api/admin/shops?contact_phone=19999999999", token)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("无匹配联系电话应返回成功空分页,HTTP %d:%s", status, body)
|
||||
}
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("解析空结果响应失败:%v", err)
|
||||
}
|
||||
if response.Data.Total != 0 || len(response.Data.Items) != 0 {
|
||||
t.Fatalf("无匹配联系电话响应不符合空分页契约:%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShopListRejectsInvalidContactPhoneBeforeQuery 验证非法联系电话在执行店铺查询前被拒绝。
|
||||
func TestShopListRejectsInvalidContactPhoneBeforeQuery(t *testing.T) {
|
||||
env := newShopTestEnv(t)
|
||||
token := env.newToken(t, auth.TokenInfo{UserID: uniqueTestID(), UserType: constants.UserTypeSuperAdmin, Username: "ur60-super-admin"})
|
||||
var shopQueryCount atomic.Int64
|
||||
callbackName := fmt.Sprintf("ur60:count_shop_queries:%d", time.Now().UnixNano())
|
||||
if err := env.db.Callback().Query().Before("gorm:query").Register(callbackName, func(db *gorm.DB) {
|
||||
if db.Statement != nil && db.Statement.Table == (model.Shop{}).TableName() {
|
||||
shopQueryCount.Add(1)
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatalf("注册店铺查询计数回调失败:%v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = env.db.Callback().Query().Remove(callbackName) })
|
||||
|
||||
invalidPhones := []string{
|
||||
"1380000000", "138000000000", "1380000000A", "+8613800000000",
|
||||
"138-0000-0000", " 13800000000", "13800000000",
|
||||
}
|
||||
for _, phone := range invalidPhones {
|
||||
shopQueryCount.Store(0)
|
||||
status, body := env.request(t, http.MethodGet, "/api/admin/shops?contact_phone="+url.QueryEscape(phone), token)
|
||||
if status != http.StatusBadRequest || shopQueryCount.Load() != 0 {
|
||||
t.Fatalf("非法联系电话应在查询前返回 400,phone=%q, queries=%d, body=%s", phone, shopQueryCount.Load(), body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentContactPhoneSearchKeepsShopScope 验证代理账号无法通过联系电话越过店铺层级范围。
|
||||
func TestAgentContactPhoneSearchKeepsShopScope(t *testing.T) {
|
||||
env := newShopTestEnv(t)
|
||||
phone := "13700000000"
|
||||
root := env.createShop(t, "UR60代理根店", phone, nil, 1, time.Now().Add(-3*time.Minute))
|
||||
child := env.createShop(t, "UR60代理下级", phone, &root.ID, 2, time.Now().Add(-2*time.Minute))
|
||||
env.createShop(t, "UR60范围外店铺", phone, nil, 1, time.Now().Add(-time.Minute))
|
||||
token := env.newToken(t, auth.TokenInfo{UserID: uniqueTestID(), UserType: constants.UserTypeAgent, ShopID: root.ID, Username: "ur60-agent"})
|
||||
|
||||
status, body := env.request(t, http.MethodGet, "/api/admin/shops?contact_phone="+phone, token)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("代理联系电话查询失败,HTTP %d:%s", status, body)
|
||||
}
|
||||
var response shopTestResponse
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("解析代理联系电话响应失败:%v", err)
|
||||
}
|
||||
if response.Data.Total != 2 {
|
||||
t.Fatalf("代理联系电话查询应只返回自身及下级:%s", body)
|
||||
}
|
||||
returned := map[uint]bool{}
|
||||
for _, item := range response.Data.Items {
|
||||
returned[uint(item["id"].(float64))] = true
|
||||
}
|
||||
if !returned[root.ID] || !returned[child.ID] {
|
||||
t.Fatalf("代理联系电话查询缺少范围内店铺:%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShopListDatabaseFailureIsSanitized 验证数据库错误通过统一 500 响应脱敏。
|
||||
func TestShopListDatabaseFailureIsSanitized(t *testing.T) {
|
||||
env := newShopTestEnv(t)
|
||||
token := env.newToken(t, auth.TokenInfo{UserID: uniqueTestID(), UserType: constants.UserTypeSuperAdmin, Username: "ur60-super-admin"})
|
||||
sqlDB, err := env.db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("获取测试数据库连接失败:%v", err)
|
||||
}
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
t.Fatalf("关闭测试数据库连接失败:%v", err)
|
||||
}
|
||||
|
||||
status, body := env.request(t, http.MethodGet, "/api/admin/shops?contact_phone=13800000000", token)
|
||||
if status != http.StatusInternalServerError {
|
||||
t.Fatalf("数据库失败应返回 HTTP 500,实际 %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.CodeInternalError || response.Msg != "内部服务器错误" || response.Data != nil {
|
||||
t.Fatalf("数据库错误响应不符合脱敏契约:%s", body)
|
||||
}
|
||||
for _, leaked := range []string{"sql", "postgres", "host", "driver", "database is closed"} {
|
||||
if strings.Contains(strings.ToLower(string(body)), leaked) {
|
||||
t.Fatalf("数据库错误响应泄露底层信息 %q:%s", leaked, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestShopContactPhoneIndexDefinition 验证联系电话索引的类型、唯一性和部分条件。
|
||||
func TestShopContactPhoneIndexDefinition(t *testing.T) {
|
||||
env := newShopTestEnv(t)
|
||||
var indexCount int64
|
||||
if err := env.db.Raw(`
|
||||
SELECT COUNT(*)
|
||||
FROM pg_class
|
||||
WHERE relname = ?
|
||||
`, "idx_shop_contact_phone").Scan(&indexCount).Error; err != nil {
|
||||
t.Fatalf("查询联系电话索引数量失败:%v", err)
|
||||
}
|
||||
if os.Getenv("UR60_EXPECT_INDEX_ABSENT") == "1" {
|
||||
if indexCount != 0 {
|
||||
t.Fatalf("回滚后联系电话索引仍然存在")
|
||||
}
|
||||
return
|
||||
}
|
||||
if indexCount != 1 {
|
||||
t.Fatalf("联系电话索引数量异常:%d", indexCount)
|
||||
}
|
||||
|
||||
var index struct {
|
||||
AccessMethod string `gorm:"column:access_method"`
|
||||
IsUnique bool `gorm:"column:is_unique"`
|
||||
Definition string `gorm:"column:definition"`
|
||||
Predicate string `gorm:"column:predicate"`
|
||||
}
|
||||
err := env.db.Raw(`
|
||||
SELECT am.amname AS access_method,
|
||||
ix.indisunique AS is_unique,
|
||||
pg_get_indexdef(ix.indexrelid) AS definition,
|
||||
pg_get_expr(ix.indpred, ix.indrelid) AS predicate
|
||||
FROM pg_index ix
|
||||
JOIN pg_class i ON i.oid = ix.indexrelid
|
||||
JOIN pg_am am ON am.oid = i.relam
|
||||
WHERE i.relname = ?
|
||||
`, "idx_shop_contact_phone").Scan(&index).Error
|
||||
if err != nil {
|
||||
t.Fatalf("查询联系电话索引定义失败:%v", err)
|
||||
}
|
||||
if index.AccessMethod != "btree" || index.IsUnique || !strings.Contains(index.Definition, "contact_phone") || !strings.Contains(index.Predicate, "deleted_at IS NULL") {
|
||||
t.Fatalf("联系电话索引定义不符合契约:%+v", index)
|
||||
}
|
||||
}
|
||||
|
||||
func newShopTestEnv(t *testing.T) *shopTestEnv {
|
||||
t.Helper()
|
||||
if os.Getenv("JUNHONG_DATABASE_HOST") == "" || os.Getenv("JUNHONG_REDIS_ADDRESS") == "" {
|
||||
@@ -286,6 +533,17 @@ func newShopTestEnv(t *testing.T) *shopTestEnv {
|
||||
|
||||
shopStore := postgres.NewShopStore(db, redisClient)
|
||||
service := shopService.New(shopStore, nil, nil, nil, nil, nil)
|
||||
shopCommission := shopCommissionService.New(
|
||||
shopStore,
|
||||
postgres.NewAccountStore(db, redisClient),
|
||||
postgres.NewAgentWalletStore(db, redisClient),
|
||||
postgres.NewCommissionWithdrawalRequestStore(db, redisClient),
|
||||
postgres.NewCommissionWithdrawalSettingStore(db, redisClient),
|
||||
postgres.NewCommissionRecordStore(db, redisClient),
|
||||
postgres.NewAgentWalletTransactionStore(db, redisClient),
|
||||
db,
|
||||
logger,
|
||||
)
|
||||
tokenManager := auth.NewTokenManager(redisClient, cfg.JWT.AccessTokenTTL, cfg.JWT.RefreshTokenTTL)
|
||||
authMiddleware := pkgMiddleware.Auth(pkgMiddleware.AuthConfig{
|
||||
TokenValidator: func(token string) (*pkgMiddleware.UserContextInfo, error) {
|
||||
@@ -310,13 +568,35 @@ func newShopTestEnv(t *testing.T) *shopTestEnv {
|
||||
ErrorHandler: internalMiddleware.ErrorHandler(logger),
|
||||
})
|
||||
RegisterAdminRoutes(app.Group("/api/admin"), &bootstrap.Handlers{
|
||||
Shop: admin.NewShopHandler(service, validator.New()),
|
||||
ShopRole: admin.NewShopRoleHandler(service),
|
||||
Shop: admin.NewShopHandler(service, validator.New()),
|
||||
ShopRole: admin.NewShopRoleHandler(service),
|
||||
ShopCommission: admin.NewShopCommissionHandler(shopCommission),
|
||||
}, &bootstrap.Middlewares{AdminAuth: authMiddleware}, nil, "/api/admin")
|
||||
|
||||
return &shopTestEnv{app: app, db: db, redis: redisClient, tokenManager: tokenManager}
|
||||
}
|
||||
|
||||
func (e *shopTestEnv) createShop(t *testing.T, name, phone string, parentID *uint, level int, createdAt time.Time) *model.Shop {
|
||||
t.Helper()
|
||||
shop := &model.Shop{
|
||||
ShopName: name,
|
||||
ShopCode: fmt.Sprintf("UR60-%d", time.Now().UnixNano()),
|
||||
ParentID: parentID,
|
||||
Level: level,
|
||||
ContactPhone: phone,
|
||||
Status: constants.ShopStatusEnabled,
|
||||
}
|
||||
shop.CreatedAt = createdAt
|
||||
shop.UpdatedAt = createdAt
|
||||
shop.Creator = uniqueTestID()
|
||||
shop.Updater = shop.Creator
|
||||
if err := e.db.Create(shop).Error; err != nil {
|
||||
t.Fatalf("创建测试店铺失败:%v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = e.db.Unscoped().Delete(shop).Error })
|
||||
return shop
|
||||
}
|
||||
|
||||
func (e *shopTestEnv) newToken(t *testing.T, info auth.TokenInfo) string {
|
||||
t.Helper()
|
||||
accessToken, refreshToken, err := e.tokenManager.GenerateTokenPair(context.Background(), &info)
|
||||
|
||||
@@ -323,6 +323,9 @@ func (s *Service) ListShopResponses(ctx context.Context, req *dto.ShopListReques
|
||||
if req.ShopCode != "" {
|
||||
filters["shop_code"] = req.ShopCode
|
||||
}
|
||||
if req.ContactPhone != "" {
|
||||
filters["contact_phone"] = req.ContactPhone
|
||||
}
|
||||
if req.ParentID != nil {
|
||||
filters["parent_id"] = *req.ParentID
|
||||
}
|
||||
|
||||
@@ -114,6 +114,9 @@ func (s *ShopStore) List(ctx context.Context, opts *store.QueryOptions, filters
|
||||
if shopCode, ok := filters["shop_code"].(string); ok && shopCode != "" {
|
||||
query = query.Where("shop_code = ?", shopCode)
|
||||
}
|
||||
if contactPhone, ok := filters["contact_phone"].(string); ok && contactPhone != "" {
|
||||
query = query.Where("contact_phone = ?", contactPhone)
|
||||
}
|
||||
if parentID, ok := filters["parent_id"].(uint); ok {
|
||||
query = query.Where("parent_id = ?", parentID)
|
||||
}
|
||||
|
||||
2
migrations/000160_add_shop_contact_phone_index.down.sql
Normal file
2
migrations/000160_add_shop_contact_phone_index.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- 回滚联系电话查询索引,不修改店铺业务数据。
|
||||
DROP INDEX IF EXISTS idx_shop_contact_phone;
|
||||
4
migrations/000160_add_shop_contact_phone_index.up.sql
Normal file
4
migrations/000160_add_shop_contact_phone_index.up.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- 为未软删除店铺的联系电话精确查询增加非唯一部分索引。
|
||||
CREATE INDEX idx_shop_contact_phone
|
||||
ON tb_shop USING btree (contact_phone)
|
||||
WHERE deleted_at IS NULL;
|
||||
@@ -37,6 +37,7 @@ const (
|
||||
DefaultMaxOpenConns = 25
|
||||
DefaultMaxIdleConns = 10
|
||||
DefaultConnMaxLifetime = 5 * time.Minute
|
||||
DefaultPage = 1 // 默认页码
|
||||
DefaultPageSize = 20
|
||||
MaxPageSize = 100
|
||||
SlowQueryThreshold = 500 * time.Millisecond
|
||||
|
||||
@@ -9,3 +9,10 @@ const (
|
||||
ShopMinLevel = 1
|
||||
ShopMaxLevel = 7
|
||||
)
|
||||
|
||||
// 店铺管理权限提示常量。
|
||||
const (
|
||||
ShopManagementForbiddenMessage = "无权限访问店铺管理功能" // 企业账号访问核心店铺管理功能时的提示
|
||||
ShopManagementAccessDescription = "仅超级管理员、平台账号和代理账号可访问,企业账号返回 403。" // 核心店铺管理接口权限说明
|
||||
ShopListPaginationDescription = "分页默认第 1 页、每页 20 条;page 最小为 1,page_size 范围为 1 至 100。所有筛选条件在查询前统一校验。" // 店铺列表分页与校验说明
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user