Files
cmautobuy/docs/admin/02-architecture.md
T
chengmaandClaude Opus 5 f0d7da37cf feat: 采集采购页,采集与采购统一展示 (#19)
原来的采购任务页是骨架:var rows []gin.H 从不查库,页面永远为空;
TODO 写的还是只查 task_type = 'purchase'。所以 #18 建出来的采集任务
在界面上哪儿都看不到——用户点完「创建采集任务」只能盯着
collect_status 猜。

模块名定为「采集采购」(用户指定),直接点出这页装的是哪两类任务,
比泛称「任务」更能让人一眼知道点进去看什么。路由 /tasks 不变,
改路由会让已有书签和文档链接全失效,没有收益。

列不按类型并列——两种任务字段完全不同,并列会让采集任务行一半是空列。
改成固定列 + 一列「目标」把业务信息概括成一句话:
  采集  PDD 737116531267
  采购  SO-001 · M/黑色 · 2件 · ≤¥42.00
拼接逻辑在 service 层,模板只负责显示。

统计和列表共用同一个筛选条件拼装函数。分开写的话总有一天会忘了
给统计也加条件,数字和表格对不上,操作员会以为页面坏了。

无主任务的客户端列显示「—」。#17 之后采集任务默认无主,这列会大量为空。

详情弹窗只读,采购专有字段(数量、价格上限、目标规格)在采集任务里
整段不出现,不显示空行。复用 #18 的弹窗机制,app.js 无需改动。

顺带清掉 PDD 页加进来之后一直没跟上的模块计数:多处「四个模块/四个页面」
改成五个。其中 06-quality-security.md 那两处是验证清单,
照着做的人只会测四个页面,PDD 页永远不在回归范围里。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:16:05 +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. 相关文档