Files
cmautobuy/admin/main.go
T
chengmaandClaude Opus 5 d2de7331bb fix: 修复迁移被原地改写导致老库缺表 (#20)
#16 原地改写了 migration v1 而不是新增一条。Migrate 只在
user_version < 版本数时才跑,老库版本号已越过 v1,改写后的语句
永远不会重跑——程序拿着对不上的库静默启动,点到 PDD 商品页才 500。

修法:v1 逐字恢复成 7ad82b7 的原样,#16 的结构改动全部挪进 v3。
全新库也走 v1→v2→v3,与老库升级跑的是同一份 v3 代码,
不需要维护两条路径。

v3 必须认两种 user_version=2:#16 的原地改写让这个版本号对应
两种不同结构(原始 v1 建的没有 pdd_products,改写后的 v1 建的已经有)。
所以 v3 每一步先查 PRAGMA table_info / sqlite_master 看实际结构
再决定做不做,只有版本号推进是无条件的;已是最终结构的库
只推版本号,日志也照实说,不谎称"新增 pdd_products"。

旧 sku_mappings 数据丢弃并打日志:新主键需要 pdd_option_key,
那是 Go 的 OptionKey() 用 json.Marshal 算的,SQL 复现不了。
硬凑一个键出来,轻则映射静默失效,重则撞上别的规格静默买错东西——
后者正是 #16 存在的全部意义。

collecting 映射成 pending:原样保留会让 MarkCollecting 永远不成功,
那个商品再也建不了采集任务,界面上表现为按钮永远置灰且无法解开。

表重建按 SQLite 官方 12 步顺序:先建 _new 再 RENAME。
实测 ALTER TABLE RENAME TO 会自动重写别的表里指向它的外键子句,
先 RENAME 让位会把 shopee_skus 的外键改成指向一张马上被删的表。

另加两道闸:启动时 CheckSchema 缺表即拒绝启动(不是警告后继续);
admin/AGENTS.md 写死"migrations 只追加、不得修改已发布条目"。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:23:24 +08:00

89 lines
2.3 KiB
Go

// Admin 是本地运行的 Web 管理端:管理蝦皮商品、顺运宝货运单、
// 采购任务和客户端,并给 Client 提供三个接口。
//
// 启动:go run . 然后打开 http://127.0.0.1:8080
// 这个命令是安全的——Admin 只管理数据,不会连手机、不会下单。
//
// 改动前必读 admin/AGENTS.md 和根目录 AGENTS.md 的红线。
package main
import (
"embed"
"flag"
"html/template"
"io/fs"
"log"
"net/http"
"github.com/gin-gonic/gin"
"cmautobuy/admin/config"
"cmautobuy/admin/handler/api"
"cmautobuy/admin/handler/web"
"cmautobuy/admin/repository"
)
// 模板和静态文件打进二进制,这样打包后只有一个 exe,
// 用户不会误删或改坏,见 docs/admin/06-quality-security.md §9。
//
//go:embed templates
var templateFS embed.FS
//go:embed static
var staticFS embed.FS
func main() {
addr := flag.String("addr", "127.0.0.1:8080",
"监听地址。默认只听本机,要给内网用需单独评估安全性")
flag.Parse()
// 1. 数据目录
dataDir, err := config.DataDir()
if err != nil {
log.Fatalf("无法确定 data 目录: %v", err)
}
log.Printf("数据目录: %s", dataDir)
// 2. 数据库
db, err := repository.Open(dataDir)
if err != nil {
log.Fatalf("打开数据库失败: %v", err)
}
defer db.Close()
if err := repository.Migrate(db); err != nil {
log.Fatalf("数据库迁移失败: %v", err)
}
if err := repository.CheckSchema(db); err != nil {
log.Fatalf("%v", err)
}
log.Printf("数据库已就绪")
// 3. Web 引擎
r := gin.Default()
// 模板:每个文件用 {{define "目录/名字"}} 声明自己的名字,
// 所以一次全部解析进来不会冲突。
tmpl, err := template.ParseFS(templateFS, "templates/*/*.html")
if err != nil {
log.Fatalf("解析模板失败: %v", err)
}
r.SetHTMLTemplate(tmpl)
// 静态文件
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
log.Fatalf("准备静态文件失败: %v", err)
}
r.StaticFS("/static", http.FS(staticSub))
// 4. 路由
web.Register(r, db, config.OnlineThreshold) // 给浏览器的页面
api.Register(r, db) // 给 Client 的接口
log.Printf("Admin 已启动: http://%s", *addr)
if err := r.Run(*addr); err != nil {
log.Fatalf("启动失败: %v", err)
}
}