负责人反馈「下拉选择缺少视频筛不出数据」,查库发现 5305 条商品的 video_diagnosis 全是 unknown,product_diagnoses 表 0 条。 根因是我中途取消 Codex 造成的:第一版按三态实现时,负责人的 wails dev 正开着,Codex 一存文件就自动重编译重启,把带 DEFAULT 'unknown' 的迁移真正执行进了数据库。随后我回滚了源码, 但数据库已升到 schema_version=10,那几条迁移不会再跑, 于是库里留下当前代码不认识的 unknown。 三处修改: 1. 追加自愈迁移,把非 missing/ok 的历史值统一收敛为 ok。 已在负责人真实数据库上验证:5305 条 unknown 全部变为 ok, 版本升到 11。下次「下载数据」会写入真实诊断值。 2. 去掉「刷新店铺」按钮,改为展开店铺下拉时按需刷新。 带 5 分钟间隔,避免每次点下拉都要等一次货憨憨登录; 自动刷新失败不弹窗,下拉框继续用缓存,旁边的「更新于 …」 会显示旧时间,看得出数据不是最新的。启动时的刷新失败仍然弹窗, 那是需要立刻知道的。 3. 修掉三个绕过 Wails 生成绑定的 window.go.main.App 调用。 Codex 这样写是因为 frontend/wailsjs 在禁改清单里,做法自洽, 但绕过类型包装且会被后来者照抄。已重新生成绑定并改为正常 import。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq
250 lines
8.8 KiB
Go
250 lines
8.8 KiB
Go
// Package store 负责本地 SQLite 数据库。
|
||
//
|
||
// 本项目的所有状态(商品、视频、下载和上传进度、货憨憨登录态)
|
||
// 都保存在这里,它是唯一的事实来源。不要再用 JSON 文件另存一份,
|
||
// 那样两边一定会不一致。
|
||
//
|
||
// 用的是 modernc.org/sqlite —— 纯 Go 实现的 SQLite,不需要 CGO,
|
||
// 也就不需要在机器上装 gcc。换成 mattn/go-sqlite3 会让新同事
|
||
// 第一次编译就卡在编译器上,所以不要换。
|
||
package store
|
||
|
||
import (
|
||
"database/sql"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
|
||
"cmsp/internal/config"
|
||
|
||
_ "modernc.org/sqlite" // 注册名为 "sqlite" 的驱动
|
||
)
|
||
|
||
// Store 是数据库连接。用完要 Close。
|
||
type Store struct {
|
||
db *sql.DB
|
||
}
|
||
|
||
// Open 打开(不存在就创建)数据库文件,并把表结构升级到最新。
|
||
//
|
||
// path 传 ":memory:" 可以开一个只存在于内存里的库,单元测试就用这个。
|
||
func Open(path string) (*Store, error) {
|
||
if path != ":memory:" {
|
||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||
return nil, fmt.Errorf("创建数据库目录失败:%w", err)
|
||
}
|
||
}
|
||
|
||
db, err := sql.Open("sqlite", path)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("打开数据库失败:%w", err)
|
||
}
|
||
|
||
// SQLite 同一时间只允许一个写入者。把连接数限制为 1,
|
||
// 可以避免多个下载协程同时写时报 "database is locked"。
|
||
// 本项目数据量很小,这点性能损失可以忽略。
|
||
db.SetMaxOpenConns(1)
|
||
|
||
if err := db.Ping(); err != nil {
|
||
db.Close()
|
||
return nil, fmt.Errorf("连接数据库失败:%w", err)
|
||
}
|
||
|
||
s := &Store{db: db}
|
||
if err := s.migrate(); err != nil {
|
||
db.Close()
|
||
return nil, err
|
||
}
|
||
return s, nil
|
||
}
|
||
|
||
// Close 关闭数据库。
|
||
func (s *Store) Close() error {
|
||
return s.db.Close()
|
||
}
|
||
|
||
// DB 返回底层连接,只给同一个包里的文件用。
|
||
func (s *Store) DB() *sql.DB { return s.db }
|
||
|
||
// migrations 是按顺序执行的建表和改表语句。
|
||
//
|
||
// 怎么加一次改动(很重要,改错会丢数据):
|
||
// 1. 在切片末尾追加一条,绝不要修改或删除已有的任何一条;
|
||
// 2. 已经发布过的语句改了,老用户的库就升不上来了;
|
||
// 3. 加完记得在 store_test.go 里补一个测试。
|
||
//
|
||
// 每条语句都必须能重复执行而不报错(用 IF NOT EXISTS),
|
||
// 因为程序每次启动都会把它们全跑一遍。
|
||
var migrations = []string{
|
||
// 1. 商品表。一行对应货憨憨里的一个 Shopee 在线商品。
|
||
`CREATE TABLE IF NOT EXISTS products (
|
||
-- 货憨憨内部记录 ID,是后续所有写操作的关联键。
|
||
-- 注意不是 Shopee 商品 ID,两者不同,别搞混。
|
||
id TEXT PRIMARY KEY,
|
||
-- Shopee 商品 ID,界面上显示为「蝦皮ID」。
|
||
item_id TEXT NOT NULL DEFAULT '',
|
||
item_name TEXT NOT NULL DEFAULT '',
|
||
main_image TEXT NOT NULL DEFAULT '',
|
||
shop_name TEXT NOT NULL DEFAULT '',
|
||
platform_shop_id TEXT NOT NULL DEFAULT '',
|
||
currency TEXT NOT NULL DEFAULT '',
|
||
min_sku_price REAL NOT NULL DEFAULT 0,
|
||
item_status TEXT NOT NULL DEFAULT '',
|
||
-- 货憨憨侧的创建时间,原样保存字符串,不做时区换算。
|
||
created_at TEXT NOT NULL DEFAULT '',
|
||
-- 下面几个是本工具自己维护的状态,货憨憨不知道这些。
|
||
-- 取值见 product.go 里的常量。
|
||
video_status TEXT NOT NULL DEFAULT 'pending',
|
||
download_status TEXT NOT NULL DEFAULT 'pending',
|
||
upload_status TEXT NOT NULL DEFAULT 'pending',
|
||
last_error TEXT NOT NULL DEFAULT '',
|
||
-- 本地记录的同步时间,方便排查“这条什么时候拉下来的”。
|
||
synced_at TEXT NOT NULL DEFAULT ''
|
||
)`,
|
||
|
||
// 2. 按店铺查是最常用的操作,加个索引。
|
||
`CREATE INDEX IF NOT EXISTS idx_products_shop
|
||
ON products (platform_shop_id)`,
|
||
|
||
// 3. 视频表。一个商品可能有多个视频,所以单独一张表。
|
||
`CREATE TABLE IF NOT EXISTS videos (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
-- 对应 products.id
|
||
product_id TEXT NOT NULL,
|
||
-- 淘宝同款商品 ID 和视频原始地址,用于排查和去重
|
||
source_item TEXT NOT NULL DEFAULT '',
|
||
source_url TEXT NOT NULL DEFAULT '',
|
||
-- 下载到本地后的文件路径
|
||
local_path TEXT NOT NULL DEFAULT '',
|
||
file_size INTEGER NOT NULL DEFAULT 0,
|
||
-- 上传到货憨憨素材空间后拿到的地址
|
||
remote_url TEXT NOT NULL DEFAULT '',
|
||
status TEXT NOT NULL DEFAULT 'pending',
|
||
last_error TEXT NOT NULL DEFAULT '',
|
||
created_at TEXT NOT NULL DEFAULT ''
|
||
)`,
|
||
|
||
`CREATE INDEX IF NOT EXISTS idx_videos_product
|
||
ON videos (product_id)`,
|
||
|
||
// 4. 键值表。存货憨憨登录态这类零散数据。
|
||
// 存进来的值可能含 token,读写时不要往日志里打。
|
||
`CREATE TABLE IF NOT EXISTS kv (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT NOT NULL DEFAULT '',
|
||
updated_at TEXT NOT NULL DEFAULT ''
|
||
)`,
|
||
|
||
// 6. 店铺缓存。只保存界面需要的白名单字段,不保存 token 或手机号。
|
||
`CREATE TABLE IF NOT EXISTS shops (
|
||
platform_shop_id TEXT PRIMARY KEY,
|
||
id TEXT NOT NULL DEFAULT '',
|
||
shop_name TEXT NOT NULL DEFAULT '',
|
||
shop_alias TEXT NOT NULL DEFAULT '',
|
||
region TEXT NOT NULL DEFAULT '',
|
||
region_name TEXT NOT NULL DEFAULT '',
|
||
platform TEXT NOT NULL DEFAULT '',
|
||
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)`,
|
||
|
||
// 11. 修正历史脏值。
|
||
//
|
||
// 开发期间有一版实现用过三态(missing / ok / unknown),那版迁移
|
||
// 已经在部分机器上执行过,把整表刷成了 unknown。当前实现只认
|
||
// missing 和 ok,unknown 会让「缺少视频」和「有视频」都筛不出东西。
|
||
// 这里把任何非法值统一收敛成 ok,下次「下载数据」写入真实诊断。
|
||
`UPDATE products SET video_diagnosis = 'ok'
|
||
WHERE video_diagnosis NOT IN ('missing', 'ok')`,
|
||
}
|
||
|
||
// migrate 把表结构升级到最新。
|
||
//
|
||
// 用 schema_version 记录已经执行到第几条,这样已经跑过的语句不会重复跑。
|
||
func (s *Store) migrate() error {
|
||
if _, err := s.db.Exec(
|
||
`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`,
|
||
); err != nil {
|
||
return fmt.Errorf("创建版本表失败:%w", err)
|
||
}
|
||
|
||
var current int
|
||
row := s.db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`)
|
||
if err := row.Scan(¤t); err != nil {
|
||
return fmt.Errorf("读取数据库版本失败:%w", err)
|
||
}
|
||
|
||
for i := current; i < len(migrations); i++ {
|
||
if _, err := s.db.Exec(migrations[i]); err != nil {
|
||
// 带上第几条,出问题时能直接定位到 migrations 切片。
|
||
return fmt.Errorf("执行第 %d 条建表语句失败:%w", i+1, err)
|
||
}
|
||
if _, err := s.db.Exec(
|
||
`INSERT INTO schema_version (version) VALUES (?)`, i+1,
|
||
); err != nil {
|
||
return fmt.Errorf("记录数据库版本失败:%w", err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Version 返回当前数据库结构版本,等于已执行的建表语句条数。
|
||
func (s *Store) Version() (int, error) {
|
||
var v int
|
||
row := s.db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`)
|
||
if err := row.Scan(&v); err != nil {
|
||
return 0, fmt.Errorf("读取数据库版本失败:%w", err)
|
||
}
|
||
return v, nil
|
||
}
|
||
|
||
// SetKV 写入一个键值对。已存在就覆盖。
|
||
func (s *Store) SetKV(key, value, now string) error {
|
||
_, err := s.db.Exec(`
|
||
INSERT INTO kv (key, value, updated_at) VALUES (?, ?, ?)
|
||
ON CONFLICT(key) DO UPDATE SET value = excluded.value,
|
||
updated_at = excluded.updated_at`,
|
||
key, value, now)
|
||
if err != nil {
|
||
return fmt.Errorf("写入 kv 失败:%w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// GetKV 读取一个键值对。键不存在时返回空字符串和 false,不当成错误。
|
||
func (s *Store) GetKV(key string) (string, bool, error) {
|
||
var value string
|
||
row := s.db.QueryRow(`SELECT value FROM kv WHERE key = ?`, key)
|
||
switch err := row.Scan(&value); {
|
||
case err == sql.ErrNoRows:
|
||
return "", false, nil
|
||
case err != nil:
|
||
return "", false, fmt.Errorf("读取 kv 失败:%w", err)
|
||
default:
|
||
return value, true, nil
|
||
}
|
||
}
|
||
|
||
// DefaultPath 返回数据库文件的默认位置。
|
||
//
|
||
// 查找顺序和配置文件一致,原因见 internal/config 的 DefaultPath 注释:
|
||
// 开发模式下 exe 在 build\bin\ 里,不能只按 exe 目录算。
|
||
func DefaultPath() string {
|
||
return config.ResolveDataPath("cmsp.db")
|
||
}
|