Files
QiuSWandClaude Opus 5 2cefa67001 feat: 初始化 Wails v2 + Vue3 骨架与 SQLite 存储 (#4)
建立可运行、可构建的 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
2026-09-02 15:59:54 +08:00

277 lines
8.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}