From 393c942762089fc2d30f302f170053739891d8f2 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 2 Sep 2026 21:02:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A7=A3=E6=9E=90=E5=95=86=E5=93=81?= =?UTF-8?q?=E8=B4=A8=E9=87=8F=E8=AF=8A=E6=96=AD=E5=B9=B6=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=8C=89=E3=80=8C=E7=BC=BA=E5=B0=91=E8=A7=86=E9=A2=91=E3=80=8D?= =?UTF-8?q?=E7=AD=9B=E9=80=89=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 由 Codex (gpt-5.6-sol) 实施,Claude 审核。 货憨憨 getPage 响应里带有商品质量诊断,其中「缺少视频」直接标出了 哪些商品才需要去淘宝找视频,能把工作量从全店商品缩小到真正需要处理的那部分。 此前这个字段被完全丢弃。 字段名是 diagnosisInfo(不是 diagnoses),结构比表面看到的多一层: {itemId, qualityLevel, diagnoses:[{field, diagnosisResults:[{type, solution}]}]} 判定规则(负责人 2026-09-02 决定,见工单评论): - missing:诊断明确报了「缺少视频」 - ok:其余全部情况,含 diagnosisInfo 为 null 只有明确报缺少视频才算缺少,其它一律当作有视频。 500 条样本里 293 条 diagnosisInfo 为 null,按此规则归入 ok。 我曾建议保留 unknown 三态以区分「尚未诊断」,负责人已知悉并选择两态, 措辞为「初定」;顾虑与重新评估条件记录在工单 #11 评论中。 - internal/store:products 加 video_diagnosis、quality_level 两列; 新增 product_diagnoses 表保存全部 6 种诊断类型(缺少尺寸表、缺少标准变体、 缺少品牌信息、所需属性过少、缺少视频、合格级属性数量不足)。 只存「缺少视频」的话,另外 5 种将来要用就得重新全量拉一遍,而采集成本为零。 迁移全部在 migrations 末尾追加,未改动任何已有条目。 - internal/huohanhan:ProductRecord.DiagnosisInfo 用指针以区分 null; 转换时按上述规则算出 video_diagnosis。 - 前端:工具栏加诊断筛选(全部 / 缺少视频 / 有视频); 「视频」列改为显示诊断结果。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq --- app.go | 14 +++- frontend/src/views/ProductListView.vue | 26 ++++-- internal/huohanhan/product.go | 107 ++++++++++++++++++------ internal/huohanhan/product_test.go | 105 ++++++++++++++++++++--- internal/store/diagnosis.go | 72 ++++++++++++++++ internal/store/diagnosis_test.go | 31 +++++++ internal/store/product.go | 52 ++++++++---- internal/store/product_test.go | 111 +++++++++++++++++++++++++ internal/store/store.go | 15 ++++ 9 files changed, 473 insertions(+), 60 deletions(-) create mode 100644 internal/store/diagnosis.go create mode 100644 internal/store/diagnosis_test.go create mode 100644 internal/store/product_test.go diff --git a/app.go b/app.go index 3dc3cbe..6bbf071 100644 --- a/app.go +++ b/app.go @@ -257,17 +257,27 @@ func (a *App) DownloadProductData(platformShopID string) error { ctx = context.Background() } a.log.Info("开始下载所选店铺的商品数据") - products, err := client.DownloadAllProducts(ctx, platformShopID, func(current, total int) { + products, diagnoses, err := client.DownloadAllProducts(ctx, platformShopID, func(current, total int) { a.log.Info("已拉取 %d/%d 页", current, total) }) if err != nil { a.log.Error("下载商品数据失败:%v", err) return err } - if err := a.db.UpsertProducts(products, time.Now().Format("2006-01-02 15:04:05")); err != nil { + updatedAt := time.Now().Format("2006-01-02 15:04:05") + if err := a.db.UpsertProducts(products, updatedAt); err != nil { a.log.Error("保存商品数据失败:%v", err) return err } + for _, product := range products { + if product.ID == "" { + continue + } + if err := a.db.ReplaceDiagnoses(product.ID, diagnoses[product.ID], updatedAt); err != nil { + a.log.Error("保存商品诊断失败:%v", err) + return err + } + } a.log.Success("商品数据下载完成,共拉取 %d 条", len(products)) return nil } diff --git a/frontend/src/views/ProductListView.vue b/frontend/src/views/ProductListView.vue index 7d0478b..a7d66ee 100644 --- a/frontend/src/views/ProductListView.vue +++ b/frontend/src/views/ProductListView.vue @@ -3,15 +3,16 @@ * 商品列表页。 * * 结构照原型 v4: - * 上行筛选:店铺 / 蝦皮ID / 创建时间范围 / 状态 / 重置 / 搜索 + * 上行筛选:店铺 / 蝦皮ID / 创建时间范围 / 状态 / 诊断 / 重置 / 搜索 * 下行操作:下载数据 / 下载视频 / 上传数据 / 运行日志 * 下面是表格和分页 * - * 四个筛选条件对应货憨憨 getPage 的原生参数,不要自己造新参数: + * 前四个筛选条件对应货憨憨 getPage 的原生参数: * 店铺 -> platformShopId * 蝦皮ID -> itemIds * 创建时间 -> createTimeStart / createTimeEnd * 状态 -> itemStatus + * 诊断筛选对应本地 SQLite 的 video_diagnosis,不会传给货憨憨。 * * 注意:本页查的是本地 SQLite,不联网。点「下载数据」才会请求货憨憨。 */ @@ -44,6 +45,7 @@ const emptyQuery = () => ({ createdFrom: '', createdTo: '', itemStatus: 'NORMAL', + videoDiagnosis: '', page: 1, pageSize: 20, }) @@ -59,6 +61,12 @@ const statusOptions = [ { label: '被禁售', value: 'BANNED' }, ] +const diagnosisOptions = [ + { label: '全部', value: '' }, + { label: '缺少视频', value: 'missing' }, + { label: '有视频', value: 'ok' }, +] + // ---- 店铺下拉 ---- // 店铺列表必须来自货憨憨 erp/shop/all,不能在前端写死。 const shopOptions = ref([]) @@ -183,9 +191,9 @@ const columns = [ }, { title: '视频', - key: 'videoStatus', - width: 72, - render: (row) => renderStatus(row.videoStatus, videoText), + key: 'videoDiagnosis', + width: 88, + render: (row) => renderStatus(row.videoDiagnosis, diagnosisText), }, { title: '下载状态', @@ -235,7 +243,7 @@ const columns = [ // 状态值到中文和颜色的对照。 // Go 侧的常量在 internal/store/product.go,改一边要改两边。 -const videoText = { pending: ['待搜', 'muted'], found: ['有', 'ok'], none: ['无', 'faint'] } +const diagnosisText = { missing: ['缺少视频', 'bad'], ok: ['有视频', 'ok'] } const downloadText = { pending: ['待下载', 'muted'], running: ['下载中', 'primary'], @@ -395,6 +403,12 @@ onUnmounted(() => { :options="statusOptions" style="width: 130px" /> + 诊断 + 重置 搜索 diff --git a/internal/huohanhan/product.go b/internal/huohanhan/product.go index b33693c..ed95e56 100644 --- a/internal/huohanhan/product.go +++ b/internal/huohanhan/product.go @@ -32,16 +32,37 @@ type ProductPageParams struct { // ProductRecord 是货憨憨商品响应中需要保存的字段白名单。 type ProductRecord struct { - ID string `json:"id"` - ItemID string `json:"itemId"` - ItemName string `json:"itemName"` - MainImage string `json:"mainImage"` - ShopName string `json:"shopName"` - PlatformShopID string `json:"platformShopId"` - Currency string `json:"currency"` - MinSkuPrice float64 `json:"minSkuPrice"` - ItemStatus string `json:"itemStatus"` - CreateTime string `json:"createTime"` + ID string `json:"id"` + ItemID string `json:"itemId"` + ItemName string `json:"itemName"` + MainImage string `json:"mainImage"` + ShopName string `json:"shopName"` + PlatformShopID string `json:"platformShopId"` + Currency string `json:"currency"` + MinSkuPrice float64 `json:"minSkuPrice"` + ItemStatus string `json:"itemStatus"` + CreateTime string `json:"createTime"` + DiagnosisInfo *DiagnosisInfo `json:"diagnosisInfo"` +} + +// DiagnosisInfo 是货憨憨返回的商品质量诊断对象。 +// 指针字段能保留 JSON null,便于按已确认的两态规则明确处理边界。 +type DiagnosisInfo struct { + ItemID string `json:"itemId"` + QualityLevel string `json:"qualityLevel"` + Diagnoses []DiagnosisGroup `json:"diagnoses"` +} + +// DiagnosisGroup 是按商品字段分组的诊断结果。 +type DiagnosisGroup struct { + Field string `json:"field"` + DiagnosisResults []DiagnosisResult `json:"diagnosisResults"` +} + +// DiagnosisResult 是一条具体的诊断类型和处理建议。 +type DiagnosisResult struct { + Type string `json:"type"` + Solution string `json:"solution"` } // ProductPage 对应货憨憨商品接口返回的裸分页对象。 @@ -172,13 +193,14 @@ func (c *Client) GetProductPage(ctx context.Context, params ProductPageParams) ( // // 最多请求 200 页。服务端分页异常时返回已取得的数据并写警告日志, // 避免桌面程序陷入无法结束的循环。 -func (c *Client) DownloadAllProducts(ctx context.Context, platformShopID string, onProgress func(current, total int)) ([]store.Product, error) { +func (c *Client) DownloadAllProducts(ctx context.Context, platformShopID string, onProgress func(current, total int)) ([]store.Product, map[string][]store.Diagnosis, error) { platformShopID = strings.TrimSpace(platformShopID) if platformShopID == "" { - return nil, fmt.Errorf("请先选择店铺") + return nil, nil, fmt.Errorf("请先选择店铺") } products := make([]store.Product, 0) + diagnoses := make(map[string][]store.Diagnosis) lastCurrent := 0 lastPages := 0 for requestedPage := 1; requestedPage <= maximumProductPages; requestedPage++ { @@ -188,22 +210,14 @@ func (c *Client) DownloadAllProducts(ctx context.Context, platformShopID string, PlatformShopID: platformShopID, }) if err != nil { - return nil, err + return nil, nil, err } for _, record := range page.Records { - products = append(products, store.Product{ - ID: record.ID, - ItemID: record.ItemID, - ItemName: record.ItemName, - MainImage: record.MainImage, - ShopName: record.ShopName, - PlatformShopID: record.PlatformShopID, - Currency: record.Currency, - MinSkuPrice: record.MinSkuPrice, - ItemStatus: record.ItemStatus, - CreatedAt: record.CreateTime, - }) + product, productDiagnoses := convertProductRecord(record) + products = append(products, product) + // 即使没有明细也保留这个键,落库时才能清掉该商品的旧诊断。 + diagnoses[record.ID] = productDiagnoses } lastCurrent = page.Current @@ -212,12 +226,51 @@ func (c *Client) DownloadAllProducts(ctx context.Context, platformShopID string, onProgress(page.Current, page.Pages) } if page.Current >= page.Pages { - return products, nil + return products, diagnoses, nil } } if lastCurrent < lastPages { c.log.Warn("商品分页超过 %d 页上限,已停止拉取;服务端报告进度 %d/%d 页", maximumProductPages, lastCurrent, lastPages) } - return products, nil + return products, diagnoses, nil +} + +// convertProductRecord 把一条货憨憨记录转换为本地商品和全部诊断明细。 +func convertProductRecord(record ProductRecord) (store.Product, []store.Diagnosis) { + videoDiagnosis := store.VideoDiagnosisOK + qualityLevel := "" + diagnoses := make([]store.Diagnosis, 0) + + if record.DiagnosisInfo != nil { + qualityLevel = record.DiagnosisInfo.QualityLevel + for _, group := range record.DiagnosisInfo.Diagnoses { + for _, result := range group.DiagnosisResults { + diagnoses = append(diagnoses, store.Diagnosis{ + ProductID: record.ID, + Field: group.Field, + Type: result.Type, + Solution: result.Solution, + }) + if result.Type == "缺少视频" { + videoDiagnosis = store.VideoDiagnosisMissing + } + } + } + } + + return store.Product{ + ID: record.ID, + ItemID: record.ItemID, + ItemName: record.ItemName, + MainImage: record.MainImage, + ShopName: record.ShopName, + PlatformShopID: record.PlatformShopID, + Currency: record.Currency, + MinSkuPrice: record.MinSkuPrice, + ItemStatus: record.ItemStatus, + CreatedAt: record.CreateTime, + VideoDiagnosis: videoDiagnosis, + QualityLevel: qualityLevel, + }, diagnoses } diff --git a/internal/huohanhan/product_test.go b/internal/huohanhan/product_test.go index b675e20..02223e3 100644 --- a/internal/huohanhan/product_test.go +++ b/internal/huohanhan/product_test.go @@ -57,21 +57,34 @@ func Test商品下载拉完三页并正确转换主键(t *testing.T) { } current, _ := strconv.Atoi(r.Form.Get("current")) calls++ + record := map[string]any{ + "id": "hhh-" + strconv.Itoa(current), + "itemId": "shopee-" + strconv.Itoa(current), + "itemName": "商品", "mainImage": "https://example.invalid/image.jpg", + "shopName": "测试店铺", "platformShopId": "1664202094", + "currency": "TWD", "minSkuPrice": 88.5, + "itemStatus": "NORMAL", "createTime": "2026-08-31 01:54:08", + } + if current == 1 { + record["diagnosisInfo"] = map[string]any{ + "itemId": "shopee-1", "qualityLevel": "1", + "diagnoses": []map[string]any{{ + "field": "ALL", + "diagnosisResults": []map[string]any{ + {"type": "缺少视频", "solution": "上传相应的视频"}, + {"type": "缺少品牌信息", "solution": "填写品牌信息"}, + }, + }}, + } + } writeJSON(w, map[string]any{ - "records": []map[string]any{{ - "id": "hhh-" + strconv.Itoa(current), - "itemId": "shopee-" + strconv.Itoa(current), - "itemName": "商品", "mainImage": "https://example.invalid/image.jpg", - "shopName": "测试店铺", "platformShopId": "1664202094", - "currency": "TWD", "minSkuPrice": 88.5, - "itemStatus": "NORMAL", "createTime": "2026-08-31 01:54:08", - }}, - "total": 3, "size": 20, "current": current, "pages": 3, + "records": []map[string]any{record}, + "total": 3, "size": 20, "current": current, "pages": 3, }) }) var progress []int - products, err := client.DownloadAllProducts(context.Background(), "1664202094", func(current, total int) { + products, diagnoses, err := client.DownloadAllProducts(context.Background(), "1664202094", func(current, total int) { if total != 3 { t.Errorf("总页数应为 3,实际 %d", total) } @@ -90,6 +103,12 @@ func Test商品下载拉完三页并正确转换主键(t *testing.T) { if first.CreatedAt != "2026-08-31 01:54:08" || first.MinSkuPrice != 88.5 { t.Fatalf("商品字段转换不完整:%+v", first) } + if first.VideoDiagnosis != "missing" || first.QualityLevel != "1" { + t.Fatalf("JSON 中的诊断摘要转换不正确:%+v", first) + } + if len(diagnoses[first.ID]) != 2 || diagnoses[first.ID][1].Type != "缺少品牌信息" { + t.Fatalf("JSON 中的全部诊断明细应当返回:%+v", diagnoses[first.ID]) + } } func Test商品分页超过二百页时警告并停止(t *testing.T) { @@ -107,7 +126,7 @@ func Test商品分页超过二百页时警告并停止(t *testing.T) { }) }) - products, err := client.DownloadAllProducts(context.Background(), "1664202094", nil) + products, _, err := client.DownloadAllProducts(context.Background(), "1664202094", nil) if err != nil { t.Fatalf("达到分页保护上限不应报错:%v", err) } @@ -121,3 +140,67 @@ func Test商品分页超过二百页时警告并停止(t *testing.T) { t.Fatalf("达到上限必须写警告日志,实际日志:%s", logger.Text()) } } + +func Test诊断为空时按负责人决定归入有视频(t *testing.T) { + product, diagnoses := convertProductRecord(ProductRecord{ID: "商品-1"}) + + if product.VideoDiagnosis != "ok" { + t.Fatalf("diagnosisInfo 为 null 时应当归入 ok,实际 %q", product.VideoDiagnosis) + } + if len(diagnoses) != 0 { + t.Fatalf("diagnosisInfo 为 null 时不应生成诊断明细,实际 %d 条", len(diagnoses)) + } +} + +func Test含缺少视频时标记为缺少并保存全部诊断(t *testing.T) { + record := ProductRecord{ + ID: "商品-2", + DiagnosisInfo: &DiagnosisInfo{ + QualityLevel: "1", + Diagnoses: []DiagnosisGroup{{ + Field: "ALL", + DiagnosisResults: []DiagnosisResult{ + {Type: "缺少视频", Solution: "上传相应的视频"}, + {Type: "缺少品牌信息", Solution: "填写品牌信息"}, + }, + }}, + }, + } + + product, diagnoses := convertProductRecord(record) + if product.VideoDiagnosis != "missing" { + t.Fatalf("含缺少视频时应当标记 missing,实际 %q", product.VideoDiagnosis) + } + if product.QualityLevel != "1" { + t.Fatalf("质量等级应当完整转换,实际 %q", product.QualityLevel) + } + if len(diagnoses) != 2 { + t.Fatalf("全部诊断类型都应保留,期望 2 条,实际 %d 条", len(diagnoses)) + } + if diagnoses[1].Type != "缺少品牌信息" || diagnoses[1].ProductID != record.ID { + t.Fatalf("非视频诊断或商品关联丢失:%+v", diagnoses[1]) + } +} + +func Test有诊断但不含缺少视频时归入有视频(t *testing.T) { + record := ProductRecord{ + ID: "商品-3", + DiagnosisInfo: &DiagnosisInfo{ + QualityLevel: "2", + Diagnoses: []DiagnosisGroup{{ + Field: "ALL", + DiagnosisResults: []DiagnosisResult{{ + Type: "缺少尺寸表", Solution: "上传尺寸表", + }}, + }}, + }, + } + + product, diagnoses := convertProductRecord(record) + if product.VideoDiagnosis != "ok" { + t.Fatalf("未报缺少视频时应当归入 ok,实际 %q", product.VideoDiagnosis) + } + if len(diagnoses) != 1 || diagnoses[0].Type != "缺少尺寸表" { + t.Fatalf("其它诊断仍应完整保留:%+v", diagnoses) + } +} diff --git a/internal/store/diagnosis.go b/internal/store/diagnosis.go new file mode 100644 index 0000000..4d39010 --- /dev/null +++ b/internal/store/diagnosis.go @@ -0,0 +1,72 @@ +package store + +import "fmt" + +// Diagnosis 是货憨憨返回的一条商品质量诊断。 +type Diagnosis struct { + ProductID string `json:"productId"` + Field string `json:"field"` + Type string `json:"type"` + Solution string `json:"solution"` +} + +// ReplaceDiagnoses 全量替换一个商品的诊断明细。 +// +// 必须先删后写并放在同一个事务里。商品被修好后,货憨憨不会再返回 +// 原来的诊断;只做追加或 upsert 会让旧诊断永久残留。 +func (s *Store) ReplaceDiagnoses(productID string, items []Diagnosis, now string) error { + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("开启商品诊断事务失败:%w", err) + } + defer tx.Rollback() + + if _, err := tx.Exec(`DELETE FROM product_diagnoses WHERE product_id = ?`, productID); err != nil { + return fmt.Errorf("清空商品 %s 的旧诊断失败:%w", productID, err) + } + + stmt, err := tx.Prepare(` + INSERT INTO product_diagnoses (product_id, field, type, solution, updated_at) + VALUES (?, ?, ?, ?, ?)`) + if err != nil { + return fmt.Errorf("准备写入商品诊断失败:%w", err) + } + defer stmt.Close() + + for _, item := range items { + if _, err := stmt.Exec(productID, item.Field, item.Type, item.Solution, now); err != nil { + return fmt.Errorf("写入商品 %s 的诊断失败:%w", productID, err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("提交商品诊断事务失败:%w", err) + } + return nil +} + +// ListDiagnoses 返回一个商品的全部诊断明细。 +func (s *Store) ListDiagnoses(productID string) ([]Diagnosis, error) { + rows, err := s.db.Query(` + SELECT product_id, field, type, solution + FROM product_diagnoses + WHERE product_id = ? + ORDER BY rowid`, productID) + if err != nil { + return nil, fmt.Errorf("读取商品 %s 的诊断失败:%w", productID, err) + } + defer rows.Close() + + items := make([]Diagnosis, 0) + for rows.Next() { + var item Diagnosis + if err := rows.Scan(&item.ProductID, &item.Field, &item.Type, &item.Solution); err != nil { + return nil, fmt.Errorf("读取商品 %s 的诊断行失败:%w", productID, err) + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("遍历商品 %s 的诊断失败:%w", productID, err) + } + return items, nil +} diff --git a/internal/store/diagnosis_test.go b/internal/store/diagnosis_test.go new file mode 100644 index 0000000..7d3bdae --- /dev/null +++ b/internal/store/diagnosis_test.go @@ -0,0 +1,31 @@ +package store + +import "testing" + +func Test商品诊断全量替换(t *testing.T) { + s := newTestStore(t) + first := []Diagnosis{ + {Field: "ALL", Type: "缺少视频", Solution: "上传视频"}, + {Field: "ALL", Type: "缺少品牌信息", Solution: "填写品牌"}, + {Field: "ALL", Type: "缺少尺寸表", Solution: "上传尺寸表"}, + } + if err := s.ReplaceDiagnoses("商品-1", first, "2026-09-02 10:00:00"); err != nil { + t.Fatalf("首次写入诊断失败:%v", err) + } + + second := []Diagnosis{{Field: "ALL", Type: "缺少标准变体", Solution: "补充变体"}} + if err := s.ReplaceDiagnoses("商品-1", second, "2026-09-02 11:00:00"); err != nil { + t.Fatalf("替换诊断失败:%v", err) + } + + got, err := s.ListDiagnoses("商品-1") + if err != nil { + t.Fatalf("读取诊断失败:%v", err) + } + if len(got) != 1 { + t.Fatalf("全量替换后应当只剩 1 条,实际 %d 条:%+v", len(got), got) + } + if got[0].ProductID != "商品-1" || got[0].Type != "缺少标准变体" { + t.Fatalf("替换后的诊断不正确:%+v", got[0]) + } +} diff --git a/internal/store/product.go b/internal/store/product.go index 6276513..3d886f6 100644 --- a/internal/store/product.go +++ b/internal/store/product.go @@ -6,11 +6,17 @@ import ( "strings" ) -// 商品的三个状态字段各有一组取值。 +// 商品诊断和三个本地状态字段各有一组取值。 // // 为什么用字符串常量而不是数字:出问题时直接 SQL 查库就能看懂, // 不用回来翻代码对照数字含义。 const ( + // video_diagnosis:货憨憨的商品质量诊断。 + // 负责人 2026-09-02 的阶段性决定是只认明确的「缺少视频」; + // diagnosisInfo 为 null 和其它所有情况都归入 ok,不引入第三种状态。 + VideoDiagnosisMissing = "missing" + VideoDiagnosisOK = "ok" + // video_status:有没有找到同款视频 VideoPending = "pending" // 还没搜过 VideoFound = "found" // 找到了 @@ -43,6 +49,8 @@ type Product struct { MinSkuPrice float64 `json:"minSkuPrice"` // 最低价 ItemStatus string `json:"itemStatus"` // 货憨憨的商品状态,例如 NORMAL CreatedAt string `json:"createdAt"` // 货憨憨侧创建时间 + VideoDiagnosis string `json:"videoDiagnosis"` // 货憨憨质量诊断中的视频结果 + QualityLevel string `json:"qualityLevel"` // 货憨憨质量等级 VideoStatus string `json:"videoStatus"` // 本工具维护 DownloadStatus string `json:"downloadStatus"` // 本工具维护 UploadStatus string `json:"uploadStatus"` // 本工具维护 @@ -50,7 +58,7 @@ type Product struct { SyncedAt string `json:"syncedAt"` // 本地同步时间 } -// ProductQuery 是查询条件,对应界面工具栏上行的四个筛选框。 +// ProductQuery 是查询条件,对应界面工具栏上行的筛选框。 // // 所有字段都可以留空,留空表示不按这个条件过滤。 type ProductQuery struct { @@ -59,6 +67,7 @@ type ProductQuery struct { CreatedFrom string `json:"createdFrom"` // 创建时间起,形如 2026-08-01 CreatedTo string `json:"createdTo"` // 创建时间止 ItemStatus string `json:"itemStatus"` // 商品状态 + VideoDiagnosis string `json:"videoDiagnosis"` // 视频诊断 Page int `json:"page"` // 页码,从 1 开始 PageSize int `json:"pageSize"` // 每页条数 } @@ -73,9 +82,9 @@ type ProductPage struct { // UpsertProducts 批量写入商品:已存在就更新,不存在就插入。 // -// 关键点:更新时只覆盖来自货憨憨的字段,绝不动 video_status、 -// download_status、upload_status 这三列。否则每次「下载数据」 -// 都会把已经下载好的进度清零,同事会白干。 +// 关键点:video_diagnosis 和 quality_level 来自货憨憨,需要随同步更新; +// video_status、download_status、upload_status 是本地状态,绝不能覆盖。 +// 否则每次「下载数据」都会把已经下载好的进度清零,同事会白干。 func (s *Store) UpsertProducts(items []Product, now string) error { if len(items) == 0 { return nil @@ -93,8 +102,9 @@ func (s *Store) UpsertProducts(items []Product, now string) error { stmt, err := tx.Prepare(` INSERT INTO products ( id, item_id, item_name, main_image, shop_name, platform_shop_id, - currency, min_sku_price, item_status, created_at, synced_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + currency, min_sku_price, item_status, created_at, video_diagnosis, + quality_level, synced_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET item_id = excluded.item_id, item_name = excluded.item_name, @@ -105,6 +115,8 @@ func (s *Store) UpsertProducts(items []Product, now string) error { min_sku_price = excluded.min_sku_price, item_status = excluded.item_status, created_at = excluded.created_at, + video_diagnosis = excluded.video_diagnosis, + quality_level = excluded.quality_level, synced_at = excluded.synced_at`) if err != nil { return fmt.Errorf("准备写入语句失败:%w", err) @@ -117,9 +129,14 @@ func (s *Store) UpsertProducts(items []Product, now string) error { // 不要用 item_id 顶替,两者不是一回事。 continue } + // 写入层也守住两态约束:只有明确的 missing 保留为缺少视频, + // 空值或其它值都按负责人决定归入 ok。 + if p.VideoDiagnosis != VideoDiagnosisMissing { + p.VideoDiagnosis = VideoDiagnosisOK + } _, err := stmt.Exec(p.ID, p.ItemID, p.ItemName, p.MainImage, p.ShopName, p.PlatformShopID, p.Currency, p.MinSkuPrice, p.ItemStatus, - p.CreatedAt, now) + p.CreatedAt, p.VideoDiagnosis, p.QualityLevel, now) if err != nil { return fmt.Errorf("写入商品 %s 失败:%w", p.ID, err) } @@ -150,7 +167,8 @@ func (s *Store) ListProducts(q ProductQuery) (ProductPage, error) { listSQL := `SELECT id, item_id, item_name, main_image, shop_name, platform_shop_id, currency, min_sku_price, item_status, created_at, - video_status, download_status, upload_status, last_error, synced_at + video_diagnosis, quality_level, video_status, download_status, + upload_status, last_error, synced_at FROM products` + where + ` ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?` @@ -166,8 +184,9 @@ func (s *Store) ListProducts(q ProductQuery) (ProductPage, error) { var p Product if err := rows.Scan(&p.ID, &p.ItemID, &p.ItemName, &p.MainImage, &p.ShopName, &p.PlatformShopID, &p.Currency, &p.MinSkuPrice, - &p.ItemStatus, &p.CreatedAt, &p.VideoStatus, &p.DownloadStatus, - &p.UploadStatus, &p.LastError, &p.SyncedAt); err != nil { + &p.ItemStatus, &p.CreatedAt, &p.VideoDiagnosis, &p.QualityLevel, + &p.VideoStatus, &p.DownloadStatus, &p.UploadStatus, &p.LastError, + &p.SyncedAt); err != nil { return ProductPage{}, fmt.Errorf("读取商品行失败:%w", err) } items = append(items, p) @@ -195,6 +214,10 @@ func buildWhere(q ProductQuery) (string, []any) { conds = append(conds, "item_status = ?") args = append(args, v) } + if v := strings.TrimSpace(q.VideoDiagnosis); v != "" { + conds = append(conds, "video_diagnosis = ?") + args = append(args, v) + } if v := strings.TrimSpace(q.CreatedFrom); v != "" { conds = append(conds, "created_at >= ?") args = append(args, v) @@ -249,13 +272,14 @@ func (s *Store) GetProduct(id string) (Product, bool, error) { var p Product row := s.db.QueryRow(`SELECT id, item_id, item_name, main_image, shop_name, platform_shop_id, currency, min_sku_price, item_status, created_at, - video_status, download_status, upload_status, last_error, synced_at + video_diagnosis, quality_level, video_status, download_status, + upload_status, last_error, synced_at FROM products WHERE id = ?`, id) err := row.Scan(&p.ID, &p.ItemID, &p.ItemName, &p.MainImage, &p.ShopName, &p.PlatformShopID, &p.Currency, &p.MinSkuPrice, &p.ItemStatus, - &p.CreatedAt, &p.VideoStatus, &p.DownloadStatus, &p.UploadStatus, - &p.LastError, &p.SyncedAt) + &p.CreatedAt, &p.VideoDiagnosis, &p.QualityLevel, &p.VideoStatus, + &p.DownloadStatus, &p.UploadStatus, &p.LastError, &p.SyncedAt) switch { case err == sql.ErrNoRows: return Product{}, false, nil diff --git a/internal/store/product_test.go b/internal/store/product_test.go new file mode 100644 index 0000000..f674c6f --- /dev/null +++ b/internal/store/product_test.go @@ -0,0 +1,111 @@ +package store + +import ( + "database/sql" + "path/filepath" + "testing" +) + +func Test老库升级保留商品和本地状态(t *testing.T) { + path := filepath.Join(t.TempDir(), "旧版.db") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatalf("打开旧版测试数据库失败:%v", err) + } + db.SetMaxOpenConns(1) + if _, err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL)`); err != nil { + t.Fatalf("创建旧版版本表失败:%v", err) + } + + oldMigrationCount := len(migrations) - 4 + for i := 0; i < oldMigrationCount; i++ { + if _, err := db.Exec(migrations[i]); err != nil { + t.Fatalf("执行旧版第 %d 条迁移失败:%v", i+1, err) + } + if _, err := db.Exec(`INSERT INTO schema_version (version) VALUES (?)`, i+1); err != nil { + t.Fatalf("记录旧版第 %d 条迁移失败:%v", i+1, err) + } + } + if _, err := db.Exec(` + INSERT INTO products (id, item_id, item_name, download_status) + VALUES ('旧商品-1', '蝦皮-1', '升级前商品', 'done')`); err != nil { + t.Fatalf("写入旧版商品失败:%v", err) + } + if err := db.Close(); err != nil { + t.Fatalf("关闭旧版数据库失败:%v", err) + } + + s, err := Open(path) + if err != nil { + t.Fatalf("用完整迁移升级旧库失败:%v", err) + } + defer s.Close() + + version, err := s.Version() + if err != nil { + t.Fatalf("读取升级后版本失败:%v", err) + } + if version != len(migrations) || version != oldMigrationCount+4 { + t.Fatalf("升级后版本应为 %d,实际 %d", len(migrations), version) + } + product, found, err := s.GetProduct("旧商品-1") + if err != nil || !found { + t.Fatalf("升级后原商品应当保留:err=%v found=%v", err, found) + } + if product.DownloadStatus != DownloadDone { + t.Fatalf("升级不能清掉本地下载状态,实际 %q", product.DownloadStatus) + } + if product.VideoDiagnosis != VideoDiagnosisOK || product.QualityLevel != "" { + t.Fatalf("新列默认值不正确:videoDiagnosis=%q qualityLevel=%q", product.VideoDiagnosis, product.QualityLevel) + } +} + +func Test按视频诊断筛选商品(t *testing.T) { + s := newTestStore(t) + items := []Product{ + {ID: "缺视频-1", ItemID: "1", VideoDiagnosis: VideoDiagnosisMissing}, + {ID: "有视频-1", ItemID: "2", VideoDiagnosis: VideoDiagnosisOK}, + {ID: "有视频-2", ItemID: "3", VideoDiagnosis: VideoDiagnosisOK}, + } + if err := s.UpsertProducts(items, "2026-09-02 10:00:00"); err != nil { + t.Fatalf("写入商品失败:%v", err) + } + + missing, err := s.ListProducts(ProductQuery{VideoDiagnosis: VideoDiagnosisMissing}) + if err != nil { + t.Fatalf("筛选缺少视频失败:%v", err) + } + if missing.Total != 1 || missing.Items[0].ID != "缺视频-1" { + t.Fatalf("缺少视频筛选结果不正确:%+v", missing.Items) + } + ok, err := s.ListProducts(ProductQuery{VideoDiagnosis: VideoDiagnosisOK}) + if err != nil { + t.Fatalf("筛选有视频失败:%v", err) + } + if ok.Total != 2 { + t.Fatalf("有视频筛选应返回 2 条,实际 %d", ok.Total) + } +} + +func Test重复写入商品不会清空诊断结果(t *testing.T) { + s := newTestStore(t) + product := Product{ + ID: "商品-1", ItemID: "蝦皮-1", + VideoDiagnosis: VideoDiagnosisMissing, QualityLevel: "1", + } + if err := s.UpsertProducts([]Product{product}, "2026-09-02 10:00:00"); err != nil { + t.Fatalf("首次写入商品失败:%v", err) + } + product.ItemName = "更新后的标题" + if err := s.UpsertProducts([]Product{product}, "2026-09-02 11:00:00"); err != nil { + t.Fatalf("重复写入商品失败:%v", err) + } + + got, found, err := s.GetProduct(product.ID) + if err != nil || !found { + t.Fatalf("读取商品失败:err=%v found=%v", err, found) + } + if got.VideoDiagnosis != VideoDiagnosisMissing || got.QualityLevel != "1" { + t.Fatalf("重复写入后诊断结果被清空:videoDiagnosis=%q qualityLevel=%q", got.VideoDiagnosis, got.QualityLevel) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index f4aa379..e8b733c 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -147,6 +147,21 @@ var migrations = []string{ status TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT '' )`, + + `ALTER TABLE products ADD COLUMN video_diagnosis TEXT NOT NULL DEFAULT 'ok'`, + + `ALTER TABLE products ADD COLUMN quality_level TEXT NOT NULL DEFAULT ''`, + + `CREATE TABLE IF NOT EXISTS product_diagnoses ( + product_id TEXT NOT NULL, + field TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL DEFAULT '', + solution TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT '' + )`, + + `CREATE INDEX IF NOT EXISTS idx_product_diagnoses_product + ON product_diagnoses (product_id)`, } // migrate 把表结构升级到最新。