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 把表结构升级到最新。