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

243 lines
13 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 单元测试
`[必须]` 覆盖:
- **规格原文解析**:两种格式各占一半,两种都要有用例,
再加解析失败的用例(确认 `parse_ok=0` 且不瞎猜);
- **Excel 导入分行**:商品汇总行 vs SKU 行的判断;
- **upsert 不覆盖人工字段**:先填 PDD 链接,再导入一次,断言链接还在;
- 任务创建校验:商品、采集结果、有效映射、数量、人民币价格上限、客户端可见范围逐条覆盖;
- 采购任务重复提交保护、部分业务失败、PDD 换品隔离和采集选项失效;
- 金额换算显示(分 ↔ 元),边界值 0 和大额;
- SKU 映射复用:第二次匹配同一 SKU 应自动带出;
- 在线状态派生:`last_seen_at` 刚好在边界前后。
- 密码哈希校验:正确密码成功,错误密码失败,数据库不出现明文密码;
- 密码长度边界:5 个字符拒绝、6 个字符接受;初始化、创建、重置和管理员自助修改规则一致;
- 角色校验:管理员可以管理用户,采购员访问用户管理返回 `403`;
- 最后管理员保护:不能禁用最后一个有效管理员。
- 客户端归属:一人多客户端、一台客户端唯一当前负责人、转交/解绑历史完整;
- 归属权限:采购员只看到自己的客户端,不能绑定、解绑或删除;禁用采购员不能成为新目标。
`[必须]` 导入相关的测试用 `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 把整个进程带崩。
- `[必须]` 文件名不得直接用于拼路径(路径穿越),落盘时用自己生成的名字。
- `[建议]` 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 防护。
- `[必须]` 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。
`[建议]` 记录这些事件:
- `shopee_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。
`[必须]` MySQL 公网端口不得向全网开放。项目负责人明确批准直连时,必须同时使用
固定 `/32` 来源白名单、公网接口其余来源 DROP、来源 IP 限定账号、最小库权限和
`REQUIRE SSL`;root 始终仅限服务器本机。
## 7. 性能
规模很小(个位数操作员、集中部署),不要提前优化。但下面几条是基本功:
- `[必须]` 搜索、排序、分页走**数据库查询**,不要一次查全量再在内存里过滤。
- `[必须]` 常用查询有索引(见 [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`。