# PRD：UR#60 店铺联系电话精确查询

Status: ready-for-agent

---

## Problem Statement

后台店铺列表目前不能按店铺联系电话检索。运营人员知道完整联系电话时，仍需通过店铺名称、编号或翻页人工定位，效率低且容易选错店铺。

本次核查还确认了三个与该查询直接相关、必须在同一需求闭环的问题：

1. 店铺列表请求虽然声明了分页及筛选校验规则，但 Handler 当前没有执行整体验证，非法查询参数可能进入查询层。
2. 未传 `page`、`page_size` 时，数据库实际按第 1 页、每页 20 条查询，但响应元数据仍可能返回 `page=0`、`size=0`。
3. 企业账号当前可能进入核心店铺管理路由；由于其没有代理店铺范围，列表查询可能失去预期的数据隔离。企业账号不应访问核心店铺管理功能。

目标是在不扩大店铺模块架构迁移范围、不改变现有响应结构的前提下，提供可验证、权限安全、性能可接受的 11 位联系电话精确查询，并完成前后端交付和文档闭环。

## Solution

在现有店铺列表接口 `GET /api/admin/shops` 增加可选查询参数 `contact_phone`。非空值必须由 11 个 ASCII 数字组成，查询使用等值匹配；它与店铺名称、店铺编号、上级店铺、层级、状态等已有筛选条件按 AND 组合。空值等同未传，不改变原查询结果。

同时完成以下配套改动：

1. 对店铺列表请求执行完整 DTO 校验，并通过统一错误响应隐藏具体校验细节。
2. 在请求进入查询前统一归一化分页默认值，使查询条件与响应中的分页元数据一致。
3. 在核心店铺管理路由增加企业账号访问限制，同时保留超级管理员、平台账号和代理账号原有权限及代理数据范围。
4. 为未软删除店铺的联系电话建立非唯一部分 B-tree 索引，支持重复联系电话并降低精确查询成本。
5. 前端店铺列表增加联系电话筛选、校验、查询和清空能力，并沿用列表已有的加载、空态和失败反馈。
6. 使用真实开发 PostgreSQL、Redis、JWT 配置完成 HTTP 集成验证，更新 OpenAPI 和需求总结文档后再进入联调与人工验收。

## User Stories

1. 作为有权限的后台运营人员，我希望输入完整的 11 位店铺联系电话并精确查找店铺，以便快速定位目标记录。
2. 作为有权限的后台运营人员，我希望联系电话筛选与名称、编号、上级店铺、层级、状态等筛选条件同时生效，以便逐步收窄结果，而不是扩大结果集合。
3. 作为有权限的后台运营人员，当多个可见店铺使用同一联系电话时，我希望看到全部符合其他筛选条件的记录，而不是只返回一条。
4. 作为有权限的后台运营人员，当联系电话没有匹配记录时，我希望得到正常的空分页结果，而不是“资源不存在”错误。
5. 作为有权限的后台运营人员，当我不填写联系电话时，我希望列表行为与改造前一致。
6. 作为有权限的后台运营人员，我希望前端在联系电话不是 11 位 ASCII 数字时直接提示并阻止请求，以便及时修正输入。
7. 作为 API 调用方，当我传入非法联系电话时，我希望后端仍独立拒绝请求，而不依赖前端校验保障数据安全。
8. 作为 API 调用方，当我传入空联系电话时，我希望后端将其视为未提供该条件，不影响已有筛选。
9. 作为 API 调用方，当我不传分页参数时，我希望查询和响应都明确使用第 1 页、每页 20 条。
10. 作为 API 调用方，当我显式传入合法分页参数时，我希望后端严格使用该页码和每页数量，不自行重置页码。
11. 作为 API 调用方，当店铺列表的任何已声明查询参数非法时，我希望收到统一的参数验证失败响应，而不是执行部分或错误查询。
12. 作为代理账号，我希望搜索结果始终只包含本店铺及下级店铺范围内的数据，不能通过联系电话探测其他代理的数据。
13. 作为超级管理员或平台账号，我希望在原有全局可见范围内使用联系电话筛选。
14. 作为企业账号，我不应访问核心店铺管理接口，并应收到明确且一致的禁止访问响应。
15. 作为维护人员，我希望联系电话查询使用适合软删除模型的数据库索引，同时允许多个店铺保存相同联系电话。
16. 作为维护人员，我希望参数错误、权限错误和数据库错误都使用现有统一响应协议，且服务端错误不会把数据库细节暴露给客户端。
17. 作为前端实现人员，我希望获得稳定的参数、响应和错误契约，以便无需猜测后端的分页、筛选或权限行为。
18. 作为验收人员，我希望通过真实 PostgreSQL、Redis 和认证链路验证该接口，以确认权限过滤和实际 SQL 行为，而不只是验证模拟对象。

