Files
QiuSWandClaude Opus 5 d34846e26e feat: 店铺列表缓存到 SQLite 并在启动时后台刷新 (#10)
由 Codex (gpt-5.6-sol) 实施,Claude 审核。

以前每次打开商品列表都要现调 erp/shop/all,而这条链路要先完成
货憨憨登录(CID/CST → 验证码 → OCR → 登录 → 验证 token),
任何一环失败下拉框就是空的,连店铺名都看不到。

internal/store/shop.go(新建)
- shops 表在 migrations 末尾追加,未改动任何已有迁移条目
- ReplaceShops 是全量替换:同一事务内先 DELETE 再批量插入。
  只做 upsert 会让货憨憨那边已删除的店铺永远留在下拉框里,
  选中后拉不到任何数据
- 缓存字段是白名单,不含 accessToken、refreshToken、createUser

app.go
- ListShops 改为只读缓存;新增 GetCachedShops、GetShopsUpdatedAt、
  RefreshShops
- startup 里用协程后台刷新,不阻塞窗口显示;且仅在账号密码都已配置时
  才刷新——同事第一次安装还没填账号就启动时,刷新必然失败,
  一开机弹「登录失败」体验很差
- 成功发 shops:refreshed 事件,失败发 shops:refresh-failed 并带上
  失败原因、缓存时间和缓存条数

前端
- 启动先读缓存立即填充下拉框,再由后台刷新更新
- 刷新失败时弹窗同时说明失败原因和缓存时间,只说「更新失败」
  使用者无法判断该不该继续用
- 下拉框旁常驻显示缓存更新时间,并提供手动刷新

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq
2026-09-02 18:00:11 +08:00

111 lines
3.4 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 (
"encoding/json"
"testing"
)
func 测试店铺列表() []Shop {
return []Shop{
{PlatformShopID: "shop-3", ID: "3", ShopName: "朝阳店", Region: "TW", Platform: "0", Status: "NORMAL"},
{PlatformShopID: "shop-1", ID: "1", ShopName: "白云店", Region: "MY", Platform: "0", Status: "NORMAL"},
{PlatformShopID: "shop-2", ID: "2", ShopName: "春风店", Region: "SG", Platform: "0", Status: "NORMAL"},
}
}
func Test建表后版本等于迁移条数(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)
}
}
func Test店铺缓存写入后按名称排序读回(t *testing.T) {
s := newTestStore(t)
if err := s.ReplaceShops(测试店铺列表(), "2026-09-02 17:30:00"); err != nil {
t.Fatalf("写入店铺缓存失败:%v", err)
}
shops, err := s.ListShops()
if err != nil {
t.Fatalf("读取店铺缓存失败:%v", err)
}
if len(shops) != 3 {
t.Fatalf("应读回 3 个店铺,实际为 %d", len(shops))
}
if shops[0].ShopName != "春风店" || shops[1].ShopName != "朝阳店" || shops[2].ShopName != "白云店" {
t.Fatalf("店铺未按名称排序:%v", []string{shops[0].ShopName, shops[1].ShopName, shops[2].ShopName})
}
if shops[0].UpdatedAt != "2026-09-02 17:30:00" {
t.Fatalf("缓存时间应写入每个店铺,实际为 %q", shops[0].UpdatedAt)
}
}
func Test店铺缓存采用全量替换(t *testing.T) {
s := newTestStore(t)
shops := 测试店铺列表()
if err := s.ReplaceShops(shops, "2026-09-02 17:30:00"); err != nil {
t.Fatalf("首次写入 3 个店铺失败:%v", err)
}
if err := s.ReplaceShops(shops[:2], "2026-09-02 17:31:00"); err != nil {
t.Fatalf("用 2 个店铺全量替换失败:%v", err)
}
got, err := s.ListShops()
if err != nil {
t.Fatalf("读取替换后的店铺失败:%v", err)
}
if len(got) != 2 {
t.Fatalf("全量替换后应只剩 2 个店铺,实际为 %d", len(got))
}
for _, shop := range got {
if shop.PlatformShopID == "shop-2" {
t.Fatalf("已从线上消失的第 3 个店铺仍留在缓存中")
}
}
}
func Test店铺缓存时间在无缓存时为空写入后可读(t *testing.T) {
s := newTestStore(t)
updatedAt, err := s.ShopsUpdatedAt()
if err != nil {
t.Fatalf("读取空缓存时间失败:%v", err)
}
if updatedAt != "" {
t.Fatalf("无缓存时更新时间应为空,实际为 %q", updatedAt)
}
const now = "2026-09-02 17:30:00"
if err := s.ReplaceShops(测试店铺列表(), now); err != nil {
t.Fatalf("写入店铺缓存失败:%v", err)
}
updatedAt, err = s.ShopsUpdatedAt()
if err != nil {
t.Fatalf("读取缓存时间失败:%v", err)
}
if updatedAt != now {
t.Fatalf("缓存时间应为 %q,实际为 %q", now, updatedAt)
}
}
func Test店铺缓存JSON不含凭据或手机号字段(t *testing.T) {
raw, err := json.Marshal(Shop{PlatformShopID: "shop-1", ShopName: "测试店铺"})
if err != nil {
t.Fatalf("序列化店铺缓存失败:%v", err)
}
var fields map[string]any
if err := json.Unmarshal(raw, &fields); err != nil {
t.Fatalf("解析店铺缓存 JSON 失败:%v", err)
}
for _, forbidden := range []string{"accessToken", "refreshToken", "createUser"} {
if _, exists := fields[forbidden]; exists {
t.Fatalf("店铺缓存 JSON 不得包含敏感字段 %q:%s", forbidden, raw)
}
}
}