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

102 lines
3.2 KiB
Go

package store
import "fmt"
// Shop 是店铺列表缓存。字段和 shops 表一一对应。
//
// 这里只保留界面展示和后续查询商品需要的白名单字段,不得加入
// accessToken、refreshToken、createUser 等凭据或个人信息。
type Shop struct {
PlatformShopID string `json:"platformShopId"`
ID string `json:"id"`
ShopName string `json:"shopName"`
ShopAlias string `json:"shopAlias"`
Region string `json:"region"`
RegionName string `json:"regionName"`
Platform string `json:"platform"`
Status string `json:"status"`
UpdatedAt string `json:"updatedAt"`
}
// ReplaceShops 用线上店铺列表全量替换本地缓存。
//
// 必须先删后写并放在同一个事务里:店铺在货憨憨被删除或停用后,
// 缓存也要跟着消失。只做 upsert 会让已删除的店铺永远留在下拉框里,
// 使用者选中后却拉不到数据。
func (s *Store) ReplaceShops(shops []Shop, now string) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("开启店铺缓存事务失败:%w", err)
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM shops`); err != nil {
return fmt.Errorf("清空旧店铺缓存失败:%w", err)
}
stmt, err := tx.Prepare(`
INSERT INTO shops (
platform_shop_id, id, shop_name, shop_alias, region,
region_name, platform, status, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return fmt.Errorf("准备写入店铺缓存失败:%w", err)
}
defer stmt.Close()
for _, shop := range shops {
if _, err := stmt.Exec(
shop.PlatformShopID, shop.ID, shop.ShopName, shop.ShopAlias,
shop.Region, shop.RegionName, shop.Platform, shop.Status, now,
); err != nil {
return fmt.Errorf("写入店铺 %s 失败:%w", shop.ShopName, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("提交店铺缓存事务失败:%w", err)
}
return nil
}
// ListShops 按店铺名排序返回本地缓存,不会联网。
func (s *Store) ListShops() ([]Shop, error) {
rows, err := s.db.Query(`
SELECT platform_shop_id, id, shop_name, shop_alias, region,
region_name, platform, status, updated_at
FROM shops
ORDER BY shop_name, platform_shop_id`)
if err != nil {
return nil, fmt.Errorf("读取店铺缓存失败:%w", err)
}
defer rows.Close()
shops := make([]Shop, 0)
for rows.Next() {
var shop Shop
if err := rows.Scan(
&shop.PlatformShopID, &shop.ID, &shop.ShopName, &shop.ShopAlias,
&shop.Region, &shop.RegionName, &shop.Platform, &shop.Status,
&shop.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("读取店铺缓存行失败:%w", err)
}
shops = append(shops, shop)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("遍历店铺缓存失败:%w", err)
}
return shops, nil
}
// ShopsUpdatedAt 返回店铺缓存的更新时间;没有缓存时返回空字符串。
func (s *Store) ShopsUpdatedAt() (string, error) {
var updatedAt string
if err := s.db.QueryRow(
`SELECT COALESCE(MAX(updated_at), '') FROM shops`,
).Scan(&updatedAt); err != nil {
return "", fmt.Errorf("读取店铺缓存时间失败:%w", err)
}
return updatedAt, nil
}