## Implementation Decisions

### 1. 接口及筛选契约

- 复用现有 `GET /api/admin/shops`，不新增 Handler 或新接口。
- 新增可选字符串查询参数 `contact_phone`，含义为“店铺联系电话精确查询”。
- 非空 `contact_phone` 必须完整匹配 `^[0-9]{11}$`：恰好 11 个 ASCII 数字。
- 不接受前后空格、全角数字、`+86`、连字符或其他格式；后端不做 trim、格式修复或号码归一化。
- 不校验中国大陆手机号号段或首位规则，只校验 11 位 ASCII 数字。
- 参数未传或值为空字符串时视为没有联系电话筛选，不影响原查询。
- 联系电话使用数据库等值条件，不使用 LIKE、包含、前缀或后缀匹配。
- `contact_phone` 与所有已提供的已有筛选参数按 AND 组合；不是 OR 查询。
- 保留已有筛选语义：店铺名称模糊匹配；店铺编号精确匹配；上级店铺、层级、状态精确匹配。
- 允许联系电话重复；相同电话的所有可见匹配店铺均进入结果，再统一分页。
- 没有匹配项时返回成功空分页：`items` 为空数组、`total=0`，不返回 404。
- 结果继续按 `created_at DESC` 排序；本需求不增加次级排序规则。
- 继续排除已软删除店铺，不改变列表项字段和现有分页响应结构。

### 2. 请求校验与分页

- 店铺列表 Handler 在完成查询参数解析后，对整个列表请求 DTO 执行 Validator 校验，而不是仅单独校验联系电话。
- 完整校验规则为：`page` 可省略，提供时不小于 1；`page_size` 可省略，提供时为 1 至 100；`shop_name` 最长 100；`shop_code` 最长 50；`parent_id` 提供时不小于 1；`level` 提供时为 1 至 7；`status` 提供时只能为 0 或 1；`contact_phone` 非空时满足 11 位 ASCII 数字规则。
- 参数解析失败或任一字段验证失败时，记录包含请求上下文和具体校验原因的服务端日志；客户端统一收到 HTTP 400、`code=1001`、`msg=参数验证失败`、`data=null` 和时间戳，不返回 Validator 或底层解析错误文本。
- 未提供 `page` 时，在查询前归一化为 1；未提供 `page_size` 时，在查询前归一化为 20。
- 查询实际使用的页码、每页数量必须与响应 `data.page`、`data.size` 完全一致。
- 显式提供的合法 `page`、`page_size` 原样生效；后端绝不因新增或改变筛选条件而自行把页码重置为 1。
- 成功响应继续使用现有统一结构：HTTP 200、`code=0`、`msg=success`，`data` 包含 `items`、`total`、`page`、`size`，并返回时间戳。

### 3. 权限边界

- 身份认证仍由现有后台认证中间件负责；本需求不创建新的认证机制。
- 核心店铺管理路由仅指店铺列表、创建、更新、删除和联级查询。
- 超级管理员和平台账号保持当前核心店铺管理访问能力；店铺列表可查看其原有范围内的数据。
- 代理账号保持当前访问能力，列表继续应用“本店铺及全部下级店铺”数据范围；联系电话和其他筛选只能在该范围内生效。
- 企业账号访问任一核心店铺管理路由时，统一返回 HTTP 403、`code=1005`、`msg=无权限访问店铺管理功能`、`data=null` 和时间戳。
- 企业账号拦截必须只挂在核心店铺管理路由组，不得因复用 `/shops` 前缀而误伤店铺角色接口、代理商资金概况、提现、佣金、钱包流水等独立路由。
- 本需求不重新定义这些独立路由各自已有的权限校验。

### 4. 查询实现与架构边界

- 这是现有列表的简单只读筛选，沿用当前 `Handler → Service → Store → GORM/DTO` 调用链。
- 不为该筛选迁移整个店铺模块，不新建聚合根、Repository 抽象、Query 模块、工厂或其他无业务价值的层次。
- Service 将非空联系电话加入筛选集合；Store 在同一 GORM 查询上追加等值条件。所有条件必须共同作用于计数查询和分页数据查询。
- 继续复用现有店铺数据权限过滤；不得在增加联系电话条件时绕过、覆盖或改写权限范围。
- 列表读取不新增缓存，不使用 Redis 缓存查询结果。Redis 仅按现有认证及代理范围链路参与测试和运行。
- 数据库查询错误转换为现有内部错误码并保留服务端上下文；客户端收到 HTTP 500、`code=2001`、`msg=内部服务器错误`、`data=null`，不得泄露 SQL、主机、库名或驱动错误。
- 这是只读操作，不新增幂等键、分布式锁、审计业务日志或异步任务；现有 HTTP 访问日志继续记录请求。

