建立可运行、可构建的 Go 骨架,供后续 R1~R5 在其上叠加。 后端: - main.go / app.go:Wails v2 窗口与 14 个前端可调方法。Wails 专有 API 只出现在这两个文件,internal/ 对 Wails 零依赖,便于将来迁移 v3 - internal/config:config.yaml 的读写、校验、脱敏。保存时按固定模板 渲染以保留注释,不用 yaml.Marshal - internal/store:SQLite(modernc.org/sqlite,无需 CGO)。建表迁移、 商品仓储、KV。重复同步不会覆盖本地下载与上传状态 - internal/logx:9 条脱敏规则,覆盖淘宝 token、Cookie、Authorization、 密码和图片 base64;并发安全的内存日志缓冲 前端(Vue 3 + Naive UI,按已确认原型 v4): - Sidebar:176px 可折叠,仅商品列表与参数设置两项,底部是登录状态的 唯一显示位置 - ProductListView:两行工具栏(四项筛选 + 重置搜索 / 四个操作按钮)、 商品表格、分页 - SettingsView:四张卡片,路径用文件与目录选择框 - LogWindow:运行日志子窗口,按级别过滤、自动滚动、导出 配置改用 YAML:分 huohanhan / taobao / download 三段。taobao 段刻意 没有账号密码字段,并有单元测试挡住后续加入。 构建相关: - .gitignore 只忽略 build/bin/,不忽略 build/。Wails 把 build/ 当源码 目录(图标、manifest),忽略掉会让别人克隆后构建失败 - .gitignore 忽略 config.yaml、payloads/ 和 *.har(含真实手机号与凭据) 未实现的下载数据、下载视频、上传数据三个方法返回明确的「还没实现」, 不静默失败。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq
277 lines
8.0 KiB
Go
277 lines
8.0 KiB
Go
package store
|
||
|
||
import (
|
||
"path/filepath"
|
||
"testing"
|
||
)
|
||
|
||
// newTestStore 开一个只存在于内存里的数据库。
|
||
// 测试之间互不影响,也不会在磁盘上留垃圾文件。
|
||
func newTestStore(t *testing.T) *Store {
|
||
t.Helper()
|
||
s, err := Open(":memory:")
|
||
if err != nil {
|
||
t.Fatalf("打开测试数据库失败:%v", err)
|
||
}
|
||
t.Cleanup(func() { s.Close() })
|
||
return s
|
||
}
|
||
|
||
func TestOpenCreatesAllTables(t *testing.T) {
|
||
s := newTestStore(t)
|
||
|
||
version, err := s.Version()
|
||
if err != nil {
|
||
t.Fatalf("读取版本失败:%v", err)
|
||
}
|
||
if version != len(migrations) {
|
||
t.Fatalf("版本应当等于建表语句条数 %d,实际 %d", len(migrations), version)
|
||
}
|
||
|
||
// 每张表都要真的存在。
|
||
for _, table := range []string{"products", "videos", "kv", "schema_version"} {
|
||
var name string
|
||
err := s.DB().QueryRow(
|
||
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
|
||
table).Scan(&name)
|
||
if err != nil {
|
||
t.Fatalf("表 %s 不存在:%v", table, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 程序每次启动都会跑一遍建表语句,重复打开不能出错,也不能重复升版本。
|
||
func TestMigrateIsIdempotent(t *testing.T) {
|
||
path := filepath.Join(t.TempDir(), "cmsp.db")
|
||
|
||
first, err := Open(path)
|
||
if err != nil {
|
||
t.Fatalf("第一次打开失败:%v", err)
|
||
}
|
||
v1, _ := first.Version()
|
||
first.Close()
|
||
|
||
second, err := Open(path)
|
||
if err != nil {
|
||
t.Fatalf("第二次打开失败:%v", err)
|
||
}
|
||
defer second.Close()
|
||
v2, _ := second.Version()
|
||
|
||
if v1 != v2 {
|
||
t.Fatalf("重复打开不应改变版本,第一次 %d,第二次 %d", v1, v2)
|
||
}
|
||
}
|
||
|
||
func sampleProducts() []Product {
|
||
return []Product{
|
||
{
|
||
ID: "1118154275420983296", ItemID: "40583431295",
|
||
ItemName: "短袖連衣裙", MainImage: "https://example.invalid/a.jpg",
|
||
ShopName: "集物生活life", PlatformShopID: "406655548",
|
||
Currency: "TWD", MinSkuPrice: 859, ItemStatus: "NORMAL",
|
||
CreatedAt: "2026-08-10 10:38:51",
|
||
},
|
||
{
|
||
ID: "1118154275420983297", ItemID: "40583429107",
|
||
ItemName: "純棉短袖T恤", MainImage: "https://example.invalid/b.jpg",
|
||
ShopName: "集物生活life", PlatformShopID: "406655548",
|
||
Currency: "TWD", MinSkuPrice: 299, ItemStatus: "NORMAL",
|
||
CreatedAt: "2026-08-11 09:12:00",
|
||
},
|
||
{
|
||
ID: "1118154275420983298", ItemID: "40571188442",
|
||
ItemName: "高腰闊腿牛仔褲", MainImage: "https://example.invalid/c.jpg",
|
||
ShopName: "YUNQISHI", PlatformShopID: "411902773",
|
||
Currency: "TWD", MinSkuPrice: 645, ItemStatus: "UNLIST",
|
||
CreatedAt: "2026-08-12 14:05:00",
|
||
},
|
||
}
|
||
}
|
||
|
||
func TestUpsertAndList(t *testing.T) {
|
||
s := newTestStore(t)
|
||
|
||
if err := s.UpsertProducts(sampleProducts(), "2026-09-02 10:00:00"); err != nil {
|
||
t.Fatalf("写入商品失败:%v", err)
|
||
}
|
||
|
||
page, err := s.ListProducts(ProductQuery{})
|
||
if err != nil {
|
||
t.Fatalf("查询失败:%v", err)
|
||
}
|
||
if page.Total != 3 {
|
||
t.Fatalf("应当有 3 条,实际 %d 条", page.Total)
|
||
}
|
||
// 默认按创建时间倒序,最新的应当排第一。
|
||
if page.Items[0].ItemID != "40571188442" {
|
||
t.Fatalf("应当按创建时间倒序,第一条却是 %s", page.Items[0].ItemID)
|
||
}
|
||
// 新写入的商品,三个处理状态都应当是 pending。
|
||
if page.Items[0].DownloadStatus != DownloadPending {
|
||
t.Fatalf("新商品下载状态应当是 pending,实际 %s", page.Items[0].DownloadStatus)
|
||
}
|
||
}
|
||
|
||
// 这是本包最关键的一条规则:重复同步不能把已有的处理进度清零。
|
||
func TestUpsertKeepsLocalStatus(t *testing.T) {
|
||
s := newTestStore(t)
|
||
items := sampleProducts()
|
||
if err := s.UpsertProducts(items, "2026-09-02 10:00:00"); err != nil {
|
||
t.Fatalf("首次写入失败:%v", err)
|
||
}
|
||
|
||
// 假装已经下载并上传完成了。
|
||
id := items[0].ID
|
||
if err := s.UpdateProductStatus(id, VideoFound, DownloadDone, UploadDone, ""); err != nil {
|
||
t.Fatalf("更新状态失败:%v", err)
|
||
}
|
||
|
||
// 再同步一次,标题在货憨憨那边被改了。
|
||
items[0].ItemName = "改过的标题"
|
||
if err := s.UpsertProducts(items, "2026-09-02 11:00:00"); err != nil {
|
||
t.Fatalf("再次写入失败:%v", err)
|
||
}
|
||
|
||
got, found, err := s.GetProduct(id)
|
||
if err != nil || !found {
|
||
t.Fatalf("读取商品失败:err=%v found=%v", err, found)
|
||
}
|
||
if got.ItemName != "改过的标题" {
|
||
t.Fatalf("来自货憨憨的字段应当被更新,实际 %q", got.ItemName)
|
||
}
|
||
if got.DownloadStatus != DownloadDone {
|
||
t.Fatalf("下载状态不能被同步覆盖,期望 %s,实际 %s", DownloadDone, got.DownloadStatus)
|
||
}
|
||
if got.UploadStatus != UploadDone {
|
||
t.Fatalf("上传状态不能被同步覆盖,期望 %s,实际 %s", UploadDone, got.UploadStatus)
|
||
}
|
||
}
|
||
|
||
func TestListProductsFilters(t *testing.T) {
|
||
s := newTestStore(t)
|
||
if err := s.UpsertProducts(sampleProducts(), "2026-09-02 10:00:00"); err != nil {
|
||
t.Fatalf("写入失败:%v", err)
|
||
}
|
||
|
||
cases := []struct {
|
||
name string
|
||
query ProductQuery
|
||
want int
|
||
}{
|
||
{"按店铺", ProductQuery{PlatformShopID: "406655548"}, 2},
|
||
{"按状态", ProductQuery{ItemStatus: "UNLIST"}, 1},
|
||
{"按单个蝦皮ID", ProductQuery{ItemIDs: "40583431295"}, 1},
|
||
{"按多个蝦皮ID", ProductQuery{ItemIDs: "40583431295,40583429107"}, 2},
|
||
{"中文逗号分隔", ProductQuery{ItemIDs: "40583431295,40583429107"}, 2},
|
||
{"空格分隔", ProductQuery{ItemIDs: "40583431295 40583429107"}, 2},
|
||
{"创建时间起", ProductQuery{CreatedFrom: "2026-08-11"}, 2},
|
||
{"创建时间止", ProductQuery{CreatedTo: "2026-08-11"}, 2},
|
||
{"时间范围", ProductQuery{CreatedFrom: "2026-08-11", CreatedTo: "2026-08-11"}, 1},
|
||
{"条件叠加", ProductQuery{PlatformShopID: "406655548", ItemStatus: "NORMAL"}, 2},
|
||
{"查不到", ProductQuery{PlatformShopID: "不存在的店铺"}, 0},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
page, err := s.ListProducts(tc.query)
|
||
if err != nil {
|
||
t.Fatalf("查询失败:%v", err)
|
||
}
|
||
if page.Total != tc.want {
|
||
t.Fatalf("期望 %d 条,实际 %d 条", tc.want, page.Total)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestListProductsPaging(t *testing.T) {
|
||
s := newTestStore(t)
|
||
if err := s.UpsertProducts(sampleProducts(), "2026-09-02 10:00:00"); err != nil {
|
||
t.Fatalf("写入失败:%v", err)
|
||
}
|
||
|
||
page, err := s.ListProducts(ProductQuery{Page: 2, PageSize: 2})
|
||
if err != nil {
|
||
t.Fatalf("查询失败:%v", err)
|
||
}
|
||
if page.Total != 3 {
|
||
t.Fatalf("总数应当是 3,实际 %d", page.Total)
|
||
}
|
||
if len(page.Items) != 1 {
|
||
t.Fatalf("第 2 页应当只有 1 条,实际 %d 条", len(page.Items))
|
||
}
|
||
}
|
||
|
||
// 没有货憨憨记录 ID 的数据要跳过,不能拿 item_id 顶替。
|
||
func TestUpsertSkipsRowsWithoutID(t *testing.T) {
|
||
s := newTestStore(t)
|
||
err := s.UpsertProducts([]Product{{ItemID: "40583431295"}}, "2026-09-02 10:00:00")
|
||
if err != nil {
|
||
t.Fatalf("不应报错:%v", err)
|
||
}
|
||
|
||
n, err := s.CountProducts()
|
||
if err != nil {
|
||
t.Fatalf("统计失败:%v", err)
|
||
}
|
||
if n != 0 {
|
||
t.Fatalf("缺少 ID 的数据不应写入,实际写入 %d 条", n)
|
||
}
|
||
}
|
||
|
||
func TestGetProductNotFound(t *testing.T) {
|
||
s := newTestStore(t)
|
||
_, found, err := s.GetProduct("不存在")
|
||
if err != nil {
|
||
t.Fatalf("查不到不应报错:%v", err)
|
||
}
|
||
if found {
|
||
t.Fatalf("不应当找到")
|
||
}
|
||
}
|
||
|
||
func TestKV(t *testing.T) {
|
||
s := newTestStore(t)
|
||
|
||
if _, found, err := s.GetKV("token"); err != nil || found {
|
||
t.Fatalf("键不存在时应当返回 false,err=%v found=%v", err, found)
|
||
}
|
||
if err := s.SetKV("token", "第一次的值", "2026-09-02 10:00:00"); err != nil {
|
||
t.Fatalf("写入失败:%v", err)
|
||
}
|
||
if err := s.SetKV("token", "第二次的值", "2026-09-02 11:00:00"); err != nil {
|
||
t.Fatalf("覆盖失败:%v", err)
|
||
}
|
||
|
||
value, found, err := s.GetKV("token")
|
||
if err != nil || !found {
|
||
t.Fatalf("读取失败:err=%v found=%v", err, found)
|
||
}
|
||
if value != "第二次的值" {
|
||
t.Fatalf("应当读到覆盖后的值,实际 %q", value)
|
||
}
|
||
}
|
||
|
||
func TestSplitIDs(t *testing.T) {
|
||
cases := []struct {
|
||
in string
|
||
want int
|
||
}{
|
||
{"", 0},
|
||
{" ", 0},
|
||
{"111", 1},
|
||
{"111,222", 2},
|
||
{"111,222", 2},
|
||
{"111 222 333", 3},
|
||
{"111,,222", 2},
|
||
{"111、222", 2},
|
||
{" 111 , 222 ", 2},
|
||
}
|
||
for _, tc := range cases {
|
||
if got := splitIDs(tc.in); len(got) != tc.want {
|
||
t.Fatalf("输入 %q 期望 %d 个 ID,实际 %d 个:%v", tc.in, tc.want, len(got), got)
|
||
}
|
||
}
|
||
}
|