diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md
index 25fdeee..db76bd2 100644
--- a/docs/02-architecture-and-code-map.md
+++ b/docs/02-architecture-and-code-map.md
@@ -2,12 +2,18 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/chengma/cmsp/wiki/Architecture-and-Code-Map.-
-wiki_revision: 662a06a06a45623d4adb68c5bf1fe86ee70103ee
-synchronized_at: 2026-09-29T07:39:06Z
+wiki_revision: fc8ac21bda0121896e608c5a61d828ad48d137e9
+synchronized_at: 2026-09-29T08:20:24Z
# 架构与代码地图
+## 商品列表下载状态筛选(2026-09-29)
+
+`ProductListView.vue` 的初始/重置查询默认为在售中(NORMAL)、缺少视频(missing)和未下载(not_downloaded)。下载状态下拉复用现有 n-select,提供全部、未下载(含失败)、待下载、下载中、已下载、下载失败;用户点击搜索应用条件,沿用原页码重置/选择清理和筛选栏换行。
+
+`store.ProductQuery.DownloadStatus` 的 JSON 名称为 `downloadStatus`,空值保持旧调用查全部下载状态。`buildWhere` 把 not_downloaded 映射到 `download_status IN (pending, failed)`,单状态等值查询,所有值参数化,与其他条件 AND 组合;COUNT 和分页列表使用同一 WHERE。not_downloaded 仅是查询条件,不写入数据库状态。筛选不调用外部接口、不更改 SQLite 表结构或商品/视频/任务/上传状态。
+
## 图搜原始商品链接(2026-09-29)
`parseImageSearchResult` 读取已通过真实响应确认的 `itemsArray[].auctionURL`,写入 `SimilarItem.URL`。`SimilarItem.DetailURL` 在 Go 侧校验 http/https、精确商品域名 `item.taobao.com` / `detail.tmall.com`、`/item.htm` 路径及唯一且匹配的 `id`,拒绝用户信息、其他端口、片段、错误编码和不可信站点;协议相对地址补 https。保留原链接查询参数与顺序,不记录完整链接或参数值。只有响应缺少链接时兼容原 ID 拼接方式;非法原链接候选跳过,不访问也不改拼旧地址,其他合法候选照常返回;若有非法链接且全部候选都不可用,则明确报错,不能记为正常无结果。
diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md
index 934f6f9..aa34195 100644
--- a/docs/03-business-rules-and-glossary.md
+++ b/docs/03-business-rules-and-glossary.md
@@ -2,12 +2,18 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/chengma/cmsp/wiki/Business-Rules-and-Glossary.-
-wiki_revision: f4dcb37885891ef200f0d4703d97d623fa8aac47
-synchronized_at: 2026-09-29T07:39:09Z
+wiki_revision: c9e553508e0ca8989a91d482ffe2680c53e73a1a
+synchronized_at: 2026-09-29T08:20:25Z
# 业务规则与术语
+## 缺视频且未下载的筛选语义(2026-09-29)
+
+商品列表默认及重置显示在售中、货憨憨诊断缺少视频、且本地未下载的记录。“未下载(含失败)”指 SQLite download_status 为 pending 或 failed;running/done 排除,避免重复处理已下载或正在下载的记录。它按下载状态筛选,不扫描文件存在;已下载但文件被人工删除的记录仍属已下载。
+
+可以把下载状态改成全部、待下载、下载中、已下载或下载失败,再点搜索;与类型(缺少视频/有视频)、店铺和上传状态等条件取交集。旧接口调用缺少 downloadStatus 字段或传空值仍查全部下载状态。筛选不清空历史无同款视频状态,也不改变批量任务/上传范围。
+
## 同款商品链接使用(2026-09-29)
MTOP 搜索方式、签名与请求参数保持现状。商品原链接以实际返回字段 `auctionURL` 为准,保留查询参数;允许淘宝/天猫的商品详情 `/item.htm`,必须与候选商品 ID 一致。非法链接候选跳过,不导航、不改拼旧地址、不泄漏到错误日志;其他合法候选仍可处理,全为非法链接则明确报错,不能记为正常无结果。缺少字段时才使用原 ID 拼接链接。完整链接仅用于当前查询结果、详情导航与下载 Referer,不新增持久化任务状态。
diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md
index 33d7f77..0d0d554 100644
--- a/docs/04-local-development-and-verification.md
+++ b/docs/04-local-development-and-verification.md
@@ -2,12 +2,18 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/chengma/cmsp/wiki/Local-Development-and-Verification.-
-wiki_revision: a99cdc64887622e4127aebd0693d37fffaa3f0c4
-synchronized_at: 2026-09-29T07:39:11Z
+wiki_revision: 855eae1f9be9d9d0ebdc77bc2ef09a7f9233d479
+synchronized_at: 2026-09-29T08:20:28Z
# 本地开发与验证
+## 缺少视频与未下载组合筛选验证(2026-09-29)
+
+`internal/store/product_test.go` 的组合筛选测试使用虚构数据和临时 SQLite,覆盖 pending/failed 包含、running/done 排除、全部及单状态、视频诊断/店铺/在售/上传交集、过滤后 COUNT 与分页一致、旧空条件兼容、参数化查询以及查询前后状态不变。未下载只依赖 SQLite 状态,不以文件是否存在判定。
+
+前端默认和重置都是在售中 + 缺少视频 + 未下载(含失败);下载状态控件位于类型与上传之间,使用标准下拉和现有自动换行布局。点击搜索应用,切换全部可恢复查看已下载记录。验证 `go test ./...`、`go vet ./...`、前端构建及 `wails build -o cmsp24.exe`。构建和 SQLite 测试不代替 Windows WebView2、Narrator、高对比度、不同显示缩放及键盘的真机验收。
+
## 商品原链接验证(2026-09-29)
`internal/taobao/imagesearch_test.go` 验证 `auctionURL` 参数保留、缺失字段兼容、非法链接候选跳过及全部非法时明确报错;`detail_test.go` 验证淘宝/天猫域名、商品 ID 匹配、拒绝恶意域名/重复 ID/错误编码/用户信息/其他端口及片段、原链接导航、缓存复用和受限后停止。`app_video_test.go` 的 SQLite 停止场景包含天猫原链接,验证商品、视频、上传及剩余队列状态保留。常规测试不访问淘宝。
diff --git a/frontend/src/views/ProductListView.vue b/frontend/src/views/ProductListView.vue
index 1fdd857..80e3862 100644
--- a/frontend/src/views/ProductListView.vue
+++ b/frontend/src/views/ProductListView.vue
@@ -13,7 +13,7 @@
* 创建时间 -> createTimeStart / createTimeEnd
* 状态 -> itemStatus
* 「类型」筛选对应本地 SQLite 的 video_diagnosis,不会传给货憨憨。
- * 默认选中「缺少视频」:这些才是需要拿主图去淘宝搜视频的商品,
+ * 默认选中「缺少视频」及「未下载(含失败)」:这些才是需要拿主图去淘宝搜视频的商品,
* 也是本工具的主要工作对象。
*
* 注意:本页查的是本地 SQLite,不联网。点「下载数据」才会通过 erpgo 查询货憨憨。
@@ -56,6 +56,7 @@ const emptyQuery = () => ({
createdTo: '',
itemStatus: 'NORMAL',
videoDiagnosis: 'missing',
+ downloadStatus: 'not_downloaded',
uploadStatus: '',
page: 1,
pageSize: 20,
@@ -89,6 +90,15 @@ const uploadStatusOptions = [
{ label: '视频不合规', value: 'invalid' },
]
+const downloadStatusOptions = [
+ { label: '全部下载状态', value: '' },
+ { label: '未下载(含失败)', value: 'not_downloaded' },
+ { label: '待下载', value: 'pending' },
+ { label: '下载中', value: 'running' },
+ { label: '已下载', value: 'done' },
+ { label: '下载失败', value: 'failed' },
+]
+
// ---- 店铺下拉 ----
// 店铺列表通过 erpgo 查询货憨憨,不能在前端写死。
const shopOptions = ref([])
@@ -760,6 +770,13 @@ onUnmounted(() => {
:options="diagnosisOptions"
style="width: 130px"
/>
+ 下载状态
+
上传
= ?")
args = append(args, v)
diff --git a/internal/store/product_test.go b/internal/store/product_test.go
index 27f8b66..4e962e1 100644
--- a/internal/store/product_test.go
+++ b/internal/store/product_test.go
@@ -3,6 +3,7 @@ package store
import (
"database/sql"
"path/filepath"
+ "reflect"
"testing"
)
@@ -157,6 +158,60 @@ func Test按视频诊断筛选商品(t *testing.T) {
}
}
+func Test缺少视频与下载状态组合筛选(t *testing.T) {
+ s := newTestStore(t)
+ items := []Product{
+ {ID: "a-pending", VideoDiagnosis: VideoDiagnosisMissing, PlatformShopID: "fictional-A", ItemStatus: "NORMAL"},
+ {ID: "b-failed", VideoDiagnosis: VideoDiagnosisMissing, PlatformShopID: "fictional-A", ItemStatus: "NORMAL"},
+ {ID: "c-running", VideoDiagnosis: VideoDiagnosisMissing, PlatformShopID: "fictional-A", ItemStatus: "NORMAL"},
+ {ID: "d-done", VideoDiagnosis: VideoDiagnosisMissing, PlatformShopID: "fictional-A", ItemStatus: "NORMAL"},
+ {ID: "e-has-video", VideoDiagnosis: VideoDiagnosisOK, PlatformShopID: "fictional-A", ItemStatus: "NORMAL"},
+ {ID: "f-other-shop", VideoDiagnosis: VideoDiagnosisMissing, PlatformShopID: "fictional-B", ItemStatus: "NORMAL"},
+ {ID: "g-unlisted", VideoDiagnosis: VideoDiagnosisMissing, PlatformShopID: "fictional-A", ItemStatus: "UNLIST"},
+ }
+ if err := s.UpsertProducts(items, "fictional-time"); err != nil {
+ t.Fatal(err)
+ }
+ for id, status := range map[string]string{"b-failed": DownloadFailed, "c-running": DownloadRunning, "d-done": DownloadDone} {
+ if err := s.UpdateProductStatus(id, VideoFound, status, UploadFailed, "fictional-error"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ before, err := s.ListProducts(ProductQuery{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ query := ProductQuery{VideoDiagnosis: VideoDiagnosisMissing, PlatformShopID: "fictional-A", ItemStatus: "NORMAL"}
+ for status, want := range map[string]int{"": 4, DownloadNotDownloaded: 2, DownloadPending: 1, DownloadFailed: 1, DownloadRunning: 1, DownloadDone: 1, "pending' OR 1=1 --": 0} {
+ query.DownloadStatus = status
+ page, err := s.ListProducts(query)
+ if err != nil || page.Total != want || len(page.Items) != want {
+ t.Fatalf("download filter %q: total=%d len=%d err=%v", status, page.Total, len(page.Items), err)
+ }
+ }
+ query.DownloadStatus = DownloadNotDownloaded
+ query.UploadStatus = UploadFailed
+ page, err := s.ListProducts(query)
+ if err != nil || page.Total != 1 || page.Items[0].ID != "b-failed" {
+ t.Fatalf("upload filter must intersect: %+v err=%v", page, err)
+ }
+ query.UploadStatus = ""
+ query.PageSize = 1
+ first, err := s.ListProducts(query)
+ if err != nil {
+ t.Fatal(err)
+ }
+ query.Page = 2
+ second, err := s.ListProducts(query)
+ if err != nil || first.Total != 2 || second.Total != 2 || len(first.Items) != 1 || len(second.Items) != 1 || first.Items[0].ID == second.Items[0].ID {
+ t.Fatalf("filtered pagination inconsistent: first=%+v second=%+v err=%v", first, second, err)
+ }
+ after, err := s.ListProducts(ProductQuery{})
+ if err != nil || !reflect.DeepEqual(before, after) {
+ t.Fatalf("filtering must not change product states: err=%v", err)
+ }
+}
+
func Test按上传状态筛选商品(t *testing.T) {
s := newTestStore(t)
items := []Product{{ID: "缺少", ItemID: "1"}, {ID: "不合规", ItemID: "2"}, {ID: "已有", ItemID: "3"}}