### 5. 数据库索引与迁移

- 新增名为 `idx_shop_contact_phone` 的非唯一 B-tree 索引，索引列为店铺表的 `contact_phone`。
- 索引仅覆盖 `deleted_at IS NULL` 的记录，与列表默认排除软删除记录的查询条件一致。
- 索引不得设为唯一；业务允许不同店铺使用同一联系电话。
- 不新增字段，不修改字段类型，不回填、清洗或删除历史联系电话数据。
- 历史记录中不符合 11 位规则的值继续保留，只是无法被合法的本接口联系电话参数精确命中。
- 迁移回滚只删除该索引，不变更任何业务数据。
- 迁移随七月迭代维护窗口发布；上线和回滚均需核验索引状态。

### 6. 前端交付契约

- 后台店铺列表筛选区新增“联系电话”输入框，并提供现有风格的查询和清空操作。
- 前端仅允许提交 11 位 ASCII 数字；非法值不发起请求，并展示清晰的中文校验提示。
- 有值时以 `contact_phone` 查询参数提交；空值时不提交该参数。
- 联系电话与页面当前已有筛选参数一并提交，后端按 AND 查询。
- 清空联系电话后恢复为不含该条件的列表查询，同时保留产品现有的其他筛选交互规则。
- 前端如何维护或改变页码属于其现有页面状态策略；后端契约只要求尊重实际收到的 `page`，本需求不新增“改变筛选必须重置页码”的规则。
- 查询期间展示加载状态；成功无数据展示空态；请求失败展示可重试的错误反馈，不把旧结果伪装成新查询结果。
- 当前后端仓库不包含前端源码；后端接口和 OpenAPI 就绪后按项目既有交付方式通知前端仓库实施并联调。

### 7. 文档与发布闭环

- 更新店铺列表请求的 OpenAPI 定义，准确描述联系电话格式、精确匹配、分页限制和现有筛选字段；重新生成接口文档。
- 因为复用现有 Handler，无需向文档生成器新增 Handler 实例，但必须确认生成结果中该参数真实可见。
- 实施阶段新增 UR#60 中文总结文档，并更新项目 README 的相关索引或说明。
- 后端代码、迁移、自动化测试、OpenAPI 和总结文档完成后才可交给前端联调。
- 前后端联调和人工验收通过后，才可在禅道中更新本需求状态；自动化测试通过不等同于整项需求完成。

## Testing Decisions

### 1. 最高价值测试接缝

- 主要验收采用 HTTP 集成测试：测试进程内启动 Fiber App，通过真实路由进入认证中间件、权限中间件、Handler、Service、Store、GORM 和统一响应处理。
- 测试加载项目 `.env.local` 中的真实开发 PostgreSQL、Redis 和 JWT 配置；不把数据库或 Redis 替换为内存实现或 Mock。
- Fiber App 在测试进程内启动，不要求开发者事先在 `127.0.0.1:3000` 运行独立 API 进程；`:3000` 只是正常开发启动时的 API 监听地址，与远程 PostgreSQL、Redis 地址无关。
- 认证用例使用真实 JWT 配置和 Redis Token 状态穿过现有认证中间件，不能通过直接向 Fiber Context 填充用户信息绕过认证。
- 测试数据必须使用唯一标识创建并严格清理；数据库测试记录和 Redis Token/范围缓存均不得污染共享开发环境，也不得依赖环境中偶然存在的业务数据。
- 测试不接入网关、对象存储、短信等第三方系统，因为本需求链路不需要它们。

### 2. 必须覆盖的后端用例

