304 lines
13 KiB
Markdown
304 lines
13 KiB
Markdown
# 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)(对接时以它为准)
|