Files
cmautobuy/admin/service/service.go
T
chengmaandClaude Opus 5 799ee33045 feat: 搭建 Admin 项目骨架(Go + Gin + html/template)
可运行的骨架:四个页面能打开、数据库自动建表、给 Client 的三个接口
按契约响应。业务逻辑留 35 处 TODO(骨架),每处标明要点和文档章节。

结构(对应 docs/admin/02-architecture.md §2/§3)
- handler/web + handler/api 分开:页面要 CSRF、出错渲染错误页;
  接口要认证、出错返回 JSON。混在一起迟早写错
- service 不认识 *gin.Context,方便不起服务器直接单测
- 只有 repository 能写 SQL
- 模板用 {{define "目录/名字"}},一次全部 ParseFS 进来不冲突
- 模板和静态资源用 //go:embed 打进二进制

已实测(Go 1.23.0,本机验证)
- go build / go vet / gofmt 全部通过
- 单文件产物 34MB,无需 cgo
- 四个页面 + 静态资源均 200,/ 正确 302 到 /shopee
- 建表 7 张 + 索引 10 个,user_version=1,二次启动不重复建表
- claim 带 X-Client-Id 返回 204(无任务可领),
  缺 X-Client-Id 返回 400 + 契约格式的 JSON 错误体

依赖版本被 Go 1.23.0 卡死,已钉住并写进文档
- gin v1.11.0        (v1.12.0 起要求 Go >= 1.25.0)
- modernc.org/sqlite v1.38.0(v1.40.0 起要求 >= 1.24.0,v1.48.0 起 >= 1.25.0)
- excelize v2.9.1    (尚未入 go.mod,写导入功能时再加)
直接 go get 不带版本会拉到最新版并报 requires go >= 1.25.0,
所以文档里的命令一律带版本号。要升依赖就得先升 Go。

一处主动调整:迁移语句从「一个字符串装多条 SQL」改成 [][]string 逐条执行。
database/sql 的 Exec 对一次多条语句的支持因驱动而异,拆开最稳妥,
报错还能精确到第几条。

说明:Gitea 尚未配置,本次无对应工单号。
admin/testdata/ 只有 README,脱敏小样本需人工制作。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 16:33:45 +08:00