1. 合法 11 位联系电话只返回联系电话完全相等且位于当前权限范围内的店铺。
2. 前缀、后缀和包含关系均不匹配，证明查询不是模糊查询。
3. 两个可见店铺联系电话相同均被返回，证明索引和查询没有引入唯一性假设。
4. 电话联系条件与店铺名称、店铺编号、上级店铺、层级、状态分别及组合使用时均为 AND；`total` 与 `items` 使用相同条件。
5. 未传和传空 `contact_phone` 均保持原列表语义。
6. 空格、全角数字、`+86`、连字符、少于 11 位、超过 11 位、含字母等非空值均返回 HTTP 400 / `code=1001`，且没有执行店铺查询。
7. `page=0`、`page_size=0`、`page_size>100`、非法层级、非法状态、超长名称或编号等已有字段违反 DTO 约束时，也返回统一参数错误。
8. 未传分页参数时，实际查询第 1 页每页 20 条，响应同步为 `page=1`、`size=20`。
9. 显式合法分页参数保持不变；后端不会因存在联系电话筛选而重置页码。
10. 无匹配数据时返回 HTTP 200、空 `items`、`total=0` 和正确分页元数据。
11. 返回顺序保持 `created_at DESC`，软删除店铺不出现在结果中。
12. 超级管理员和平台账号能在原有范围内查询；代理账号只能看到本店铺及下级店铺，即使范围外店铺使用相同电话也不可见。
13. 企业账号访问列表返回 HTTP 403 / `code=1005` / `msg=无权限访问店铺管理功能`。
14. 企业账号访问创建、更新、删除、联级查询同样被核心路由组拒绝；独立的店铺角色及资金/佣金路由不因本次路由分组而被误拦截。
15. 未认证、Token 无效或 Redis Token 状态无效时继续遵循现有认证错误契约。
16. 数据库查询失败时返回脱敏的 HTTP 500 / `code=2001`，响应不出现底层错误文本。
17. 成功和失败响应均保持统一的 `code`、`msg`、`data`、`timestamp` 外层结构。

### 3. 迁移、文档与前端验证

- 在可控开发环境验证迁移向上执行后存在 `idx_shop_contact_phone`，它是非唯一、仅含 `contact_phone` 且带 `deleted_at IS NULL` 条件的 B-tree 索引。
- 验证迁移回滚只删除该索引，随后再次向上迁移恢复索引；全程不改变店铺业务数据。
- 运行目标包测试和全仓 Go 测试，执行格式化、静态检查及构建；任何失败必须在交付前处理。
- 重新生成 OpenAPI，并核验店铺列表中出现 `contact_phone` 及正确约束，且生成结果无无关漂移。
- 前端验证合法输入发起正确请求、非法输入不发请求、清空后不携带联系电话、组合筛选参数完整、加载/空态/失败反馈正确。
- 联调以浏览器网络请求和真实接口响应共同核对；不得只凭页面展示判断 AND 条件、分页或权限是否正确。

### 4. 完成标准

- 后端实现、迁移、HTTP 集成测试、全仓回归、OpenAPI、中文总结文档和 README 更新全部完成。
- 前端实现与联调完成，超级管理员/平台/代理/企业四类账号的关键行为通过人工验收。
- 精确查询、AND 组合、默认分页、重复电话、空结果、非法参数、代理隔离和企业 403 均有可复现证据。
- 维护窗口的发布检查和索引回滚路径已演练或核验。
- 人工验收通过并更新禅道后，UR#60 才能标记完成。

## Out of Scope

- 不新增独立的联系电话搜索接口。
- 不做模糊、前缀、后缀、分词或跨字段 OR 搜索。
- 不验证运营商号段、号码真实性、号码归属地，也不规范化 `+86`、空格、连字符或全角字符。
- 不修改店铺创建、更新接口的联系电话校验规则；这两个接口的历史验证差异不在 UR#60 内处理。
- 不清洗、回填或删除历史联系电话数据。
- 不禁止多个店铺使用相同联系电话，不建立唯一约束。
- 不改变已有列表项字段、排序、软删除语义或响应外层结构。
- 不调整前端自身的页码状态策略；后端只按收到的参数查询。
- 不重构整个店铺模块，不迁移未触碰用例到 DDD 或新的 Query 目录。
- 不为只读列表增加 Redis 结果缓存、审计业务日志、幂等控制或异步任务。
- 不借本次核心路由权限修复重新设计店铺角色、资金、佣金、提现、钱包流水等独立接口的授权规则。
- 不接入或改造网关、对象存储、短信等第三方系统。
- 不在本 Spec 阶段实施代码、生成整轮七月迭代 PRD、拆分 tickets 或创建 OpenSpec 产物。

## Further Notes

- 本 Spec 仅覆盖七月迭代中的 UR#60“店铺联系电话精确查询”，不代表整轮七月迭代范围。
- 需求事实以当前会话确认、七月迭代标准评审稿、当前独立方案和对应禅道草稿为顺序收敛；当前代码和开发环境仅用于核实系统现状。
- 当前代码已证明列表真实查询默认值与响应分页元数据存在偏差，也证明企业账号可能因无店铺范围而获得过宽列表结果，因此两项修复属于本需求的验收边界，不是额外优化。
- 本需求没有引入新的领域术语、业务状态或跨模块长期架构决策，因此无需修改领域词汇表或新增 ADR。
- 本地开发 PostgreSQL 和 Redis 已验证网络可达，但 API 进程是否已独立启动不影响测试设计；集成测试应自行构建并启动进程内 Fiber App。
- `.env.local` 含敏感配置，实施和测试日志不得打印连接密码、JWT 密钥或其他凭据，交付文档也不得复制这些值。
