From 0588ef18c8dfa63449d7e3e86a6acc6d90b6bc71 Mon Sep 17 00:00:00 2001 From: chengma Date: Tue, 11 Aug 2026 10:16:48 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=88=87=E6=8D=A2=E8=9D=A6?= =?UTF-8?q?=E7=9A=AE=E6=95=B0=E6=8D=AE=E5=88=B0=E7=9B=AE=E5=BD=95=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=20(#135)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/AGENTS.md | 4 +- admin/go.mod | 2 +- admin/handler/web/csrf.go | 3 - admin/handler/web/shopee.go | 99 +------- admin/handler/web/web.go | 1 - admin/main_test.go | 31 +++ admin/service/service.go | 5 - admin/service/shopee_import.go | 381 ---------------------------- admin/service/shopee_import_test.go | 381 ---------------------------- admin/service/shopee_list.go | 2 +- admin/templates/shopee/list.html | 34 +-- admin/testdata/README.md | 31 +-- docs/admin/00-getting-started.md | 2 +- docs/admin/00-glossary.md | 2 +- docs/admin/01-requirements.md | 8 +- docs/admin/02-architecture.md | 31 ++- docs/admin/03-data-model.md | 80 +----- docs/admin/05-ui-specification.md | 7 +- docs/admin/06-quality-security.md | 8 +- docs/admin/08-顺运宝接口.md | 15 +- 20 files changed, 84 insertions(+), 1043 deletions(-) delete mode 100644 admin/service/shopee_import.go delete mode 100644 admin/service/shopee_import_test.go diff --git a/admin/AGENTS.md b/admin/AGENTS.md index c2fbbdb..5022532 100644 --- a/admin/AGENTS.md +++ b/admin/AGENTS.md @@ -41,7 +41,7 @@ - `modernc.org/sqlite` 只保留给 SQLite → MySQL 单向迁移工具和历史库回归, 不得用于生产运行时,也不得做 SQLite/MySQL 双写或自动同步。 - 仍然**不得改用 `mattn/go-sqlite3`**,避免引入 cgo 和 gcc。 -- Excel 读取固定 `github.com/xuri/excelize/v2`。 +- PDD Excel 读取固定 `github.com/xuri/excelize/v2`;蝦皮数据由商品目录接口接入。 ### 依赖版本已钉死,不要随手升 @@ -96,7 +96,7 @@ - `[必须]` **金额一律用整数存**,人民币用分、台币用分,字段名带单位后缀(`_cent`)。 **禁止用 float 存金额。** - `[必须]` 时间一律带时区 ISO 8601,库里存 UTC,页面上转本地时区显示。 -- `[必须]` Excel 导入只做 **upsert(更新或新增)**, +- `[必须]` PDD Excel 和商品目录接口导入只做 **upsert(更新或新增)**, **绝不允许先清空再导入** —— 会把人工填的 PDD 链接全洗掉。 - `[必须]` 蝦皮规格原文(`spec_raw`)永远保留,解析不出来就留空,不要瞎猜。 - `[必须]` Client 提交结果时,**不管任务是否已取消、是否已重派,一律接受**, diff --git a/admin/go.mod b/admin/go.mod index dfb8f75..e6583c1 100644 --- a/admin/go.mod +++ b/admin/go.mod @@ -7,7 +7,7 @@ go 1.23.0 // github.com/gin-gonic/gin Web 框架 // github.com/go-sql-driver/mysql 生产 MySQL 8 驱动,纯 Go // modernc.org/sqlite 只读历史 SQLite 迁移驱动,纯 Go -// github.com/xuri/excelize/v2 读蝦皮 Excel 报表 +// github.com/xuri/excelize/v2 读 PDD 链接 Excel // // 不得改用 github.com/mattn/go-sqlite3(需要 cgo,Windows 上要装 gcc, // 交叉编译和打包 exe 都会变麻烦)。 diff --git a/admin/handler/web/csrf.go b/admin/handler/web/csrf.go index 9e81938..c925b02 100644 --- a/admin/handler/web/csrf.go +++ b/admin/handler/web/csrf.go @@ -65,9 +65,6 @@ func CSRFMiddleware() gin.HandlerFunc { case "/pdd/import": c.Request.Body = http.MaxBytesReader( c.Writer, c.Request.Body, service.MaxPddUploadBytes+1<<20) - case "/shopee/import": - c.Request.Body = http.MaxBytesReader( - c.Writer, c.Request.Body, service.MaxShopeeUploadBytes+1<<20) } submitted := c.PostForm(csrfFieldName) diff --git a/admin/handler/web/shopee.go b/admin/handler/web/shopee.go index 5d64c6a..8fa7418 100644 --- a/admin/handler/web/shopee.go +++ b/admin/handler/web/shopee.go @@ -2,17 +2,13 @@ package web import ( "fmt" - "io" "net/http" "net/url" - "os" - "path/filepath" "strconv" "strings" "github.com/gin-gonic/gin" - "cmautobuy/admin/config" "cmautobuy/admin/repository" "cmautobuy/admin/service" ) @@ -22,7 +18,7 @@ import ( // **商品级一行**(不是 SKU 级),数据来自 shopee_products 和 shopee_skus // 聚合联查。列定义见 docs/admin/05-ui-specification.md §4.2、工单 #41。 func (h *Handler) ShopeeList(c *gin.Context) { - h.renderShopeeList(c, c.Query("goods_id"), c.Query("status"), c.Query("page"), c.Query("msg"), nil) + h.renderShopeeList(c, c.Query("goods_id"), c.Query("status"), c.Query("page"), c.Query("msg")) } // ShopeeDetail 渲染双击行弹出的那个弹窗的**内容**(不是整页)。 @@ -54,99 +50,11 @@ func (h *Handler) ShopeeDetail(c *gin.Context) { }) } -// ShopeeImport 处理 Excel 上传导入。 -// -// 关键规则(写错会丢数据,见 docs/admin/03-data-model.md §3.3): -// - 商品汇总行(商品規格ID 为 "-")进 shopee_products, -// SKU 行进 shopee_skus,两者要分开处理; -// - 按列名找索引,不要写死列号; -// - 规格原文两种格式各占一半,解析失败留空并把 parse_ok 置 0,不要猜; -// - **只能 upsert,绝不允许先清空再导入**, -// 否则人工填的 PDD 链接会被洗掉; -// - upsert 的 DO UPDATE SET 里不得出现 pdd_goods_url / pdd_goods_id。 -// -// 导入结果(含全部失败行)直接渲染在返回页面里,**不走 303 跳转**—— -// 失败行可能有几十条,塞进跳转用的 URL 查询参数既丑又有长度限制, -// 没法满足"失败行要能全部看到"的要求(见工单 #38)。 -// 代价是这个页面刷新(F5)会弹出浏览器自带的"重新提交表单"确认, -// 这是标准行为,比丢失失败行信息更容易接受。 -func (h *Handler) ShopeeImport(c *gin.Context) { - // 限制整个请求体大小:50MB 上限 + 给表单其它字段留一点余量。 - // 放在 FormFile 之前,这样超限时请求体读到一半就会出错, - // 不会先把整个超大文件读进内存再拒绝。 - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, service.MaxShopeeUploadBytes+1<<20) - - fileHeader, err := c.FormFile("file") - if err != nil { - fail(c, http.StatusBadRequest, - "没有收到文件(也可能是文件超过了 50MB 上限),请重新选择 Excel 后再提交。未写入数据库。") - return - } - - src, err := fileHeader.Open() - if err != nil { - fail(c, http.StatusBadRequest, "无法打开上传的文件,请重试。未写入数据库。") - return - } - defer src.Close() - - // 只读文件开头几个字节判断是不是真的 xlsx(zip),不用整份读进内存。 - head := make([]byte, 8) - n, _ := io.ReadFull(src, head) - if err := service.ValidateShopeeUpload(fileHeader.Filename, fileHeader.Size, head[:n]); err != nil { - fail(c, http.StatusBadRequest, err.Error()+"。未写入数据库。") - return - } - if _, err := src.Seek(0, io.SeekStart); err != nil { - fail(c, http.StatusInternalServerError, "读取上传文件失败,请重试。未写入数据库。") - return - } - - uploadDir, err := config.SubDir("uploads") - if err != nil { - fail(c, http.StatusInternalServerError, "无法准备上传目录,请重试。未写入数据库。") - return - } - // `[必须]` 自己生成文件名,不用 fileHeader.Filename 拼路径—— - // 用户可控的文件名里塞一个 "../../etc/passwd" 就是路径穿越。 - savePath := filepath.Join(uploadDir, service.NewShopeeUploadFilename()) - - dst, err := os.Create(savePath) - if err != nil { - fail(c, http.StatusInternalServerError, "保存上传文件失败,请重试。未写入数据库。") - return - } - if _, err := io.Copy(dst, src); err != nil { - dst.Close() - fail(c, http.StatusInternalServerError, "保存上传文件失败,请重试。未写入数据库。") - return - } - if err := dst.Close(); err != nil { - fail(c, http.StatusInternalServerError, "保存上传文件失败,请重试。未写入数据库。") - return - } - - result, err := service.ImportShopeeExcel(h.db, savePath) - if err != nil { - fail(c, http.StatusBadRequest, - "导入失败:"+err.Error()+"。这次导入整体没有生效(已回滚),可以修好文件后重新上传。") - return - } - - msg := fmt.Sprintf("导入完成:%d 个商品 / %d 个 SKU", result.ProductCount, result.SKUCount) - if len(result.Failures) > 0 { - msg += fmt.Sprintf(",%d 行解析失败(见下方列表)", len(result.Failures)) - } - // 导入完成后回到第 1 页、清空筛选——刚导入的数据从第一页就能看到, - // 沿用筛选反而可能让人以为导入没生效(筛出来的还是老的几条)。 - h.renderShopeeList(c, "", "", "", msg, result.Failures) -} - -// renderShopeeList 是 ShopeeList 和 ShopeeImport 共用的渲染逻辑。 +// renderShopeeList 统一渲染蝦皮列表;数据写入改由商品目录接口负责。 // // `[必须]` 分页控件用 纯 GET 导航,翻页要保留 keyword/statusRaw, // 所以这里把它们原样透传回模板去拼下一页的链接,不在这里丢掉(工单 #43)。 -func (h *Handler) renderShopeeList(c *gin.Context, keyword, statusRaw, pageRaw, msg string, failures []service.ImportFailure) { +func (h *Handler) renderShopeeList(c *gin.Context, keyword, statusRaw, pageRaw, msg string) { filter := repository.ShopeeFilter{ Keyword: keyword, Status: service.ParseShopeeStatus(statusRaw), @@ -183,7 +91,6 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, statusRaw, pageRaw, "Status": status, "HasAnyProducts": result.HasAnyProducts, "IsFiltered": result.IsFiltered, - "Failures": failures, "Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()), "DetailURL": "/shopee/detail?" + detailValuesForShopee(values, result.Page), })) diff --git a/admin/handler/web/web.go b/admin/handler/web/web.go index 1e8b161..1255f75 100644 --- a/admin/handler/web/web.go +++ b/admin/handler/web/web.go @@ -56,7 +56,6 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) { // 1. 蝦皮数据 pages.GET("/shopee", h.ShopeeList) pages.GET("/shopee/detail", h.ShopeeDetail) // 双击行时前端来取弹窗内容 - pages.POST("/shopee/import", h.ShopeeImport) pages.POST("/shopee/save", h.ShopeeSave) pages.POST("/shopee/delete", h.ShopeeDelete) pages.POST("/shopee/collect", h.ShopeeCollect) diff --git a/admin/main_test.go b/admin/main_test.go index 07b3413..cc56002 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -120,6 +120,37 @@ func TestTaskPage_创建人筛选列表列和详情字段齐全(t *testing.T) { } } +func TestShopeePage_移除Excel入口且保留目录记录入口(t *testing.T) { + content, err := os.ReadFile("templates/shopee/list.html") + if err != nil { + t.Fatal(err) + } + page := string(content) + for _, removed := range []string{`action="/shopee/import"`, `type="file"`, "导入 Excel"} { + if strings.Contains(page, removed) { + t.Errorf("蝦皮页面仍包含已移除入口 %q", removed) + } + } + for _, want := range []string{`href="/integrations/catalog"`, "商品目录接口"} { + if !strings.Contains(page, want) { + t.Errorf("蝦皮页面缺少 %q", want) + } + } +} + +func TestShopeeImportRoute_已移除(t *testing.T) { + router, err := newRouter(nil) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/shopee/import", nil) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + if resp.Code != http.StatusNotFound { + t.Fatalf("旧蝦皮导入路由应为 404,实际 %d", resp.Code) + } +} + func TestTemplatesAndRoutesCanBeBuiltWithoutDatabaseConnection(t *testing.T) { if _, err := newRouter(nil); err != nil { t.Fatalf("模板或路由组装失败: %v", err) diff --git a/admin/service/service.go b/admin/service/service.go index 76fc0e7..912ca18 100644 --- a/admin/service/service.go +++ b/admin/service/service.go @@ -24,11 +24,6 @@ var ErrNotImplemented = errors.New("功能尚未实现") // ErrTaskNotVisible 同时表示任务不存在和不在当前账号范围,避免泄露他人任务。 var ErrTaskNotVisible = errors.New("任务不存在或不在当前账号可见范围") -// ---------- 蝦皮 Excel 导入 ---------- -// -// ImportResult / ImportShopeeExcel / ParseSpec 的实现见 shopee_import.go, -// 那个文件还负责上传文件的安全校验(大小、扩展名、内容魔数)。 - // ---------- 采集 ---------- // CreateCollectTasks 为若干蝦皮商品创建采集任务。 diff --git a/admin/service/shopee_import.go b/admin/service/shopee_import.go deleted file mode 100644 index 207d901..0000000 --- a/admin/service/shopee_import.go +++ /dev/null @@ -1,381 +0,0 @@ -// 蝦皮 Excel 报表导入。 -// -// 见 docs/admin/03-data-model.md §3.3(五步,缺一不可)和工单 #38。 -// 改动前必读 admin/AGENTS.md 的分层约定:本层不碰 HTTP,也不拼 SQL。 -package service - -import ( - "bytes" - "database/sql" - "fmt" - "path/filepath" - "strings" - - "github.com/xuri/excelize/v2" - - "cmautobuy/admin/repository" -) - -// ---------- 文件安全 ---------- - -// MaxShopeeUploadBytes 是上传文件的大小上限。 -// -// 真实样本 1.9MB,50MB 已经是它的 26 倍,正常的报表增长不会碰到这个上限; -// 设上限是为了防止恶意或误传的超大文件把磁盘和内存占满。 -const MaxShopeeUploadBytes = 50 * 1024 * 1024 - -// xlsxMagic 是 zip 本地文件头的魔数。xlsx 本质上是一个 zip 包, -// 光看扩展名判断不了内容——文件改个后缀就能绕过,所以要连内容一起查。 -var xlsxMagic = []byte{0x50, 0x4B, 0x03, 0x04} - -// ValidateShopeeUpload 校验上传的文件是否可以接受。 -// head 至少要传前 4 个字节(调用方从流里读出来,不需要读整个文件)。 -func ValidateShopeeUpload(filename string, size int64, head []byte) error { - if size <= 0 { - return fmt.Errorf("文件是空的") - } - if size > MaxShopeeUploadBytes { - return fmt.Errorf("文件超过 50MB 上限(当前约 %.1fMB)", float64(size)/1024/1024) - } - if !strings.EqualFold(filepath.Ext(filename), ".xlsx") { - return fmt.Errorf("只允许 .xlsx 文件,收到的是 %q", filepath.Ext(filename)) - } - if !bytes.HasPrefix(head, xlsxMagic) { - return fmt.Errorf("文件内容不像一个 xlsx(zip 格式)文件,可能是改了扩展名的其他文件") - } - return nil -} - -// NewShopeeUploadFilename 生成落盘用的文件名。 -// -// `[必须]` 不能用用户上传时的原始文件名拼路径——那是外部输入, -// 塞一个 "../../etc/passwd" 之类的名字就是路径穿越。 -// 自己生成的名字只含十六进制字符,天然安全。 -func NewShopeeUploadFilename() string { - return "shopee-" + newID() + ".xlsx" -} - -// ---------- 导入 ---------- - -// ShopeeSheetName 是报表里真正装数据的那个 sheet。 -// -// `[必须]` 按名字取,不许用第 0 个下标。工作簿另外还有 4 个 sheet -// (新上架商品 / 高潛力廣告商品 / 優化商品廣告 / 追蹤商品廣告成效), -// 全是广告报表,连「商品規格ID」列都没有。它现在恰好是第 0 个, -// 但蝦皮调整报表顺序后,按下标取会静默导入一张完全不相干的表。 -const ShopeeSheetName = "最佳表現商品" - -// shopeeRequiredColumns 是导入必须用到的 8 个业务列。 -// -// `[必须]` 按列名找索引,不写死列号:报表现在是 40 列, -// 蝦皮加一列统计指标所有列号就全部错位,而且不报错—— -// 会把「點擊率」当成「商品規格」存进去。 -var shopeeRequiredColumns = []string{ - "商品ID", "商品名稱", "商品當前狀態", "商品規格ID", - "商品規格", "規格當前狀態", "商品選項貨號", "主商品貨號", -} - -// ImportFailure 是一行解析失败的记录,行号是 Excel 里的真实行号 -// (从 1 开始,含表头),方便操作员直接去 Excel 里核对。 -// -// `[必须]` 不许静默跳过失败行——返回结构里必须带行号、原文和原因, -// 界面上要能全部看到,不能只显示"失败 N 行"。 -type ImportFailure struct { - Row int - Raw string - Reason string -} - -// ImportResult 是一次导入的统计结果,要显示给操作员看。 -type ImportResult struct { - ProductCount int - SKUCount int - Failures []ImportFailure -} - -// shopeeProductRow / shopeeSKURow 是解析阶段的中间结果。 -// -// 为什么先解析进内存、再统一写库,而不是边读边写: -// SKU 行要靠 goods_id 外键指向 shopee_products,写入顺序必须是 -// "全部商品先写完,再写 SKU"。工单没有保证 Excel 里商品汇总行 -// 一定排在它的 SKU 行前面(样本里恰好是,但不能依赖这个巧合)。 -// 两阶段比"假设文件里的行序"更可靠,11287 行的内存开销可以忽略。 -// -// 这不违反"流式读,不要 GetRows()"的要求——那条针对的是 excelize -// 内部一次性把 40 列全部读出来的开销,这里用 Rows() 逐行读、 -// 只把用得到的 8 个字段摘出来存进自己的小结构体。 -type shopeeProductRow struct { - GoodsID string - Title string - ShopeeStatus string - MainSKUCode string -} - -type shopeeSKURow struct { - SKUID string - GoodsID string - SpecRaw string - Color string - Size string - Advice string - ParseOK bool - SKUCode string -} - -// ImportShopeeExcel 解析蝦皮报表并 upsert 进库。 -// -// 五步(docs/admin/03-data-model.md §3.3): -// 1. 按 sheet 名字取数据,分行(商品規格ID == "-" 是商品汇总行,其余是 SKU 行); -// 2. 按列名找索引; -// 3. 解析规格原文,解析失败就留空、不猜; -// 4. upsert,绝不清空、绝不碰人工字段; -// 5. 返回统计(含失败行号)。 -func ImportShopeeExcel(db *sql.DB, path string) (*ImportResult, error) { - f, err := excelize.OpenFile(path) - if err != nil { - return nil, fmt.Errorf("打开 Excel 文件失败: %w", err) - } - defer f.Close() - - if !hasSheet(f, ShopeeSheetName) { - return nil, fmt.Errorf( - "工作簿里没有「%s」这个 sheet,实际有的 sheet 是: %s", - ShopeeSheetName, strings.Join(f.GetSheetList(), "、")) - } - - rows, err := f.Rows(ShopeeSheetName) - if err != nil { - return nil, fmt.Errorf("读取 sheet「%s」失败: %w", ShopeeSheetName, err) - } - defer rows.Close() - - if !rows.Next() { - return nil, fmt.Errorf("sheet「%s」是空的,连表头都没有", ShopeeSheetName) - } - header, err := rows.Columns() - if err != nil { - return nil, fmt.Errorf("读取表头失败: %w", err) - } - colIdx, err := shopeeColumnIndex(header) - if err != nil { - return nil, err - } - - var ( - products []shopeeProductRow - skus []shopeeSKURow - failures []ImportFailure - ) - seenGoods := map[string]bool{} - - rowNum := 1 // 表头是第 1 行 - for rows.Next() { - rowNum++ - cells, err := rows.Columns() - if err != nil { - failures = append(failures, ImportFailure{ - Row: rowNum, Reason: "读取这一行失败: " + err.Error(), - }) - continue - } - - goodsID := strings.TrimSpace(cellAt(cells, colIdx["商品ID"])) - if goodsID == "" { - failures = append(failures, ImportFailure{Row: rowNum, Reason: "商品ID 为空,整行跳过"}) - continue - } - - specID := strings.TrimSpace(cellAt(cells, colIdx["商品規格ID"])) - if specID == "" || specID == "-" { - // 商品汇总行:一个商品在报表里只出现一次,但防御性地去重, - // 避免万一撞见重复行时把同一个商品塞进 products 两次, - // 让 ProductCount 统计出错。 - if !seenGoods[goodsID] { - seenGoods[goodsID] = true - products = append(products, shopeeProductRow{ - GoodsID: goodsID, - Title: strings.TrimSpace(cellAt(cells, colIdx["商品名稱"])), - ShopeeStatus: strings.TrimSpace(cellAt(cells, colIdx["商品當前狀態"])), - MainSKUCode: strings.TrimSpace(cellAt(cells, colIdx["主商品貨號"])), - }) - } - continue - } - - // SKU 行。解析失败不代表这一行不导入——spec_raw 仍然要存, - // 只是 color/size/advice 留空、parse_ok=0,操作员在界面上人工补, - // 见 ParseSpec 的注释。 - specRaw := cellAt(cells, colIdx["商品規格"]) - color, size, advice, ok := ParseSpec(specRaw) - if !ok { - failures = append(failures, ImportFailure{ - Row: rowNum, Raw: specRaw, - Reason: "规格原文解析不了,已按原文保存,parse_ok=0,需要人工补颜色/尺码", - }) - } - skus = append(skus, shopeeSKURow{ - SKUID: specID, - GoodsID: goodsID, - SpecRaw: specRaw, - Color: color, - Size: size, - Advice: advice, - ParseOK: ok, - SKUCode: strings.TrimSpace(cellAt(cells, colIdx["商品選項貨號"])), - }) - } - if err := rows.Error(); err != nil { - return nil, fmt.Errorf("读取 sheet「%s」出错: %w", ShopeeSheetName, err) - } - - // `[建议]` 整个导入放一个事务里,失败整体回滚—— - // 导入一半的数据比没导入更难收拾。 - tx, err := db.Begin() - if err != nil { - return nil, fmt.Errorf("开始导入事务失败: %w", err) - } - defer tx.Rollback() // 已提交的事务再 Rollback 是空操作,安全 - - for _, p := range products { - if err := repository.UpsertShopeeProduct(tx, p.GoodsID, p.Title, p.ShopeeStatus, p.MainSKUCode); err != nil { - return nil, err - } - } - for _, s := range skus { - if err := repository.UpsertShopeeSKU( - tx, s.SKUID, s.GoodsID, s.SpecRaw, s.Color, s.Size, s.Advice, s.ParseOK, s.SKUCode, - ); err != nil { - return nil, err - } - } - - if err := tx.Commit(); err != nil { - return nil, fmt.Errorf("提交导入事务失败: %w", err) - } - - return &ImportResult{ - ProductCount: len(products), - SKUCount: len(skus), - Failures: failures, - }, nil -} - -func hasSheet(f *excelize.File, name string) bool { - for _, s := range f.GetSheetList() { - if s == name { - return true - } - } - return false -} - -// shopeeColumnIndex 按表头文字算出每个必需列的下标。 -// 缺列时把缺的列名全部列出来,不要只报第一个——操作员一次能看全, -// 不用改一列、重传、再发现缺下一列。 -func shopeeColumnIndex(header []string) (map[string]int, error) { - idx := map[string]int{} - for i, name := range header { - idx[strings.TrimSpace(name)] = i - } - var missing []string - for _, name := range shopeeRequiredColumns { - if _, ok := idx[name]; !ok { - missing = append(missing, name) - } - } - if len(missing) > 0 { - return nil, fmt.Errorf("表头缺少必需列: %s(实际表头: %s)", - strings.Join(missing, "、"), strings.Join(header, "、")) - } - return idx, nil -} - -// cellAt 安全取某一列的值。 -// excelize 的 Columns() 只返回到这一行最后一个非空单元格为止, -// 行尾有空单元格被省略是正常情况,不是数据问题,越界当空值处理。 -func cellAt(cells []string, i int) string { - if i < 0 || i >= len(cells) { - return "" - } - return cells[i] -} - -// ---------- 规格解析 ---------- - -// ParseSpec 把蝦皮的规格原文拆成颜色、尺码、建议。 -// -// 输入样例(实测两种格式各占一半): -// -// "黑色,M【建議40-50公斤】" -> 黑色 / M / 40-50公斤 / true -// "卡其色拼黑色,L 建議50-57.5kg" -> 卡其色拼黑色 / L / 50-57.5kg / true -// "黑色,M" -> 黑色 / M / "" / true -// "莫名其妙的格式" -> "" / "" / "" / false -// -// 最后一个返回值是 ok;false 时前三个必须为空,**不要猜**—— -// 猜出来的颜色尺码会一路传到采购任务,最后买错东西。 -// -// # 21 行括号不配对的脏数据怎么处理 -// -// 实测样本里有 21 行(约 0.3%)出现【】不配对,例如: -// -// "紅色,3XL建議80-90公斤】" 缺左括号 -// "粉色,3XL【寬鬆版 82.5-92.5kg" 缺右括号,而且用的是"寬鬆版"不是"建議" -// -// 这里选择的行为是:**只要整条原文里【和】数量不相等,就整体判定 -// ok=false**,不去猜哪一段该算尺码、哪一段该算建议。 -// -// 理由:这 21 行的错法并不统一——有的缺左括号、有的缺右括号、 -// 有的干脆用别的词代替"建議"。想"能提取多少算多少"就得针对每种 -// 错法单独写规则,规则越写越多,而且没有办法验证这些规则对不对 -// (没有人工核对过的正确答案)。相比之下,"括号不配对就是脏数据, -// 交给人工看"是一条简单、每次都一样、不会越猜越错的规则, -// 符合 admin/AGENTS.md「蝦皮规格原文永远保留,解析不出来就留空, -// 不要瞎猜」。原文仍然会存进 spec_raw,界面上标出来即可。 -func ParseSpec(raw string) (color, size, advice string, ok bool) { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return "", "", "", false - } - - if strings.Count(trimmed, "【") != strings.Count(trimmed, "】") { - return "", "", "", false - } - - // 实测「颜色,尺码」之间的逗号数恒为 1(6092 条 SKU 无例外)。 - // 不是恰好一个逗号,说明格式超出了已知范围,不要猜哪个逗号才是分隔符。 - if strings.Count(trimmed, ",") != 1 { - return "", "", "", false - } - - parts := strings.SplitN(trimmed, ",", 2) - color = strings.TrimSpace(parts[0]) - right := strings.TrimSpace(parts[1]) - if color == "" || right == "" { - return "", "", "", false - } - - openIdx := strings.Index(right, "【") - closeIdx := strings.LastIndex(right, "】") - - switch { - case openIdx >= 0 && closeIdx > openIdx: - // 「M【建議40-50公斤】」「L 【57.5/70公斤】」这类格式: - // 括号里是建议,括号外是尺码。 - size = strings.TrimSpace(right[:openIdx]) - advice = strings.TrimSpace(right[openIdx+len("【") : closeIdx]) - advice = strings.TrimSpace(strings.TrimPrefix(advice, "建議")) - case strings.Contains(right, "建議"): - // 「L 建議50-57.5kg」这类没有括号、靠"建議"二字分隔的格式。 - i := strings.Index(right, "建議") - size = strings.TrimSpace(right[:i]) - advice = strings.TrimSpace(right[i+len("建議"):]) - default: - // 「M」这种只有尺码、没有建议的格式。 - size = right - } - - if size == "" { - return "", "", "", false - } - return color, size, advice, true -} diff --git a/admin/service/shopee_import_test.go b/admin/service/shopee_import_test.go deleted file mode 100644 index 662a826..0000000 --- a/admin/service/shopee_import_test.go +++ /dev/null @@ -1,381 +0,0 @@ -package service - -import ( - "database/sql" - "path/filepath" - "strings" - "testing" - - "github.com/xuri/excelize/v2" - - "cmautobuy/admin/model" -) - -// ── ParseSpec ────────────────────────────────────────── - -func TestParseSpec(t *testing.T) { - cases := []struct { - name string - raw string - wantColor string - wantSize string - wantAdvice string - wantOK bool - }{ - { - name: "带【】的建议", raw: "黑色,M【建議40-50公斤】", - wantColor: "黑色", wantSize: "M", wantAdvice: "40-50公斤", wantOK: true, - }, - { - name: "空格分隔的建议", raw: "卡其色拼黑色,L 建議50-57.5kg", - wantColor: "卡其色拼黑色", wantSize: "L", wantAdvice: "50-57.5kg", wantOK: true, - }, - { - name: "只有颜色和尺码没有建议", raw: "黑色,M", - wantColor: "黑色", wantSize: "M", wantAdvice: "", wantOK: true, - }, - { - name: "颜色自己也带【】", raw: "黑色 【夏裝單件T恤】,L 【57.5/70公斤】", - wantColor: "黑色 【夏裝單件T恤】", wantSize: "L", wantAdvice: "57.5/70公斤", wantOK: true, - }, - { - // 真实样本里的脏数据:缺左括号。见 ParseSpec 注释里"21 行括号 - // 不配对怎么处理"一节——整体判定失败,不猜。 - name: "括号不配对_缺左括号", raw: "紅色,3XL建議80-90公斤】", - wantColor: "", wantSize: "", wantAdvice: "", wantOK: false, - }, - { - // 真实样本里的另一种脏数据:缺右括号,而且用"寬鬆版"代替"建議"。 - name: "括号不配对_缺右括号", raw: "粉色,3XL【寬鬆版 82.5-92.5kg", - wantColor: "", wantSize: "", wantAdvice: "", wantOK: false, - }, - { - name: "完全认不出的格式_没有逗号", raw: "莫名其妙的格式", - wantColor: "", wantSize: "", wantAdvice: "", wantOK: false, - }, - { - name: "两个逗号_超出已知范围", raw: "黑色,M,多余", - wantColor: "", wantSize: "", wantAdvice: "", wantOK: false, - }, - { - name: "空字符串", raw: "", - wantColor: "", wantSize: "", wantAdvice: "", wantOK: false, - }, - { - // raw 本身非空(trim 前有内容),但 trim 后为空——用来跟"空字符串" - // 那条区分开:如果 trimmed=="" 分支直接把 raw 当返回值, - // 这条能抓到(raw!=""),"空字符串"那条抓不到(raw 本身就是 "")。 - name: "只有空白字符", raw: " ", - wantColor: "", wantSize: "", wantAdvice: "", wantOK: false, - }, - { - // 逗号右侧为空——只有颜色,没有尺码信息。 - name: "逗号右侧为空", raw: "黑色,", - wantColor: "", wantSize: "", wantAdvice: "", wantOK: false, - }, - { - // 逗号左侧为空——只有尺码,没有颜色信息。 - name: "逗号左侧为空", raw: ",M", - wantColor: "", wantSize: "", wantAdvice: "", wantOK: false, - }, - { - // 真实样本第 5454 行:右半整个被【】包住,括号外没有尺码文字, - // 提取后 size 为空。这是真实数据里 23 行失败中的 2 行之一, - // 直接用真实原文而不是自造用例,证明的是"这个真实格式我们 - // 确实认不出来,而且认不出来时不猜",不只是代码逻辑自洽。 - name: "右半整个被【】包住_size提取后为空", raw: "面膜安全褲3條膚色,【2xl 70-85公斤可穿】", - wantColor: "", wantSize: "", wantAdvice: "", wantOK: false, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - color, size, advice, ok := ParseSpec(tc.raw) - if ok != tc.wantOK { - t.Fatalf("ok = %v,想要 %v(raw=%q)", ok, tc.wantOK, tc.raw) - } - if !ok { - // [必须] ok=false 时前三个必须全为空,不许出现"猜出来的一半"。 - if color != "" || size != "" || advice != "" { - t.Fatalf("ok=false 时 color/size/advice 必须全为空,得到 %q/%q/%q", - color, size, advice) - } - return - } - if color != tc.wantColor || size != tc.wantSize || advice != tc.wantAdvice { - t.Fatalf("got %q/%q/%q,want %q/%q/%q", - color, size, advice, tc.wantColor, tc.wantSize, tc.wantAdvice) - } - }) - } -} - -// ── 测试固件 ────────────────────────────────────────── - -// shopeeHeader 是真实报表的表头,只留导入用得到的 8 个业务列—— -// 和真实报表一样按名字定位,列的相对顺序特意打乱一下(把 8 列之外的 -// 顺序无所谓,这里保持简单,只放业务列)。 -var shopeeHeader = []string{ - "商品ID", "商品名稱", "商品當前狀態", "商品規格ID", - "商品規格", "規格當前狀態", "商品選項貨號", "主商品貨號", -} - -// buildShopeeWorkbook 现造一个 xlsx 固件,覆盖工单要求的场景: -// 正常行、三种规格格式、括号不配对、以及无关的广告报表 sheet -// (用来验证"按名字取 sheet,不用下标 0")。 -// -// [必须] 测试不依赖 raw_data/,固件必须是现造或者提交进 admin/testdata/ -// 的小文件,见工单 #38。这里选择现造:文件是二进制,提交进仓库不好 review。 -func buildShopeeWorkbook(t *testing.T, header []string, rows [][]string) string { - t.Helper() - f := excelize.NewFile() - defer f.Close() - - // 默认 sheet 改名成一个无关的广告报表,模拟真实工作簿里 - // "最佳表現商品"不是第 0 个 sheet 这种情况(虽然样本里恰好是, - // 但不能依赖这个巧合,见 ImportShopeeExcel 的实现和它的注释)。 - f.SetSheetName("Sheet1", "追蹤商品廣告成效") - f.NewSheet("最佳表現商品") - - if header != nil { - for col, name := range header { - cellRef, _ := excelize.CoordinatesToCellName(col+1, 1) - f.SetCellStr("最佳表現商品", cellRef, name) - } - } - for r, row := range rows { - for col, val := range row { - cellRef, _ := excelize.CoordinatesToCellName(col+1, r+2) - f.SetCellStr("最佳表現商品", cellRef, val) - } - } - - path := filepath.Join(t.TempDir(), "shopee_test.xlsx") - if err := f.SaveAs(path); err != nil { - t.Fatalf("保存测试固件失败: %v", err) - } - return path -} - -// row 按 shopeeHeader 的列顺序拼一行,减少每个测试用例里的列对齐负担。 -func row(goodsID, title, status, specID, spec, specStatus, skuCode, mainSKUCode string) []string { - return []string{goodsID, title, status, specID, spec, specStatus, skuCode, mainSKUCode} -} - -// ── ImportShopeeExcel ────────────────────────────────── - -func TestImportShopeeExcel_基本导入(t *testing.T) { - db := newTestDB(t) - - rows := [][]string{ - // 商品 A:汇总行 + 3 种规格格式的 SKU 行 - row("1001", "测试商品A", "正常", "-", "-", "", "", "货号A"), - row("1001", "测试商品A", "正常", "9001", "黑色,M【建議40-50公斤】", "正常", "sku-9001", "货号A"), - row("1001", "测试商品A", "正常", "9002", "卡其色拼黑色,L 建議50-57.5kg", "正常", "sku-9002", "货号A"), - row("1001", "测试商品A", "正常", "9003", "黑色,M", "正常", "sku-9003", "货号A"), - // 商品 B:汇总行 + 1 个解析失败的 SKU(括号不配对) - row("1002", "测试商品B", "正常", "-", "-", "", "", "货号B"), - row("1002", "测试商品B", "正常", "9004", "紅色,3XL建議80-90公斤】", "正常", "sku-9004", "货号B"), - } - path := buildShopeeWorkbook(t, shopeeHeader, rows) - - result, err := ImportShopeeExcel(db, path) - if err != nil { - t.Fatalf("导入失败: %v", err) - } - if result.ProductCount != 2 { - t.Errorf("ProductCount = %d,想要 2", result.ProductCount) - } - if result.SKUCount != 4 { - t.Errorf("SKUCount = %d,想要 4", result.SKUCount) - } - if len(result.Failures) != 1 { - t.Fatalf("Failures 数量 = %d,想要 1(括号不配对那一行)", len(result.Failures)) - } - if result.Failures[0].Raw != "紅色,3XL建議80-90公斤】" { - t.Errorf("失败行原文 = %q,不对", result.Failures[0].Raw) - } - if result.Failures[0].Row != 7 { - // 表头第 1 行 + 6 条数据行,"紅色..."是第 6 条数据 -> Excel 行号 7 - t.Errorf("失败行行号 = %d,想要 7", result.Failures[0].Row) - } - - // 解析失败的 SKU 仍然要写进库:spec_raw 照存,color/size/advice 为空,parse_ok=0。 - var color, size, advice sql.NullString - var parseOK int - err = db.QueryRow( - `SELECT color, size, advice, parse_ok FROM shopee_skus WHERE sku_id = ?`, "9004", - ).Scan(&color, &size, &advice, &parseOK) - if err != nil { - t.Fatalf("查询失败 SKU 出错: %v", err) - } - if color.Valid && color.String != "" { - t.Errorf("解析失败的行 color 应为空,得到 %q", color.String) - } - if parseOK != 0 { - t.Errorf("解析失败的行 parse_ok 应为 0,得到 %d", parseOK) - } -} - -func TestImportShopeeExcel_错误sheet名(t *testing.T) { - db := newTestDB(t) - - f := excelize.NewFile() - f.SetSheetName("Sheet1", "新上架商品") - f.NewSheet("高潛力廣告商品") - path := filepath.Join(t.TempDir(), "no_target_sheet.xlsx") - if err := f.SaveAs(path); err != nil { - t.Fatalf("保存测试固件失败: %v", err) - } - f.Close() - - _, err := ImportShopeeExcel(db, path) - if err == nil { - t.Fatal("缺少「最佳表現商品」sheet 应该报错") - } - msg := err.Error() - if !strings.Contains(msg, "最佳表現商品") || !strings.Contains(msg, "新上架商品") || !strings.Contains(msg, "高潛力廣告商品") { - t.Errorf("错误信息应列出实际有哪些 sheet,得到: %s", msg) - } -} - -func TestImportShopeeExcel_缺列(t *testing.T) { - db := newTestDB(t) - - // 表头缺了「商品規格ID」,模拟蝦皮加/删列导致列错位的场景。 - badHeader := []string{"商品ID", "商品名稱", "商品當前狀態", "商品規格", "規格當前狀態", "商品選項貨號", "主商品貨號"} - path := buildShopeeWorkbook(t, badHeader, nil) - - _, err := ImportShopeeExcel(db, path) - if err == nil { - t.Fatal("缺少必需列应该报错") - } - if !strings.Contains(err.Error(), "商品規格ID") { - t.Errorf("错误信息应说明缺的是哪一列,得到: %s", err.Error()) - } -} - -// TestImportShopeeExcel_不覆盖人工字段 是工单 #38 里最要命的一条: -// 报表里没有 pdd_goods_url / pdd_goods_id 这两列,upsert 绝不能把它们 -// 写成空值,否则操作员攒的 PDD 链接会被一次导入洗光,而且不报错。 -func TestImportShopeeExcel_不覆盖人工字段(t *testing.T) { - db := newTestDB(t) - - rows := [][]string{ - row("2001", "测试商品C", "正常", "-", "-", "", "", "货号C"), - row("2001", "测试商品C", "正常", "8001", "黑色,M", "正常", "sku-8001", "货号C"), - } - path := buildShopeeWorkbook(t, shopeeHeader, rows) - - // 先导入一次,让商品存在。 - if _, err := ImportShopeeExcel(db, path); err != nil { - t.Fatalf("第一次导入失败: %v", err) - } - - // 模拟操作员手工填了 PDD 链接。 - const manualURL = "https://mobile.yangkeduo.com/goods.html?goods_id=999999" - if _, err := db.Exec( - `UPDATE shopee_products SET pdd_goods_url = ?, pdd_goods_id = ? WHERE goods_id = ?`, - manualURL, "999999", "2001", - ); err != nil { - t.Fatalf("模拟人工填链接失败: %v", err) - } - - // 再导入同一个文件(同一个商品名字也可能变了,模拟蝦皮改了标题)。 - rows[0][1] = "测试商品C改名后" - rows[1][1] = "测试商品C改名后" - path2 := buildShopeeWorkbook(t, shopeeHeader, rows) - if _, err := ImportShopeeExcel(db, path2); err != nil { - t.Fatalf("第二次导入失败: %v", err) - } - - var gotURL, gotGoodsID, gotTitle sql.NullString - err := db.QueryRow( - `SELECT pdd_goods_url, pdd_goods_id, title FROM shopee_products WHERE goods_id = ?`, "2001", - ).Scan(&gotURL, &gotGoodsID, &gotTitle) - if err != nil { - t.Fatalf("查询商品失败: %v", err) - } - if gotURL.String != manualURL { - t.Errorf("pdd_goods_url 被导入洗掉了:got %q, want %q", gotURL.String, manualURL) - } - if gotGoodsID.String != "999999" { - t.Errorf("pdd_goods_id 被导入洗掉了:got %q, want %q", gotGoodsID.String, "999999") - } - // 标题这类报表字段应该正常刷新,证明"不覆盖"只针对人工字段,不是整行不更新。 - if gotTitle.String != "测试商品C改名后" { - t.Errorf("title 应该被报表里的新值刷新:got %q", gotTitle.String) - } -} - -// TestImportShopeeExcel_不删除人工新增SKU 对应「05」§4.4 提到的人工新增 SKU -// (本工单不实现新增入口,但导入必须留出这个口子,不能做成 -// "先 DELETE 再 INSERT" 的全量替换)。 -func TestImportShopeeExcel_不删除人工新增SKU(t *testing.T) { - db := newTestDB(t) - - rows := [][]string{ - row("3001", "测试商品D", "正常", "-", "-", "", "", "货号D"), - row("3001", "测试商品D", "正常", "7001", "黑色,M", "正常", "sku-7001", "货号D"), - } - path := buildShopeeWorkbook(t, shopeeHeader, rows) - if _, err := ImportShopeeExcel(db, path); err != nil { - t.Fatalf("导入失败: %v", err) - } - - // 模拟操作员手动新增一个报表里没有的 SKU。 - now := model.NowISO() - if _, err := db.Exec(` - INSERT INTO shopee_skus (sku_id, goods_id, spec_raw, color, size, advice, parse_ok, sku_code, is_manual, created_at, updated_at) - VALUES ('manual-1', '3001', '手动新增-均码', '均码', '', '', 1, '', 1, ?, ?)`, - now, now); err != nil { - t.Fatalf("插入人工新增 SKU 失败: %v", err) - } - - // 再导入同一个文件。 - if _, err := ImportShopeeExcel(db, path); err != nil { - t.Fatalf("第二次导入失败: %v", err) - } - - var isManual int - err := db.QueryRow(`SELECT is_manual FROM shopee_skus WHERE sku_id = 'manual-1'`).Scan(&isManual) - if err != nil { - t.Fatalf("人工新增的 SKU 被导入删掉了: %v", err) - } - if isManual != 1 { - t.Errorf("is_manual 应该还是 1,得到 %d", isManual) - } -} - -func TestImportShopeeExcel_二次导入不翻倍(t *testing.T) { - db := newTestDB(t) - - rows := [][]string{ - row("4001", "测试商品E", "正常", "-", "-", "", "", "货号E"), - row("4001", "测试商品E", "正常", "6001", "黑色,M", "正常", "sku-6001", "货号E"), - row("4001", "测试商品E", "正常", "6002", "白色,L", "正常", "sku-6002", "货号E"), - } - path := buildShopeeWorkbook(t, shopeeHeader, rows) - - first, err := ImportShopeeExcel(db, path) - if err != nil { - t.Fatalf("第一次导入失败: %v", err) - } - second, err := ImportShopeeExcel(db, path) - if err != nil { - t.Fatalf("第二次导入失败: %v", err) - } - if first.ProductCount != second.ProductCount || first.SKUCount != second.SKUCount { - t.Fatalf("两次导入统计不一致: %+v vs %+v", first, second) - } - - var productCount, skuCount int - db.QueryRow(`SELECT COUNT(*) FROM shopee_products WHERE goods_id = '4001'`).Scan(&productCount) - db.QueryRow(`SELECT COUNT(*) FROM shopee_skus WHERE goods_id = '4001'`).Scan(&skuCount) - if productCount != 1 { - t.Errorf("shopee_products 应该只有 1 行,得到 %d(重复导入产生了重复行)", productCount) - } - if skuCount != 2 { - t.Errorf("shopee_skus 应该只有 2 行,得到 %d(重复导入产生了重复行)", skuCount) - } -} diff --git a/admin/service/shopee_list.go b/admin/service/shopee_list.go index 0c8ca2d..5153550 100644 --- a/admin/service/shopee_list.go +++ b/admin/service/shopee_list.go @@ -1,6 +1,6 @@ // 蝦皮数据列表页的查询和界面文字组装。 // -// 分成单独文件是因为它和 shopee_import.go 职责不同:这里只读,不写库。 +// 分成单独文件是因为它只负责蝦皮列表查询视图,不承担商品目录批量写入。 package service import ( diff --git a/admin/templates/shopee/list.html b/admin/templates/shopee/list.html index 92e1efe..0863598 100644 --- a/admin/templates/shopee/list.html +++ b/admin/templates/shopee/list.html @@ -4,12 +4,6 @@ {{/* ── 第一段:顶部工具条 ────────────────────────────── */}}
{{if .CurrentUser.IsAdmin}}导入记录{{end}} -
- - - -
-
{{/* 状态筛选是刚需(找待补规格 / 找没填链接的),不是锦上添花, 见 docs/admin/05-ui-specification.md §4.1、工单 #43。 @@ -82,8 +76,8 @@ {{if .IsFiltered}} 没有匹配的数据,换个商品 ID 或商品名称试试。 {{else}} - 还没有数据,点左上角「导入 Excel」开始。
- 样本文件不在仓库里,需向项目负责人索取,放到 raw_data/ 下。 + 还没有数据。蝦皮与 PDD 对应数据由第三方脚本通过商品目录接口导入。
+ {{if $.CurrentUser.IsAdmin}}可点击左上角「导入记录」查看脚本提交结果。{{end}} {{end}} @@ -96,30 +90,6 @@ 双击任意一行可以查看这个商品的完整规格表。

-{{/* ── 导入失败行:全部列出来,不折叠、不只显示条数 ──── */}} -{{if .Failures}} -
-

本次导入有 {{len .Failures}} 行解析失败,规格原文已按原样保存,需人工补颜色/尺码/建议:

- - - - - - - - - - {{range .Failures}} - - - - - - {{end}} - -
行号规格原文原因
{{.Row}}{{.Raw}}{{.Reason}}
-
-{{end}} {{/* ── 详情弹窗的壳子 ─────────────────────────── 里面的内容双击行时由 /shopee/detail 返回,前端只负责放进来和显示, diff --git a/admin/testdata/README.md b/admin/testdata/README.md index a7beeb2..717a663 100644 --- a/admin/testdata/README.md +++ b/admin/testdata/README.md @@ -3,32 +3,5 @@ 自动化测试用的**脱敏小样本**放这里,**要提交进 Git**。 否则别人拉下仓库跑 `go test ./...` 会失败。 -## 蝦皮 Excel 导入测试(工单 #38) - -**没有提交静态的 `shopee_sample.xlsx`。** 固件改成用 `excelize` 在 -`admin/service/shopee_import_test.go` 里现造(见 `buildShopeeWorkbook`), -测试跑到时临时生成、测试结束自动清理。 - -选择现造而不是提交一个二进制文件的原因:xlsx 是二进制格式,PR 里看不出 -改了什么,审查只能"信任"文件内容;现造的话,固件长什么样直接写在 -测试代码里,一眼能看到覆盖了哪些场景。 - -覆盖的场景(对应工单 #38 的要求): - -- 商品汇总行(`商品規格ID` 是 `-`); -- SKU 行,规格原文用**带【】**的格式,如 `黑色,M【建議40-50公斤】`; -- SKU 行,规格原文用**空格分隔**的格式,如 `卡其色拼黑色,L 建議50-57.5kg`; -- SKU 行,只有颜色和尺码、没有建议,如 `黑色,M`; -- 颜色自己也带【】的格式,如 `黑色 【夏裝單件T恤】,L 【57.5/70公斤】`; -- 括号不配对的脏数据(真实样本里的 21~23 行),验证 `parse_ok=0` - 且不瞎猜; -- 表头缺列、sheet 名不对; -- 已有人工填的 `pdd_goods_url` / `pdd_goods_id`,验证导入不覆盖; -- 已有 `is_manual = 1` 的人工新增 SKU,验证导入不删除; -- 同一个文件导入两次,验证不产生重复行。 - -真实样本 `raw_data/蝦皮数据样本.xlsx`(需向项目负责人索取, -含商业数据,已在 `.gitignore` 里排除)仍然用于**人工验证**, -不用于自动化测试——1.9MB,每次跑测试都读一遍太慢。 - -相关规定见 `docs/admin/06-quality-security.md` §2.1。 +`pdd_links.xlsx` 是 PDD 一列式批量导入的脱敏样本。蝦皮数据不再使用 Admin Excel +上传,商品目录接口测试直接构造脱敏 JSON,不需要在这里保存蝦皮工作簿。 diff --git a/docs/admin/00-getting-started.md b/docs/admin/00-getting-started.md index b20d2a0..1e09c38 100644 --- a/docs/admin/00-getting-started.md +++ b/docs/admin/00-getting-started.md @@ -161,7 +161,7 @@ syb: | `listen tcp :8080: bind: ...` | 8080 端口被占了 | 换端口:`go run . -port 8081`,或关掉占用的程序 | | 页面全是没样式的白底黑字 | 静态文件没加载到 | 确认是从 `admin/` 目录启动的,静态文件路径是相对的 | | `database is locked` | 有别的程序占着数据库 | 关掉 DB Browser 再试;程序运行时只读打开是可以的 | -| 导入 Excel 报错但没说哪一行 | 解析器没带行号 | 这是 bug,报错必须带行号,见 [06](06-quality-security.md) §2 | +| PDD Excel 导入报错但没说哪一行 | 解析器没带行号 | 这是 bug,报错必须带行号,见 [06](06-quality-security.md) §2 | ## 6. 数据库在哪里、怎样配置 diff --git a/docs/admin/00-glossary.md b/docs/admin/00-glossary.md index 2e9a037..03ff636 100644 --- a/docs/admin/00-glossary.md +++ b/docs/admin/00-glossary.md @@ -31,7 +31,7 @@ | upsert | "有就更新、没有就新增",一次操作搞定 | Excel 导入的唯一正确做法,见 §3 | | cgo | Go 调用 C 代码的机制。**用了就需要装 C 编译器** | 本项目**避开它**;MySQL 和历史 SQLite 迁移驱动都是纯 Go | | `modernc.org/sqlite` | 纯 Go 实现的 SQLite,不需要 cgo | 只用于读取历史 `admin.db` 和迁移回归,不进入生产运行时 | -| excelize | Go 读写 Excel 的库 | 固定用它读蝦皮报表 | +| excelize | Go 读写 Excel 的库 | 固定用它读 PDD 链接批量导入文件;蝦皮数据不再由 Admin 读 Excel | | CSRF | 攻击者诱导你在已登录状态下发出非本意的请求 | 所有写操作都要防,见 [06](06-quality-security.md) §4 | | 参数化查询 | SQL 里用 `?` 占位、值单独传,而不是拼字符串 | 防 SQL 注入的唯一正确做法 | | 商品目录批次 | 第三方脚本一次提交的一组蝦皮、PDD 和关联数据 | 用 `source + batch_id` 做幂等;系统只保存处理摘要,不保存完整请求体 | diff --git a/docs/admin/01-requirements.md b/docs/admin/01-requirements.md index 72db67b..5622c37 100644 --- a/docs/admin/01-requirements.md +++ b/docs/admin/01-requirements.md @@ -26,7 +26,7 @@ Admin 的职责是把这三方的数据串起来,最终产出 Client 能执行 - **管理员:**首次初始化 Admin,执行全部业务操作,并创建、禁用或重置采购员账号。 - **采购员:**登录后执行现有业务操作,但不能管理用户。 - **Client:**领取任务、在安卓设备上执行、提交结果。契约见 [Client 侧文档](../client/04-admin-api-contract.md)。 -- **蝦皮:**只提供 Excel 报表导出,**没有接口对接**。 +- **蝦皮:**不同来源文件由第三方脚本归一化,再提交到 Admin 商品目录接口。 - **顺运宝:**提供货运单,`[待定]` 同步方式待确认,MVP 先做占位按钮。 - **拼多多:**由 Client 操作,Admin 不直接接触。 @@ -35,7 +35,7 @@ Admin 的职责是把这三方的数据串起来,最终产出 Client 能执行 这条链路是理解全部五个模块的关键,先看懂它: ```text -蝦皮报表 ──导入──→ 商品表 + SKU 表 +第三方商品目录脚本 ──批量接口──→ 商品表 + SKU 表 │ 编辑弹窗:人工填 PDD 链接 │ @@ -453,7 +453,7 @@ MVP 包含: - 五模块页面框架和统一的三段式布局; - MySQL 8.4 建库与追加式迁移;SQLite 仅作为一次性历史数据迁移来源; -- 蝦皮 Excel 导入(upsert)、搜索、批量删除、手动新增、编辑弹窗; +- 商品目录批量接口(蝦皮/PDD/关联 upsert)、导入记录、蝦皮搜索、批量删除、手动新增、编辑弹窗; - PDD 链接录入与发起采集; - 顺运宝数据的**手工录入或造数**(同步按钮占位); - 规格匹配弹窗与可复用映射; @@ -485,7 +485,7 @@ MVP 之后: - 集中部署,操作人员规模是个位数;不追求大规模并发,但任务领取必须保证并发唯一。 - 页面在 1366×768 上可正常使用。 -- 导入 1 万行 Excel 应在可接受时间内完成,并显示进度或结果统计。 +- 商品目录批次和 PDD Excel 导入应在可接受时间内完成,并显示结果统计。 - 所有写操作有 CSRF 防护,所有 SQL 参数化。 - 密码只保存成熟算法生成的哈希;Session 有过期、退出和账号禁用失效机制。 - 首次管理员、采购员初始密码和重置密码统一要求至少 6 个字符,最多 72 个字节; diff --git a/docs/admin/02-architecture.md b/docs/admin/02-architecture.md index 17bdb01..b9a57ab 100644 --- a/docs/admin/02-architecture.md +++ b/docs/admin/02-architecture.md @@ -90,7 +90,7 @@ admin/ │ └── api/ 给 Client 的接口 │ └── client_api.go ├── service/ -│ ├── shopee_import.go Excel 解析与 upsert +│ ├── catalog_import.go 商品目录批次校验、幂等与 upsert │ ├── collect.go 创建采集任务 │ ├── mapping.go SKU 映射复用 │ ├── purchase.go 创建采购任务与校验 @@ -217,28 +217,27 @@ func DataDir() (string, error) { ## 7. 请求流程示例 -以"导入蝦皮 Excel"为例,看清各层职责: +以“第三方脚本导入商品目录”为例,看清各层职责: ```text -浏览器 POST /shopee/import (multipart 文件) +第三方脚本 POST /api/v1/integrations/catalog/batches(JSON) ↓ -handler/web/shopee.go - - 校验文件大小和扩展名 - - 存到 data/uploads/ - - 调 service.ImportShopeeExcel(path) +handler/integration/catalog_api.go + - 独立 Bearer 鉴权 + - 限制请求体并解析 JSON + - 调 service.ImportCatalogBatch(...) ↓ -service/shopee_import.go - - 用 excelize 逐行读 - - 按 商品規格ID 是否为 "-" 分成商品行/SKU 行 - - 解析规格原文 → 颜色/尺码/建议(失败留空) +service/catalog_import.go + - 校验批次、商品、SKU、金额和关联 + - 开单事务并处理幂等/冲突 - 调 repository 做 upsert - - 返回 {商品数, SKU数, 失败行号列表} + - 返回各类新增、更新和关联计数 ↓ -repository/shopee.go - - INSERT ... ON CONFLICT(...) DO UPDATE - - 只更新报表来的字段,人工填的 pdd_goods_url 不动 +repository/catalog_import.go + - 按来源观测时间更新 + - 人工 pdd_goods_url / pdd_goods_id 和 is_manual 不动 ↓ -handler 渲染结果页:导入 5195 商品 / 6092 SKU,失败 0 行 +handler 返回批次 JSON;管理员在统一导入记录页查看摘要 ``` `[必须]` 注意最后一步:**upsert 时不得覆盖人工维护的字段**。 diff --git a/docs/admin/03-data-model.md b/docs/admin/03-data-model.md index 3165250..9fcefa6 100644 --- a/docs/admin/03-data-model.md +++ b/docs/admin/03-data-model.md @@ -209,79 +209,17 @@ CREATE INDEX idx_shopee_skus_goods ON shopee_skus(goods_id); CREATE INDEX idx_shopee_skus_parse ON shopee_skus(parse_ok); ``` -### 3.3 Excel 导入规则 +### 3.3 商品目录接口导入规则 -`[必须]` 这一节的每一条都要照做,写错会丢数据。 +蝦皮页面不再上传或解析固定格式 Excel。第三方脚本负责把不同来源文件归一化, +再按 [商品目录接口契约](10-商品目录接入接口.md) 提交结构化商品、SKU、PDD 和关联。 -**第一步:分行** - -| 行类型 | 判断方法 | 样本条数 | 导入到 | -|---|---|---|---| -| 商品汇总行 | `商品規格ID` 是 `-` 或空 | 5195 | `shopee_products` | -| SKU 行 | `商品規格ID` 是数字 | 6092 | `shopee_skus` | - -拿参考样本(11287 行)导入应得到 **5195 商品 + 6092 SKU**,数字对不上就是解析有问题。 - -> 样本文件含商业数据,**不在仓库里**,找项目负责人要,放 `raw_data/` 下。 -> 自动化测试用 `admin/testdata/` 里的小样本,别读大文件。 - -**第二步:按列名找索引,不要写死列号** - -报表有 40 列,蝦皮改一次导出格式列号就变。启动时按表头文字定位: - -```go -idx := map[string]int{} -for i, name := range header { - idx[strings.TrimSpace(name)] = i -} -goodsID := row[idx["商品ID"]] -``` - -找不到必需列时**直接报错停止**,不要用默认值蒙混过去。 - -**第三步:解析规格原文** - -`商品規格` 这一列格式**不统一**,实测两种各占一半: - -| 格式 | 占比 | 样例 | -|---|---|---| -| 有【】 | 53.4% | `黑色,M【建議40-50公斤】` | -| 无括号、空格分隔 | 46.6% | `卡其色拼黑色,L 建議50-57.5kg` | - -好消息:**逗号数恒为 1**(6092 条无例外),所以"颜色,尺码"这个二分结构是稳的。 - -规则: - -1. 按第一个逗号切开 → 左边是颜色,右边是"尺码 + 可能的建议"; -2. 右边尝试提取建议:先找 `【建議...】`,再找 ` 建議...`; -3. 剩下的就是尺码; -4. `[必须]` 任何一步失败都**不要猜**,把 `parse_ok` 置 0,颜色尺码留空, - `spec_raw` 照常保存。界面上把这些行标出来让人工补。 - -**第四步:upsert,绝不清空** - -```sql -INSERT INTO shopee_products (goods_id, title, shopee_status, main_sku_code, - created_at, updated_at) -VALUES (?, ?, ?, ?, ?, ?) -ON CONFLICT(goods_id) DO UPDATE SET - title = excluded.title, - shopee_status = excluded.shopee_status, - main_sku_code = excluded.main_sku_code, - updated_at = excluded.updated_at; - -- 注意:pdd_goods_url / pdd_data / collect_status 一个都不在这里 -``` - -| 别这么做 | 后果 | -|---|---| -| `DELETE FROM shopee_products` 再导入 | **人工填的 PDD 链接、采集结果全没了** | -| `DO UPDATE SET` 里写 `pdd_goods_url = excluded.pdd_goods_url` | 报表里没这列,会被更新成空 | -| 删掉报表里没出现的 SKU | 人工新增的(`is_manual=1`)会被误删 | - -**第五步:返回统计** - -导入结束返回 `{商品数, SKU数, 解析失败行号列表}`,页面上显示出来。 -**不要静默跳过失败行。** +- `[必须]` 整批只做 upsert,不清空表,也不删除批次中缺席的数据。 +- `[必须]` `spec_raw` 原样保留;解析失败由上游明确提交 `parse_ok=false`,Admin 不猜。 +- `[必须]` 商品更新不覆盖 `pdd_goods_url` / `pdd_goods_id`,SKU 更新不覆盖 `is_manual`。 +- `[必须]` 较旧 `observed_at` 不覆盖较新接口数据。 +- 空 PDD 关联可以建立;相同关联幂等;不同关联返回冲突,不静默换品。 +- 一个批次在单事务中写入并返回新增、更新、未变化和失败统计。 ## 4. `pdd_products` 拼多多商品 diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index 13d4a4b..de3b329 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -124,12 +124,11 @@ ### 4.1 工具条 ```text -[导入 Excel] 状态[全部▾] 商品ID [________] [搜索] [删除] +[导入记录] 状态[全部▾] 商品ID [________] [搜索] [删除] ``` -- **导入 Excel**:选文件 → POST 上传 → 显示结果统计 - (导入 5195 商品 / 6092 SKU,失败 N 行)。 - `[必须]` 失败行要列出行号和原因,不能静默跳过。 +- **导入记录**:管理员进入统一商品目录导入记录页;采购员无系统级审计入口。 + 蝦皮数据由第三方脚本通过商品目录接口提交,本页不再上传或解析 Excel。 - **状态筛选**(`[必须]`,工单 #43):全部 / 待补规格 / 未填 PDD 链接 / 已填链接。 这个筛选是**刚需**,不是锦上添花——本页主要用途是维护 (找出需要补规格的商品、找出还没关联 PDD 链接的商品),只靠商品 ID diff --git a/docs/admin/06-quality-security.md b/docs/admin/06-quality-security.md index 5f7660e..33ed540 100644 --- a/docs/admin/06-quality-security.md +++ b/docs/admin/06-quality-security.md @@ -20,10 +20,8 @@ `[必须]` 覆盖: -- **规格原文解析**:两种格式各占一半,两种都要有用例, - 再加解析失败的用例(确认 `parse_ok=0` 且不瞎猜); -- **Excel 导入分行**:商品汇总行 vs SKU 行的判断; -- **upsert 不覆盖人工字段**:先填 PDD 链接,再导入一次,断言链接还在; +- **商品目录批次**:鉴权、原子写入、幂等重放、关联冲突和旧观测时间保护; +- **upsert 不覆盖人工字段**:先填 PDD 链接,再通过目录接口导入,断言链接还在; - **PDD 链接批量导入**:空工作表、正确/错误表头、两个允许域名、短链/非 PDD 链接、空行、5000 条边界、文件内重复、新建/复用/复活分类和重复导入不覆盖采集结果; - 任务创建校验:商品、采集结果、有效映射、数量、人民币价格上限、客户端可见范围逐条覆盖; @@ -163,7 +161,7 @@ `[建议]` 记录这些事件: -- `shopee_import_started/completed/failed`(带条数统计) +- `catalog_import_started/completed/failed`(只带来源、批次号和计数摘要) - `collect_task_created` - `purchase_task_created` - `task_claimed`(带 client_id、task_id) diff --git a/docs/admin/08-顺运宝接口.md b/docs/admin/08-顺运宝接口.md index 323984c..5edd0bd 100644 --- a/docs/admin/08-顺运宝接口.md +++ b/docs/admin/08-顺运宝接口.md @@ -369,23 +369,20 @@ POST /am/stock/detail/listByStock?hist=0 ### 6.3 `productSpec` 的格式和蝦皮报表完全一致 ```text -蝦皮 Excel「商品規格」 黑色,M【建議40-50公斤】 +蝦皮目录 `spec_raw` 黑色,M【建議40-50公斤】 顺运宝 productSpec 白色,L【建議50-60公斤】 黑色+白色【純棉兩件裝】 簡約親膚,L【建議52.5-60公斤】 ``` -`[必须]` **直接复用 `service.ParseSpec()`**(#38 实现),不要另写一份解析。 -两处各写一份,规则迟早不一致,而解析出的颜色尺码要拿去下单。 - -`[必须]` 第二个例子里颜色部分自带 `【】`,`ParseSpec` 已覆盖这种情况 -(按第一个逗号切开)。 +`[必须]` 规格身份统一复用 `spec.SpecKey()`:只折叠空白,不猜颜色、尺码或建议。 +第三方目录脚本提交 `spec_raw` 和明确的解析结果;顺运宝匹配不到时交给人工确认。 ### 6.4 由此推导出的匹配路径 ```text productId ──→ shopee_products.goods_id 商品级,直接相等 -productSpec ──→ ParseSpec() ──→ 颜色 + 尺码 - ──→ 在该商品的 shopee_skus 里比对 ──→ 拿到 sku_id +productSpec ──→ SpecKey() ──→ 稳定规格身份键 + ──→ 在该商品目录和人工映射中查找 ``` `[必须]` 比对不上时**不要猜**,标成待人工匹配。蝦皮报表只含有销售成绩的 @@ -543,4 +540,4 @@ syb: - 数据落库:[03 数据模型](03-data-model.md) - 界面:[05 界面规范](05-ui-specification.md) §6 顺运宝数据页 - 安全要求:[06 质量与安全](06-quality-security.md) -- 规格解析:`admin/service/shopee_import.go` 的 `ParseSpec()` +- 规格身份:`admin/spec/speckey.go` 的 `SpecKey()`