Files
cmautobuy/docs/admin/02-architecture.md
T

304 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.
# 02 Admin 系统架构
- 文档状态:基线草案,待架构评审
- 适用范围:`admin/`
本文档中 `[必须]` / `[建议]` / `[待定]` 的含义见 [文档索引](../README.md#文档标注说明)。
没有标注的默认是 `[必须]`。看不懂的词查 [术语表](00-glossary.md)。
## 1. 架构目标
- 结构简单到初级程序员能独立加一个模块;
- 页面服务端渲染,不引入前端构建链;
- 业务逻辑和 Web 框架解耦,方便单测;
- 给 Client 的接口和给浏览器的页面互不干扰;
- `go build` 出单个 exe,不依赖 C 编译器。
## 2. 分层
```text
┌──────────────────────────────────────────────┐
│ handler/web 页面:解析表单 → 调 service │
│ → 渲染模板 │
│ handler/api 给 Client 的 JSON 接口 │
└───────────────────────┬──────────────────────┘
│ 只传普通结构体
┌───────────────────────▼──────────────────────┐
│ service 业务逻辑 │
│ 导入解析 / 建任务 / 匹配复用 │
└───────────────────────┬──────────────────────┘
│
┌───────────────────────▼──────────────────────┐
│ repository MySQL 8 读写 │
│ 只有这一层能写 SQL │
└───────────────────────┬──────────────────────┘
│
┌───────────────────────▼──────────────────────┐
│ model 纯数据结构,不导入 Gin/驱动 │
└──────────────────────────────────────────────┘
```
规则:
- `[必须]` `handler` 不写业务逻辑、不拼 SQL。它只做三件事:取参数、调 service、渲染。
- `[必须]` `service` 不认识 `*gin.Context`。这样才能不起服务器就单测。
- `[必须]` 只有 `repository` 能写 SQL,且**一律参数化查询**。
- `[必须]` `handler/web` 和 `handler/api` **分开**:
页面出错要渲染错误页,接口出错要返回 JSON;页面要 CSRF,接口要认证。
混在一起迟早写错。
- `[必须]` Web 登录中间件只挂在 `handler/web` 路由组,不能挂到整个 Gin Engine。
`/api/v1/client/*` 本阶段保持原有接口行为,不能返回登录页或 302 重定向。
### 2.1 登录与路由边界
```text
公开网页
├── GET/POST /setup 仅数据库没有用户时可用
├── GET/POST /login
└── /static/*
需要登录的网页
├── /shopee /pdd /syb /tasks /clients
├── POST /logout
└── /users 仅管理员
保持原样的 Client API
└── /api/v1/client/*
```
- `Session` 是服务器保存的网页登录状态,浏览器只持有随机 Cookie。
- 没有任何用户时,业务网页跳转到 `/setup`;初始化完成后 `/setup` 的服务端处理永久拒绝再次创建管理员。
- 有用户但未登录时,业务网页跳转到 `/login`;API 继续返回原有 JSON 或 `204`。
- Handler 从中间件写入的当前用户读取身份,不信任表单里的 `user_id` 或 `role`。
- 采购员可以使用现有业务页面,但访问 `/users` 必须返回 `403`。
## 3. 目录
```text
admin/
├── main.go 启动、路由注册
├── go.mod
├── config/ 端口、路径、超时
├── handler/
│ ├── web/ 五个模块的页面
│ │ ├── auth.go 初始化、登录、退出
│ │ ├── user.go 管理员维护采购员
│ │ ├── shopee.go
│ │ ├── syb.go
│ │ ├── task.go
│ │ └── client.go
│ └── api/ 给 Client 的接口
│ └── client_api.go
├── service/
│ ├── catalog_import.go 商品目录批次校验、幂等与 upsert
│ ├── collect.go 创建采集任务
│ ├── mapping.go SKU 映射复用
│ ├── purchase.go 创建采购任务与校验
│ └── clientreg.go 客户端注册与在线判断
├── repository/
│ ├── db.go 连接、迁移
│ ├── user.go 用户和 Web Session
│ ├── shopee.go
│ ├── syb.go
│ ├── task.go
│ └── client.go
├── model/ 纯结构体
├── templates/ 见 §4
├── static/
│ ├── css/
│ └── js/
├── testdata/ 测试用的小样本
└── data/ 运行时数据,见 §6(不进 Git)
```
`[建议]` 一个模块一个文件,文件名和模块对应。初级程序员找代码时不用猜。
## 4. 模板组织
```text
templates/
├── layout.html 整体骨架:导航 + 内容占位 + 状态条
├── auth/
│ ├── setup.html 首次管理员初始化
│ └── login.html
├── user/list.html 采购员账号管理
├── shopee/
│ ├── list.html 表格页
│ └── edit_modal.html 编辑弹窗(含 PDD 链接和采集按钮)
├── syb/
│ ├── list.html
│ └── match_modal.html 规格匹配弹窗
├── task/list.html
├── client/list.html
└── partials/
├── toolbar.html 顶部工具条
├── table.html 带勾选的表格
└── statusbar.html 底部状态条
```
`[必须]` 三段式布局做成 `partials/` 里的公共片段,五个模块复用。
**不要每个页面复制一份**——改一次样式要改五个地方,必然改漏。
`[必须]` 模板输出走 `html/template` 的自动转义。
**禁止用 `template.HTML` 包裹用户可控的内容**(商品名、订单号都是外部来的)。
## 5. 前端约束
`[必须]` **不引入 React / Vue / npm / 打包器。**
| 功能 | 怎么实现 |
|---|---|
| 搜索、删除、导入 | 普通表单 POST,服务端渲染,**零 JS** |
| 全选 / 反选 | 原生 JS,十几行 |
| 双击行开弹窗 | 原生 JS 或 htmx |
| 导入进度 | MVP 先做成"提交后显示结果统计",不做实时进度条 |
`[必须]` 破坏性操作(删除、导入)用 **POST**,不得用 GET。
浏览器和爬虫会预取 GET 链接,用 GET 删数据迟早出事故。
## 6. 文件位置
跟 Client 一样是**便携模式**:程序自己产生的东西全在 `data/` 下。
```text
data/
├── backup/ 迁移前 SQLite 只读备份
├── logs/ 运行日志
└── uploads/ 上传的 Excel 原件
```
| 情况 | `data/` 在哪 |
|---|---|
| 打包成 exe 后 | exe 旁边 |
| 直接 `go run .` | `admin/data/` |
`[必须]` 路径统一走一个函数,**代码里不许出现第二处拼路径的地方**:
```go
package config
import (
"os"
"path/filepath"
)
// DataDir 返回可写数据目录,不存在就创建。
// 打包后 = exe 旁边的 data/;go run 时 = 当前目录下的 data/。
func DataDir() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", err
}
dir := filepath.Join(filepath.Dir(exe), "data")
// go run 会把程序编译到临时目录,那种情况下用当前工作目录
if isTempBuild(exe) {
wd, err := os.Getwd()
if err != nil {
return "", err
}
dir = filepath.Join(wd, "data")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return dir, nil
}
```
| 别这么做 | 为什么 |
|---|---|
| 硬编码 `D:\...\data` | 换台机器就废了 |
| 直接用 `os.Getwd()` | 双击 exe 和命令行启动,当前目录不一样 |
| 往 exe 所在目录写日志但不建 `data/` | 打包后和程序文件混在一起,升级时会被删 |
`[必须]` 升级时保留 `data/`,并先备份 MySQL。数据库结构通过 MySQL
`schema_migrations` 只追加升级,见 [03](03-data-model.md) §2。
## 7. 请求流程示例
以“第三方脚本导入商品目录”为例,看清各层职责:
```text
第三方脚本 POST /api/v1/integrations/catalog/batches(JSON)
↓
handler/integration/catalog_api.go
- 独立 Bearer 鉴权
- 限制请求体并解析 JSON
- 调 service.ImportCatalogBatch(...)
↓
service/catalog_import.go
- 校验批次、商品、SKU、金额和关联
- 开单事务并处理幂等/冲突
- 调 repository 做 upsert
- 返回各类新增、更新和关联计数
↓
repository/catalog_import.go
- 按来源观测时间更新
- 人工 pdd_goods_url / pdd_goods_id 和 is_manual 不动
↓
handler 返回批次 JSON;管理员在统一导入记录页查看摘要
```
`[必须]` 注意最后一步:**upsert 时不得覆盖人工维护的字段**。
这是导入逻辑里最容易写错、后果最严重的一处,见 [03](03-data-model.md) §3.3。
## 8. 与 Client 的边界
- Admin 是任务的创建者和分配者,Client 是执行者。
- Admin **不知道** Client 执行到哪一步(没有心跳,是有意的)。
- Client 提交结果时,Admin **无条件接受**,哪怕任务已取消或已重派。
- Client 的运行时规格解析是 `POST /api/v1/client/tasks/{task_id}/spec-resolution` 一次性命令,
不是任务状态查询或轮询;权威字段、哈希和错误码仍以 Client 侧契约 §7.1 为准。
- Admin 用户登录与 Client 身份是两套边界。Web Session 不传给 Client,
Web 登录中间件也不覆盖 `/api/v1/client/*`。
- 详见 [04 Client 接口实现](04-client-api.md)。
## 9. AI 配置与密钥边界
AI 服务商的普通配置和非敏感审计由 `repository` 写入 MySQL。API Key 通过
`service.AISecretStore` 写入部署指定的独立文件;该文件必须使用绝对路径,不能位于
仓库、`data/` 或 release 目录,生产建议为 `/etc/cmautobuy/secrets/ai-secrets.yaml` 且权限为
`600`。页面只得到“是否配置”和固定掩码尾号。
外部模型调用位于独立 HTTP 客户端边界:Base URL 允许 HTTP 和 HTTPS,拒绝其他协议、
URL 内凭据、环回、链路本地、云元数据和未显式允许的私网地址;重定向和实际拨号也执行
相同检查。HTTP 不提供传输加密,只应在可信网络使用。部署级 `allowed_hosts` 是私有模型
端点的唯一例外入口,网页不能修改,也没有单独的 HTTP 开关。
规格匹配按“确定性规则 → 有限候选 → 模型选择 → 服务端硬校验 → 事务写入”的顺序执行。
模型只看到商品标题、SYB 规格文本和后端生成的候选短编号,不能生成真实 PDD 选项键。
唯一确定的规则结果不调用模型;有效人工映射始终优先。保存前重新计算上下文版本并检查
当前可购买候选,防止 PDD 重采集或人工并发修改后写入过期结果。
采购运行时规格解析复用同一个 `AISecretStore`、端点策略、HTTP 客户端、超时、响应结构
校验和置信度阈值,但不复用 SYB 映射写入路径。Handler 只限制 64 KiB 请求体并翻译稳定
错误码;`service/purchase_spec_resolution.go` 负责请求/哈希/任务身份校验、规则优先和 AI
候选白名单;`repository/purchase_spec_resolution.go` 只保存观察和最终决策。
一次解析分成以下三个边界:
1. 短事务核对任务和领取历史,写入 `purchase_spec_resolutions.outcome=pending` 候选观察;
2. 事务外执行确定性规则或调用模型,同一进程内相同幂等身份串行,避免并发重复调用;
3. 短事务把 `pending` 完成一次,并与 `idempotency_keys` 的完整响应一起提交。
服务中断后留下的 `pending` 可以由原幂等请求接管;最终 SQL 仍只允许第一次从 `pending`
完成。响应中的 `match` 必须从保存的请求候选逐字重建。此链路不更新任务、PDD 商品和
下单执行状态;`shopee_backfill` 只保留为来源诊断信号,不参与启用或拒绝判断。
批量入口先在事务中创建 `ai_match_batches` 和逐条 `ai_match_batch_items`,再由 Admin 进程内
的有界工作池执行。工作池按业务上下文哈希归并相同明细,运行中持续写入逐条状态和汇总计数;
浏览器只轮询批次状态接口,不持有 API Key,也不承担匹配判断。Admin 启动时把遗留的
`queued/running` 批次和未完成明细标记为 `interrupted`,成功映射保持不变。
## 10. 相关文档
- [上手指南](00-getting-started.md)
- [术语表](00-glossary.md)
- [产品需求基线](01-requirements.md)
- [数据模型](03-data-model.md)
- [Client 接口实现](04-client-api.md)
- [界面规范](05-ui-specification.md)
- [质量与安全](06-quality-security.md)
- [Client 侧接口契约](../client/04-admin-api-contract.md)(对接时以它为准)