feat: 解析商品质量诊断并支持按「缺少视频」筛选 (#11)
由 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<span class="lb">诊断</span>
|
||||
<n-select
|
||||
v-model:value="query.videoDiagnosis"
|
||||
:options="diagnosisOptions"
|
||||
style="width: 130px"
|
||||
/>
|
||||
<n-button style="margin-left: 12px" @click="reset">重置</n-button>
|
||||
<n-button type="primary" @click="search()">搜索</n-button>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
+38
-14
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 把表结构升级到最新。
|
||||
|
||||
Reference in New Issue
Block a user