Files
cmautobuy/docs/admin/02-architecture.md
T
chengmaandClaude Opus 5 90379347b4 docs: 建立 Admin 子项目的文档基线
仓库从单子项目变成两个:client(Python + PyQt5 桌面端)和
admin(Go + Gin + HTML 模板 Web 管理端)。两者技术栈完全不同,
规则各自独立,只通过三个 HTTP 接口交互。

新增 admin/AGENTS.md 和 docs/admin/ 共 9 份文档,结构与 docs/client/ 对齐。

关键设计决策(均已与用户确认)
- 四模块:蝦皮数据 / 顺运宝数据 / 采购任务 / 客户端列表,
  统一三段式布局(工具条 / 带勾选的表格 / 状态条)
- 蝦皮数据拆成商品表 + SKU 表:报表本身就是两层结构,
  且 PDD 链接是商品级的,放 SKU 级会重复维护
- pdd_data 存商品级,一个 PDD 商品采一次,所有订单共用
- SKU 映射独立成表且可复用,同一蝦皮 SKU 只需人工匹配一次
- PDD 链接人工填写,采集按钮就放在填链接的编辑弹窗里
- 任务分配给指定客户端;不加心跳,注册在领取时完成,
  在线状态由 last_seen_at 派生
- SQLite 驱动固定 modernc.org/sqlite(纯 Go 免 cgo,go build 直接出 exe)
- 不引入前端框架和 npm 构建,只允许原生 JS 或 htmx

基于样本文件 raw_data/蝦皮数据样本.xlsx 实测得出的约束
- 11287 行 = 5195 商品汇总行 + 6092 SKU 行,导入必须分开处理
- 商品規格ID 零重复,是天然主键
- 规格原文两种格式各占 53.4% / 46.6%,只解析【】会漏掉一半
- 平均每商品仅 1.17 个 SKU,报表不是全量目录,
  因此必须支持手动新增,且货运单外键不能加硬约束

同步更新
- 根 AGENTS.md 开头的指针改为两个子项目对照表
- docs/README.md 重构为双子项目索引
- .gitignore 加 admin/data/、admin.exe、Excel 锁文件

说明:Gitea 尚未配置,本次无对应工单号。
raw_data/ 未提交,含台币销售额等商业数据,待用户决定。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:49:04 +08:00

8.7 KiB
Raw Blame History

02 Admin 系统架构

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

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

1. 架构目标

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

2. 分层

┌──────────────────────────────────────────────┐
│ handler/web      页面:解析表单 → 调 service │
│                  → 渲染模板                  │
│ handler/api      给 Client 的 JSON 接口      │
└───────────────────────┬──────────────────────┘
                        │ 只传普通结构体
┌───────────────────────▼──────────────────────┐
│ service          业务逻辑                     │
│                  导入解析 / 建任务 / 匹配复用 │
└───────────────────────┬──────────────────────┘
                        │
┌───────────────────────▼──────────────────────┐
│ repository       SQLite 读写                  │
│                  只有这一层能写 SQL           │
└───────────────────────┬──────────────────────┘
                        │
┌───────────────────────▼──────────────────────┐
│ model            纯数据结构,不导入 Gin/驱动  │
└──────────────────────────────────────────────┘

规则:

  • [必须] handler 不写业务逻辑、不拼 SQL。它只做三件事:取参数、调 service、渲染。
  • [必须] service 不认识 *gin.Context。这样才能不起服务器就单测。
  • [必须] 只有 repository 能写 SQL,且一律参数化查询。
  • [必须] handler/web 和 handler/api 分开: 页面出错要渲染错误页,接口出错要返回 JSON;页面要 CSRF,接口要认证。 混在一起迟早写错。

3. 目录

admin/
├── main.go                 启动、路由注册
├── go.mod
├── config/                 端口、路径、超时
├── handler/
│   ├── web/                四个模块的页面
│   │   ├── shopee.go
│   │   ├── syb.go
│   │   ├── task.go
│   │   └── client.go
│   └── api/                给 Client 的接口
│       └── client_api.go
├── service/
│   ├── shopee_import.go    Excel 解析与 upsert
│   ├── collect.go          创建采集任务
│   ├── mapping.go          SKU 映射复用
│   ├── purchase.go         创建采购任务与校验
│   └── clientreg.go        客户端注册与在线判断
├── repository/
│   ├── db.go               连接、迁移
│   ├── shopee.go
│   ├── syb.go
│   ├── task.go
│   └── client.go
├── model/                  纯结构体
├── templates/              见 §4
├── static/
│   ├── css/
│   └── js/
├── testdata/               测试用的小样本
└── data/                   运行时数据,见 §6(不进 Git)

[建议] 一个模块一个文件,文件名和模块对应。初级程序员找代码时不用猜。

4. 模板组织

templates/
├── layout.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/
├── admin.db        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/ 打包后和程序文件混在一起,升级时会被删

[必须] 升级方式:换掉 exe,data/ 原样保留。所以数据库必须支持迁移,见 03 §2。

7. 请求流程示例

以"导入蝦皮 Excel"为例,看清各层职责:

浏览器 POST /shopee/import (multipart 文件)
   ↓
handler/web/shopee.go
   - 校验文件大小和扩展名
   - 存到 data/uploads/
   - 调 service.ImportShopeeExcel(path)
   ↓
service/shopee_import.go
   - 用 excelize 逐行读
   - 按 商品規格ID 是否为 "-" 分成商品行/SKU 行
   - 解析规格原文 → 颜色/尺码/建议(失败留空)
   - 调 repository 做 upsert
   - 返回 {商品数, SKU数, 失败行号列表}
   ↓
repository/shopee.go
   - INSERT ... ON CONFLICT(...) DO UPDATE
   - 只更新报表来的字段,人工填的 pdd_goods_url 不动
   ↓
handler 渲染结果页:导入 5195 商品 / 6092 SKU,失败 0 行

[必须] 注意最后一步:upsert 时不得覆盖人工维护的字段。 这是导入逻辑里最容易写错、后果最严重的一处,见 03 §3.3。

8. 与 Client 的边界

  • Admin 是任务的创建者和分配者,Client 是执行者。
  • Admin 不知道 Client 执行到哪一步(没有心跳,是有意的)。
  • Client 提交结果时,Admin 无条件接受,哪怕任务已取消或已重派。
  • 详见 04 Client 接口实现。

9. 相关文档