135 lines
5.4 KiB
Go
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.
// Package service 放业务逻辑。
//
// 本包**不认识 *gin.Context**——这样才能不起服务器就写单元测试。
// handler 负责取参数,service 负责判断和编排,repository 负责读写数据库。
//
// 骨架阶段这里只有函数签名和 TODO,实现按工单逐个补。
package service
import (
"database/sql"
"errors"
)
// ErrNotImplemented 表示该功能还没实现。
// 补完实现后要把对应的返回删掉,不要留着假装能用。
var ErrNotImplemented = errors.New("功能尚未实现")
// ---------- 蝦皮 Excel 导入 ----------
// ImportResult 是一次导入的统计结果,要显示给操作员看。
type ImportResult struct {
ProductCount int // 写入的商品数,样本应为 5195
SKUCount int // 写入的 SKU 数,样本应为 6092
FailedRows []int // 解析失败的行号,**不要静默跳过**
}
// ImportShopeeExcel 解析蝦皮报表并 upsert 进库。
//
// 实现要点见 docs/admin/03-data-model.md §3.3,五步缺一不可:
// 1. 分行:商品規格ID 为 "-" 的是商品汇总行,其余是 SKU 行;
// 2. 按列名找索引,不要写死列号(报表 40 列,蝦皮改格式列号就变);
// 3. 解析规格原文:先按第一个逗号切开,右边再提取建议。
// 实测两种格式各占 53.4% / 46.6%,只认【】会漏掉一半。
// **任何一步失败都不要猜**,parse_ok 置 0,颜色尺码留空,原文照存;
// 4. upsert,**绝不清空**。DO UPDATE SET 里不得出现
// pdd_goods_url / pdd_data / collect_status——报表里没这些列,
// 写进去会把人工填的洗成空;
// 5. 返回统计。
func ImportShopeeExcel(db *sql.DB, path string) (*ImportResult, error) {
// TODO(骨架): 用 github.com/xuri/excelize/v2 流式读行
return nil, ErrNotImplemented
}
// ParseSpec 把蝦皮的规格原文拆成颜色、尺码、建议。
//
// 输入样例(两种格式各占一半):
//
// "黑色,M【建議40-50公斤】" -> 黑色 / M / 40-50公斤 / true
// "卡其色拼黑色,L 建議50-57.5kg" -> 卡其色拼黑色 / L / 50-57.5kg / true
// "莫名其妙的格式" -> "" / "" / "" / false
//
// 最后一个返回值是 ok;false 时前三个必须为空,**不要猜**。
// 调用方要把 parse_ok 存进库,界面上把这些行标出来让人工补。
func ParseSpec(raw string) (color, size, advice string, ok bool) {
// TODO(骨架): 按第一个逗号切开;右边先找【建議...】,再找空格后的 建議...
return "", "", "", false
}
// ---------- 采集 ----------
// CreateCollectTasks 为若干蝦皮商品创建采集任务。
//
// 规则:
// - 按 goods_id **去重**(一个商品有多个 SKU 行,别建重复任务);
// - PDD 链接为空的跳过;
// - collect_status 已是 collecting 的跳过,并在结果里说明跳过了几个;
// - 建任务成功后把 collect_status 置为 collecting。
func CreateCollectTasks(db *sql.DB, goodsIDs []string, clientID string) (created, skipped int, err error) {
// TODO(骨架)
return 0, 0, ErrNotImplemented
}
// ---------- 规格匹配 ----------
// SaveMapping 保存「蝦皮规格 = PDD 规格」的对应关系。
//
// 存的是**可复用的映射**(sku_mappings 表),不是某一张订单的临时数据。
// 下次遇到同一个蝦皮 SKU 自动带出,操作员只需确认。
func SaveMapping(db *sql.DB, shopeeSKUID, goodsID, pddOptionsJSON, operator string) error {
// TODO(骨架): upsert sku_mappings
return ErrNotImplemented
}
// ---------- 采购任务 ----------
// TaskCreateError 说明某一条为什么建不了任务。
// **不要静默跳过**,要把这些列出来告诉操作员缺什么。
type TaskCreateError struct {
SybID string
Reason string // 例如「该商品未填写 PDD 链接」
}
// CreatePurchaseTasks 由货运单创建采购任务。
//
// 六条校验缺一不可(docs/admin/01-requirements.md §5):
// 1. 已填 PDD 链接
// 2. 已采集成功(pdd_data 非空)
// 3. 已有 SKU 映射
// 4. 数量 > 0
// 5. **价格上限已填且 > 0**——默认从 pdd_data 里该 SKU 的价格带出,
// 操作员可改但不允许为空。没有它 Client 会拒绝执行。
// 6. 已选择分配的客户端
//
// 另外 pdd_goods_url 必须写进任务,Client 那边是 NOT NULL。
func CreatePurchaseTasks(db *sql.DB, sybIDs []string, clientID string) (created int, failures []TaskCreateError, err error) {
// TODO(骨架)
return 0, nil, ErrNotImplemented
}
// ---------- 客户端 ----------
// RegisterClient 在客户端领取任务时登记或更新它。
//
// **没有单独的注册接口,也没有心跳**——注册就在 claim 里做,
// 理由见 docs/admin/04-client-api.md §3。
//
// 规则:
// - 新 client_id 就新增,已有就更新 device/capabilities/last_seen_at;
// - name 若已被人工改过,**不要用客户端上报的覆盖**;
// - 客户端没上报 name 时,用 client_id 当显示名。
func RegisterClient(db *sql.DB, clientID, name, address, platform, pddPackage, capabilitiesJSON string) error {
// TODO(骨架): upsert clients
return ErrNotImplemented
}
// TouchClient 刷新 last_seen_at。
//
// claim / result / failure **三个接口都要调**。
// 只在 claim 里调的话,客户端执行长任务期间不调 claim,
// 会被误判成离线。
func TouchClient(db *sql.DB, clientID string) error {
// TODO(骨架)
return ErrNotImplemented
}