Files
cmautobuy/docs/admin/06-quality-security.md
T

314 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 06 Admin 质量、安全与测试基线
- 文档状态:基线草案,待质量评审
- 适用范围:Admin 源码、模板、数据库和发布产物
本文档中 `[必须]` / `[建议]` / `[待定]` 的含义见 [文档索引](../README.md#文档标注说明)。
没有标注的默认是 `[必须]`。看不懂的词查 [术语表](00-glossary.md)。
## 1. 质量目标
- 导入不丢人工维护的数据(PDD 链接、SKU 映射、手动新增的行)。
- 不把校验不过的任务发给 Client——发出去 Client 也执行不了。
- 同一任务不会被两个客户端同时领走。
- Client 提交的结果一定收得下,哪怕任务已取消。
- 页面和日志不泄露 token、密码、Cookie。
## 2. 测试分层
### 2.1 单元测试
`[必须]` 覆盖:
- **商品目录批次**:鉴权、原子写入、幂等重放、关联冲突和旧观测时间保护;
- **upsert 不覆盖人工字段**:先填 PDD 链接和人工修改 SKU 颜色/尺码/建议,再分别用
`fill_missing`、`overwrite_same_source` 导入,断言链接和人工字段(含清空建议)仍在;
- **PDD 链接批量导入**:空工作表、正确/错误表头、两个允许域名、短链/非 PDD
链接、空行、5000 条边界、文件内重复、新建/复用/复活分类和重复导入不覆盖采集结果;
- 任务创建校验:商品、采集结果、有效映射、数量、人民币价格上限、客户端可见范围逐条覆盖;
- 采购任务重复提交保护、部分业务失败、PDD 换品隔离和采集选项失效;
- 金额换算显示(分 ↔ 元),边界值 0 和大额;
- SKU 映射复用:第二次匹配同一 SKU 应自动带出;
- 在线状态派生:`last_seen_at` 刚好在边界前后。
- 密码哈希校验:正确密码成功,错误密码失败,数据库不出现明文密码;
- 密码长度边界:5 个字符拒绝、6 个字符接受;初始化、创建、重置和管理员自助修改规则一致;
- 角色校验:管理员可以管理用户,采购员访问用户管理返回 `403`;
- 最后管理员保护:不能禁用最后一个有效管理员。
- 客户端归属:一人多客户端、一台客户端唯一当前负责人、转交/解绑历史完整;
- 归属权限:采购员只看到自己的客户端,不能绑定、解绑或删除;禁用采购员不能成为新目标。
- 任务创建人隔离:采购员列表/统计/分页只包含本人任务,伪造创建人参数不能扩大范围;
- 任务越权:他人详情表现为 404,混合本人/他人/不存在编号的批量删除完整回滚。
`[必须]` 导入相关的测试用 `admin/testdata/` 下的**小样本**(几十行),
不要读完整报表。
`[必须]` 这份小样本**必须提交进 Git**,否则别人拉下来测试跑不了。
制作方法:从真实报表里挑几十行,**删掉全部销售额、曝光、转化率等指标列**,
只保留导入用得到的字段(商品ID、商品名稱、商品規格ID、商品規格、貨號等),
并把商品名称改成无意义的占位文字。
完整报表含商业数据,**不进 Git**(见 `.gitignore`)。
### 2.2 集成测试
用独立、库名以 `_test` 结尾的真实 MySQL 8.4 测试库覆盖:
- 完整导入 → 建采集任务 → 模拟 Client 提交结果 → `collect_status` 变 `collected`;
- 并发 `claim`:两个客户端同时领,只有一个拿到;
- 幂等:同键同内容重复提交,只落库一次;
- 同键不同内容 → `409`;
- **任务已取消,Client 提交结果仍被接受**;
- **任务已重派给别人,原客户端提交仍被接受**;
- 同一任务收到两个客户端的两份结果,都存下来;
- 数据库从上一版本迁移,人工数据不丢。
- 创建采购任务后通过现有 Client 契约读取,断言目标规格、数量、人民币价格上限和指定客户端完整;
中间三条是 [04](04-client-api.md) §4.1 那条最容易写错的规则,**必须有测试盯着**。
### 2.3 页面测试
- `go vet ./...` 无告警;
- 所有模板能正常渲染(起服务跑一遍五个页面,断言 200);
- 空数据、少量数据、大量数据三种情况;
- 批量删除的二次确认存在;
- 校验失败时输入不丢。
- 没有用户时业务页面跳转初始化页,初始化后入口永久关闭;
- 未登录访问业务页面跳转登录页,登录后正常渲染;
- 退出、Session 过期、密码重置和账号禁用后不能继续访问;
- Client API 不返回登录页或 302 重定向。
- 管理员客户端页显示全量和负责人;采购员客户端页只读且只显示自己的客户端;
- 绑定/转交弹窗有明确标签和焦点,解绑有二次确认,操作结果可被辅助技术读到。
- 管理员采集采购页可按正常账号、禁用账号和历史任务筛选并显示创建人;采购员不显示
创建人筛选和列表列,详情仍显示创建人。
### 2.4 契约测试
`[必须]` 拿 [04 §9 的实现清单](04-client-api.md) 逐条写测试。
那张清单本身就是验收标准,可以直接照着做。
## 3. 数据安全
`[必须]` 下面几条错一条就会丢数据:
| 规则 | 为什么 |
|---|---|
| 导入只能 upsert,**禁止先清空再导入** | 人工填了几个月的 PDD 链接会被洗掉 |
| upsert 的 `DO UPDATE SET` 里**不得出现** `pdd_goods_url` / `pdd_data` / `collect_status` | 报表里没这些列,写进去会被更新成空 |
| **不得删除报表里没出现的行** | 手动新增的(`is_manual=1`)会被误删;报表本身就不是全量 |
| 迁移前备份或用可回滚步骤 | `data/` 在升级时保留,迁移失败会毁掉全部历史 |
| **不得在启动时删库重建** | 同上 |
`[必须]` 生产变更前执行 MySQL 备份;SQLite 最终迁移前把 `admin.db` 复制到
`data/backup/` 并保持只读,至少保留到生产验收完成。
## 4. Web 安全
- `[必须]` 所有写操作(新增/编辑/删除/导入/建任务)加 **CSRF 防护**。
- `[必须]` 破坏性操作用 **POST**,不得用 GET。
- `[必须]` SQL **一律参数化查询**,禁止字符串拼接。
搜索框的内容是用户可控的,拼进 SQL 就是注入。
- `[必须]` 模板输出走 `html/template` 的自动转义。
**禁止用 `template.HTML` 包裹用户可控内容**——商品名、订单号都来自外部。
- `[必须]` 上传的 Excel 限制**大小和扩展名**,解析失败要返回明确错误,
不能 panic 把整个进程带崩。
- `[必须]` PDD 链接导入还要校验 xlsx 文件头和 5000 条非空数据行上限;
文件结构错误时不写库,行内容错误时导入其他合法行并列出全部失败明细,
数据库异常时本批合法行整体回滚。
- `[必须]` 文件名不得直接用于拼路径(路径穿越),落盘时用自己生成的名字。
- `[建议]` MVP 只监听 `127.0.0.1`,不对外暴露。要给内网用再单独评估。
- `[必须]` 不提供固定默认密码和公开注册;第一位管理员由用户首次初始化。
- `[必须]` 密码使用成熟算法哈希,禁止自创加密、明文保存或可逆加密。
- `[必须]` 密码最少 6 个字符、最多 72 个字节;初始化、创建采购员、重置密码、管理员自助修改
的服务端校验和 HTML 表单约束保持一致。
- `[必须]` 首次管理员创建必须在数据库写事务中完成,并发请求最多一个成功。
- `[必须]` 登录成功后使用新的随机 Session Token,数据库只保存其 SHA-256 哈希。
- `[必须]` 登录 Cookie 设置 `HttpOnly`、`SameSite=Lax`、`Path=/`;HTTPS 部署时设置 `Secure`。
- `[必须]` Session 默认 12 小时过期;退出、密码重置、管理员自助修改密码和账号禁用立即撤销对应 Session。
- `[必须]` 管理员自助修改密码必须验证当前密码,目标账号从当前 Session 取得;成功后撤销该账号全部 Session 并重新登录。
- `[必须]` `/setup`、`/login`、`/logout`、`/account/change-password` 和用户管理写操作都保留 CSRF 防护。
- `[必须]` 客户端绑定、转交、解绑和删除只能由管理员执行,并保留 CSRF 防护。
- `[必须]` 任务列表、统计、分页、详情和删除按同一创建人范围授权;采购员提交 URL 或
表单中的其他创建人/任务编号不能扩大范围,混合越权批量删除必须全部回滚。
- `[必须]` Web 登录中间件只保护 HTML 路由,不得覆盖 `/api/v1/client/*`。
- `[建议]` 对连续登录失败做简单限速;错误提示不区分用户名不存在和密码错误。
## 5. 错误处理
`[必须]` 页面出错渲染错误页,接口出错返回 JSON,**两者不要混**。
`[必须]` 错误信息要说清三件事:**发生了什么、保住了什么、下一步做什么**。
```text
好:导入失败:第 128 行「商品規格ID」为空且不是汇总行。
前 127 行已成功导入,请修正后重新导入。
差:导入失败
差:runtime error: index out of range [40] with length 39
```
`[必须]` Go 的错误堆栈只写日志,不上页面。
`[建议]` 用稳定错误码,方便排查:
| 前缀 | 场景 |
|---|---|
| `IMPORT_*` | Excel 格式、列缺失、行解析失败 |
| `TASK_*` | 建任务校验不通过 |
| `CLIENT_*` | 客户端注册、领取冲突 |
| `DB_*` | 迁移、写入、锁超时 |
## 6. 日志
`[必须]` 日志写 `data/logs/`,至少包含时间、级别、模块、事件名、关键 ID。
`[建议]` 记录这些事件:
- `catalog_import_started/completed/failed`(只带来源、批次号和计数摘要)
- `collect_task_created`
- `purchase_task_created`
- `task_claimed`(带 client_id、task_id)
- `task_result_received`
- `task_result_received_but_cancelled` ← 这条要单独记,方便事后对账
- `client_registered`
- `admin_initialized`、`user_login_succeeded`、`user_login_failed`
- `user_created`、`user_disabled`、`user_enabled`、`user_password_reset`
- `client_assignment_changed`、`client_assignment_ended`(只记录管理员用户名和相关 ID)
`[必须]` **不得记录** token、密码、Cookie,也不要把完整请求体无脑打进日志。
`[必须]` 本机 `admin/config.yaml` 可以保存 MySQL 和顺运宝明文密码,但必须保持
Git 忽略且不得随程序打包、截图或粘贴到工单和日志;线上优先使用权限 `600` 的
环境文件,环境变量按字段覆盖 YAML。
项目负责人已于 2026-08-11 明确批准生产 MySQL 3307 接受所有公网来源。该例外仅限
`autobuy` 专用业务账号,并且必须保持库级权限、`REQUIRE SSL`、Admin
`verify_ca` 和 root 仅限服务器本机;不得扩展到其他数据库或关闭 TLS。恢复固定
出口 IP 后应优先改回 `/32` 白名单。
`[必须]` Admin 公网连接使用 `database.tls_mode: verify_ca`:最低 TLS 1.2,加载
服务器公开 CA 并验证证书链,验证失败即拒绝连接,不得使用允许明文回退的
`preferred`。服务器自动证书没有 SAN,客户端只信任该服务器的专属 CA,不做公网
IP 主机名匹配;CA 轮换时必须同步更新客户端公开证书。
## 7. 性能
商品目录第三方接口使用独立 Bearer Token;未配置时接口必须返回 503 并保持禁用。
Token 只能放在未提交的 `config.yaml` 或环境变量中,禁止写入数据库、日志、页面、
工单和归档。鉴权失败只返回统一错误,不提示哪一部分凭据不正确。
规模很小(个位数操作员、集中部署),不要提前优化。但下面几条是基本功:
- `[必须]` 搜索、排序、分页走**数据库查询**,不要一次查全量再在内存里过滤。
- `[必须]` 常用查询有索引(见 [03 数据模型](03-data-model.md) 里的 `CREATE INDEX`)。
- `[必须]` 导入 1 万行要在一个事务里批量写,不要一行一个事务。
- `[建议]` 导入时不要把整个文件读进内存,用 excelize 的流式行读取。
## 8. 发布门禁
| # | 门禁项 | 怎么验证 | 谁负责 |
|---|---|---|---|
| 1 | 依赖版本已固定 | `go.mod` / `go.sum` 已提交,`go mod verify` 通过 | 开发者 |
| 2 | `go vet ./...` 无告警 | | 开发者 |
| 3 | `go test ./...` 全绿 | 不允许有跳过而未说明的用例 | 开发者 |
| 4 | 五个页面能正常打开 | 起服务跑一遍 | 开发者 |
| 5 | 样本导入条数正确 | 导入参考样本(需向项目负责人索取,放 `raw_data/`),应得 5195 商品 + 6092 SKU | 开发者 |
| 6 | 数据库升级和 SQLite 单向迁移成功 | 隔离 MySQL 升级通过;旧 `admin.db` 迁移后逐表核对一致 | 开发者 |
| 7 | 契约测试通过 | [04 §9 清单](04-client-api.md)逐条 | 开发者 |
| 8 | 干净环境启动 | 没装过本项目的机器上 `go run .` 或跑 exe | 开发者 |
| 9 | 日志和页面无敏感信息 | 翻一遍 `data/logs/` | 开发者 |
| 10 | 开源许可证已确认 | 新增依赖的许可证 | **项目负责人** |
| 11 | 登录与角色测试通过 | 初始化、登录、退出、禁用、最后管理员保护 | 开发者 |
| 12 | Client API 回归通过 | 四接口不重定向、不返回 HTML,契约测试全绿 | 开发者 |
| 13 | Session 安全属性正确 | 检查 Cookie 属性、过期和撤销 | 开发者 |
| 14 | 客户端归属正确 | 唯一当前负责人、历史、角色可见范围、既有任务不变 | 开发者 |
第 10 项开发者**不要自己判断放行**,把包名和许可证类型报给项目负责人。
当前新增生产数据库驱动:`github.com/go-sql-driver/mysql` v1.9.2,许可证
**MPL-2.0**。技术验证通过不代表许可证已获发布批准,仍需项目负责人确认门禁 10。
## 9. 打包
`[待定]` 打包动作属于 MVP 之后,但策略现在定下来:
- `go build` 直接出**单个 exe**,不需要 C 编译器
(这就是选 `modernc.org/sqlite` 的原因);
- 模板和静态文件用 `//go:embed` 打进 exe,**不要散在外面**,
否则用户可能改坏或删掉;
- `data/` 留在 exe 旁边,**升级时保留**,见 [02 架构](02-architecture.md) §6;
- 升级方式:替换服务程序,先备份 MySQL,再由 `schema_migrations` 追加升级。
产出目录:
```text
CMAutoBuyAdmin/
├── admin.exe 单文件,模板和静态资源都在里面
└── data/ 数据,升级时保留
├── backup/ 迁移前 SQLite 只读备份
├── logs/
└── uploads/
```
比 Client 简单——Go 没有 Python 那种依赖收集问题,不需要 `app/` 目录。
## 10. 任务完成定义
单元任务同时满足下面几条才算完成:
1. 工单验收标准逐项通过;
2. 代码、迁移、测试和必要文档同步完成;
3. 没有静默跳过的测试;
4. 变更不泄露敏感信息;
5. Gitea 工单更新最终结果和提交哈希;
6. 完成记录归档到 `docs/task`。
## 11. AI 外部调用安全
- API Key 不得进入数据库、`data/`、日志、页面响应、测试快照、工单或归档;测试只使用假密钥。
- 生产必须通过 `CMAUTOBUY_AI_SECRETS_PATH` 指向 release、仓库和 `data/` 之外的绝对路径;
发布重启前后都要核对环境变量、服务账号读写权限和文件 `600` 权限,不能只发布二进制。
- systemd 只把独立的 `/etc/cmautobuy/secrets/` 通过 `ReadWriteDirectories` 加入写白名单;
不得把含数据库环境文件的整个 `/etc/cmautobuy/` 目录交给服务账号写入。
- 密钥文件使用同目录临时文件、`fsync`、原子替换和 `600` 权限,失败时旧文件保持可用。
- 连接测试只发送模型名和最小无业务文本,不发送订单号、店铺账号、用户、地址或 Client 信息。
- Base URL、DNS 解析、实际拨号和重定向都要执行 SSRF 校验;私有地址只能由部署级允许列表开放。
- Base URL 可使用 HTTP 或 HTTPS,但 HTTP 不提供传输加密,API Key 和匹配请求会以明文经过网络,只应在可信网络使用。
- 测试失败、超时和非 2xx 响应不得记录 Authorization、完整请求或完整响应。
- 规格匹配请求只包含商品标题、规格文本和候选短编号,不包含订单号、店铺账号、用户、地址或 Client 信息。
- 模型返回值必须经过严格 JSON 结构、候选白名单、颜色冲突、额外维度、置信度和上下文版本校验;模型自报置信度不能替代硬门禁。
- 批量匹配用服务端限制单批 100 条和配置的有界并发;相同业务上下文只调用一次,跟随项必须
复用数据库中的当前有效映射,不能只复制模型文本结果。
- 批次和逐条状态必须持久化。Admin 异常退出或重启后,遗留 `queued/running` 状态改为
`interrupted`;成功映射不回滚,未完成条目允许重新勾选,且不能因为重试覆盖人工映射。
- 批次状态接口按创建人隔离,管理员除外;响应和日志只含批次号、计数和脱敏原因,不含密钥、
完整模型请求/响应或订单隐私数据。
### 11.1 采购运行时规格解析联合验收
Admin 和 Client 必须共同读取
`testdata/contracts/purchase_spec_resolution_v1.json`,不得各自维护一份容易漂移的样例。
这份样例只使用虚构任务号、Client 编号、商品标题和规格,不得加入真实订单、账号、地址、
手机号、设备号或凭据。
联合验收至少覆盖:
- 规则唯一命中、规则歧义、AI 白名单命中、低置信度、额外维度、伪造候选和模型超时;
- `snapshot_hash`、`idempotency_key`、任务版本、领取关系和请求大小等 HTTP 错误;
- 同一请求重放返回同一结果,冲突请求不得覆盖原结果或重复调用模型;
- SQLite 服务测试和以 `_test` 结尾的 MySQL 8 测试库均验证迁移、唯一约束和审计持久化;
- Client 的 Mock 与真实 HTTP Gateway 对同一向量产生相同请求和错误分类。
从仓库根目录执行核心联合测试:
```powershell
Set-Location admin
$env:GOTOOLCHAIN='go1.23.0'
go test ./handler/api ./service -run PurchaseSpecResolutionContractVectors -count=1
Set-Location ../client
C:/Python310/python.exe -m pytest -q test/test_purchase_spec_resolution_contract_vectors.py
```
真实设备验收不是自动测试的替代品。它只能在项目负责人指定测试商品、账号、Client、Android
设备和价格上限后执行,并且最多创建一个未付款订单;到达不可逆阶段后只能核对订单,禁止重试
下单,整个过程不进入付款页面。