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

12 KiB
Raw Blame History

02 Admin 系统架构

  • 文档状态:基线草案,待架构评审
  • 适用范围:admin/

本文档中 [必须] / [建议] / [待定] 的含义见 文档索引。 没有标注的默认是 [必须]。看不懂的词查 术语表。

1. 架构目标

  • 结构简单到初级程序员能独立加一个模块;
  • 页面服务端渲染,不引入前端构建链;
  • 业务逻辑和 Web 框架解耦,方便单测;
  • 给 Client 的接口和给浏览器的页面互不干扰;
  • go build 出单个 exe,不依赖 C 编译器。

2. 分层

┌──────────────────────────────────────────────┐
│ 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 登录与路由边界

公开网页
├── 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. 目录

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. 模板组织

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/ 下。

data/
├── backup/         迁移前 SQLite 只读备份
├── logs/           运行日志
└── uploads/        上传的 Excel 原件
情况 data/ 在哪
打包成 exe 后 exe 旁边
直接 go run . admin/data/

[必须] 路径统一走一个函数,代码里不许出现第二处拼路径的地方:

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 §2。

7. 请求流程示例

以“第三方脚本导入商品目录”为例,看清各层职责:

第三方脚本 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 §3.3。

8. 与 Client 的边界

  • Admin 是任务的创建者和分配者,Client 是执行者。
  • Admin 不知道 Client 执行到哪一步(没有心跳,是有意的)。
  • Client 提交结果时,Admin 无条件接受,哪怕任务已取消或已重派。
  • Admin 用户登录与 Client 身份是两套边界。Web Session 不传给 Client, Web 登录中间件也不覆盖 /api/v1/client/*。
  • 详见 04 Client 接口实现。

9. AI 配置与密钥边界

AI 服务商的普通配置和非敏感审计由 repository 写入 MySQL。API Key 通过 service.AISecretStore 写入部署指定的独立文件;该文件必须使用绝对路径,不能位于 仓库、data/ 或 release 目录,生产建议为 /etc/cmautobuy/ai-secrets.yaml 且权限为 600。页面只得到“是否配置”和固定掩码尾号。

外部模型调用位于独立 HTTP 客户端边界:Base URL 允许 HTTP 和 HTTPS,拒绝其他协议、 URL 内凭据、环回、链路本地、云元数据和未显式允许的私网地址;重定向和实际拨号也执行 相同检查。HTTP 不提供传输加密,只应在可信网络使用。部署级 allowed_hosts 是私有模型 端点的唯一例外入口,网页不能修改,也没有单独的 HTTP 开关。

规格匹配按“确定性规则 → 有限候选 → 模型选择 → 服务端硬校验 → 事务写入”的顺序执行。 模型只看到商品标题、SYB 规格文本和后端生成的候选短编号,不能生成真实 PDD 选项键。 唯一确定的规则结果不调用模型;有效人工映射始终优先。保存前重新计算上下文版本并检查 当前可购买候选,防止 PDD 重采集或人工并发修改后写入过期结果。

批量入口先在事务中创建 ai_match_batches 和逐条 ai_match_batch_items,再由 Admin 进程内 的有界工作池执行。工作池按业务上下文哈希归并相同明细,运行中持续写入逐条状态和汇总计数; 浏览器只轮询批次状态接口,不持有 API Key,也不承担匹配判断。Admin 启动时把遗留的 queued/running 批次和未完成明细标记为 interrupted,成功映射保持不变。

10. 相关文档