This commit is contained in:
2026-09-11 09:50:06 +08:00
parent 99263f89e5
commit 5c8c0f6ad7
22 changed files with 242 additions and 339 deletions
+5
View File
@@ -0,0 +1,5 @@
# 运行时数据目录(compose 挂载卷,不进构建上下文,workspace 含 1.3GB APK 上传)
workspace/
data/
cache/
training/
+12 -12
View File
@@ -21,23 +21,23 @@ var AnnotateRecord = &annotateRecordDao{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS annotate_record (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone_num TEXT NOT NULL,
task_id INTEGER NOT NULL,
dataset_id INTEGER NOT NULL DEFAULT 0,
image_id INTEGER NOT NULL,
labels_json TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
submitted_at TEXT,
reviewed_at TEXT,
id BIGSERIAL PRIMARY KEY,
phone_num VARCHAR(20) NOT NULL,
task_id BIGINT NOT NULL,
dataset_id BIGINT NOT NULL DEFAULT 0,
image_id BIGINT NOT NULL,
labels_json JSONB,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at TIMESTAMP NOT NULL,
submitted_at TIMESTAMP,
reviewed_at TIMESTAMP,
UNIQUE (phone_num, image_id)
)`)
if err != nil {
panic(err)
}
// 存量表补列(领取时冗余数据集 id,管理端详情页按数据集过滤记录)
common.EnsureColumn(ctx, consts.TableAnnotateRecord, "dataset_id", "dataset_id INTEGER NOT NULL DEFAULT 0")
common.EnsureColumn(ctx, consts.TableAnnotateRecord, "dataset_id", "dataset_id BIGINT NOT NULL DEFAULT 0")
// 领取锁:同一张图同时只允许一条 pendingpartial unique indexSQLite 原生支持)
if _, err := g.DB().Exec(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_annotate_record_image_pending
ON annotate_record (image_id) WHERE status = 'pending'`); err != nil {
@@ -52,7 +52,7 @@ func (d *annotateRecordDao) InsertInTx(ctx context.Context, tx gdb.TX, m *entity
"task_id": m.TaskId,
"dataset_id": m.DatasetId,
"image_id": m.ImageId,
"labels_json": m.LabelsJson,
"labels_json": common.NilIfEmpty(m.LabelsJson),
"status": m.Status,
"created_at": m.CreatedAt,
}).Insert()
+5 -5
View File
@@ -19,11 +19,11 @@ var AnnotateTask = &annotateTaskDao{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS annotate_task (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dataset_id INTEGER NOT NULL,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'published',
created_at TEXT NOT NULL
id BIGSERIAL PRIMARY KEY,
dataset_id BIGINT NOT NULL,
name VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'published',
created_at TIMESTAMP NOT NULL
)`)
if err != nil {
panic(err)
+8 -16
View File
@@ -4,7 +4,6 @@ import (
"context"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
"observer-server/biz/consts"
"observer-server/biz/model/entity"
@@ -20,29 +19,22 @@ var AppVersion = &appVersionDao{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS app_version (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version TEXT NOT NULL UNIQUE,
id BIGSERIAL PRIMARY KEY,
version VARCHAR(32) NOT NULL UNIQUE,
notes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)`)
if err != nil {
panic(err)
}
// v6 迁移:删除 url 列(下载地址改为固定文件 app.apkDir/observer-latest.apk
// 表内不再记录;新库建表已无此列直接跳过)
cols, err := g.DB().GetAll(ctx, "PRAGMA table_info(app_version)")
if err != nil {
panic(err)
}
for _, col := range cols {
if gconv.String(col["name"]) == "url" {
if _, err := g.DB().Exec(ctx, "ALTER TABLE app_version DROP COLUMN url"); err != nil {
panic(err)
}
g.Log().Warningf(ctx, "存量表 app_version 已迁移:删除 url 列")
break
if common.HasColumn(ctx, "app_version", "url") {
if _, err := g.DB().Exec(ctx, "ALTER TABLE app_version DROP COLUMN url"); err != nil {
panic(err)
}
g.Log().Warningf(ctx, "存量表 app_version 已迁移:删除 url 列")
}
}
+23 -23
View File
@@ -22,42 +22,42 @@ var Dataset = &datasetDao{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS dataset (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
source TEXT NOT NULL DEFAULT 'manual',
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
source VARCHAR(10) NOT NULL DEFAULT 'manual',
image_count INTEGER NOT NULL DEFAULT 0,
labeled_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'building',
cover TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'building',
cover VARCHAR(200),
description TEXT,
name_prefix TEXT NOT NULL DEFAULT '',
name_prefix VARCHAR(50) NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
gen_species TEXT,
gen_tone TEXT,
gen_heights TEXT,
gen_scenes TEXT,
gen_actions TEXT,
gen_occlusions TEXT,
gen_classes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
gen_species VARCHAR(100),
gen_tone VARCHAR(50),
gen_heights VARCHAR(100),
gen_scenes JSONB,
gen_actions JSONB,
gen_occlusions JSONB,
gen_classes VARCHAR(200),
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)`)
if err != nil {
panic(err)
}
// 存量库迁移:生成图文件名前缀列(EnsureColumn 的 ddl 须自带列名)
common.EnsureColumn(ctx, consts.TableDataset, "name_prefix", "name_prefix TEXT NOT NULL DEFAULT ''")
common.EnsureColumn(ctx, consts.TableDataset, "name_prefix", "name_prefix VARCHAR(50) NOT NULL DEFAULT ''")
// 存量库迁移:序号列(列表排序主键,升序;同号按创建时间倒序)
common.EnsureColumn(ctx, consts.TableDataset, "sort_order", "sort_order INTEGER NOT NULL DEFAULT 0")
// 存量库迁移:生成参数池列(VLM 自动生成,2026-08-28
for _, c := range []struct{ name, ddl string }{
{"gen_species", "gen_species TEXT"},
{"gen_tone", "gen_tone TEXT"},
{"gen_heights", "gen_heights TEXT"},
{"gen_scenes", "gen_scenes TEXT"},
{"gen_actions", "gen_actions TEXT"},
{"gen_occlusions", "gen_occlusions TEXT"},
{"gen_classes", "gen_classes TEXT"},
{"gen_species", "gen_species VARCHAR(100)"},
{"gen_tone", "gen_tone VARCHAR(50)"},
{"gen_heights", "gen_heights VARCHAR(100)"},
{"gen_scenes", "gen_scenes JSONB"},
{"gen_actions", "gen_actions JSONB"},
{"gen_occlusions", "gen_occlusions JSONB"},
{"gen_classes", "gen_classes VARCHAR(200)"},
} {
common.EnsureColumn(ctx, consts.TableDataset, c.name, c.ddl)
}
+22 -27
View File
@@ -4,7 +4,6 @@ import (
"context"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
"observer-server/biz/consts"
"observer-server/biz/model/entity"
@@ -19,13 +18,13 @@ var DatasetImage = &datasetImageDao{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS dataset_image (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dataset_id INTEGER NOT NULL,
filename TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'manual',
id BIGSERIAL PRIMARY KEY,
dataset_id BIGINT NOT NULL,
filename VARCHAR(255) NOT NULL,
source VARCHAR(10) NOT NULL DEFAULT 'manual',
animal_count INTEGER NOT NULL DEFAULT 0,
labels_json TEXT,
created_at TEXT NOT NULL,
labels_json JSONB,
created_at TIMESTAMP NOT NULL,
UNIQUE (dataset_id, filename)
)`)
if err != nil {
@@ -33,32 +32,25 @@ func init() {
}
// 存量库迁移:标注列缺失时追加(新库建表已含列,幂等跳过);candidates_json 已随 v10 删除
// 注意 EnsureColumn 的 ddl 参数须自带列名(拼接为 ALTER TABLE ADD COLUMN <ddl>
common.EnsureColumn(ctx, consts.TableDatasetImage, "labels_json", "labels_json TEXT")
common.EnsureColumn(ctx, consts.TableDatasetImage, "labels_json", "labels_json JSONB")
common.EnsureColumn(ctx, consts.TableDatasetImage, "animal_count", "animal_count INTEGER NOT NULL DEFAULT 0")
common.EnsureColumn(ctx, consts.TableDatasetImage, "clean_excluded", "clean_excluded INTEGER NOT NULL DEFAULT 0")
common.EnsureColumn(ctx, consts.TableDatasetImage, "review_status", "review_status INTEGER NOT NULL DEFAULT 0")
common.EnsureColumn(ctx, consts.TableDatasetImage, "clean_excluded", "clean_excluded SMALLINT NOT NULL DEFAULT 0")
common.EnsureColumn(ctx, consts.TableDatasetImage, "review_status", "review_status SMALLINT NOT NULL DEFAULT 0")
// 存量库迁移:众包任务图片粒度下发占用标记(2026-09-07,0=未下发)
common.EnsureColumn(ctx, consts.TableDatasetImage, "annotate_task_id", "annotate_task_id INTEGER NOT NULL DEFAULT 0")
common.EnsureColumn(ctx, consts.TableDatasetImage, "annotate_task_id", "annotate_task_id BIGINT NOT NULL DEFAULT 0")
// 存量迁移(幂等):加列后既有标注统一视为已审定稿(非空框→2);上线后不变式
// review_status=0 ⟹ labels_json 无框 恒成立(拒绝清框、提交/预标置 1、审核置 2),
// 故本 UPDATE 只会命中存量行,不会误改新数据
if _, err := g.DB().Exec(ctx, `UPDATE dataset_image SET review_status = 2
WHERE review_status = 0 AND labels_json IS NOT NULL AND labels_json != '' AND labels_json != '[]'`); err != nil {
WHERE review_status = 0 AND labels_json IS NOT NULL AND labels_json != '[]'::jsonb`); err != nil {
panic(err)
}
// 存量库迁移:删除 prompt 列(生成提示词不再存储,2026-09-02 决策)
cols, err := g.DB().GetAll(ctx, "PRAGMA table_info(dataset_image)")
if err != nil {
panic(err)
}
for _, col := range cols {
if gconv.String(col["name"]) == "prompt" {
if _, err := g.DB().Exec(ctx, "ALTER TABLE dataset_image DROP COLUMN prompt"); err != nil {
panic(err)
}
g.Log().Warningf(ctx, "存量表 dataset_image 已迁移:删除 prompt 列")
break
if common.HasColumn(ctx, "dataset_image", "prompt") {
if _, err := g.DB().Exec(ctx, "ALTER TABLE dataset_image DROP COLUMN prompt"); err != nil {
panic(err)
}
g.Log().Warningf(ctx, "存量表 dataset_image 已迁移:删除 prompt 列")
}
}
@@ -89,12 +81,15 @@ func (d *datasetImageDao) GetById(ctx context.Context, id int64) (*entity.Datase
// ListByDataset 某数据集全部图片(按文件名编号倒序,详情页逐行展示用)。
// 文件名形如 <前缀>_<编号>.jpg(无前缀为时间戳数字名),须提取下划线后的数字按数值倒序:
// 字符串序会把 pheasant_101.jpg 排在 pheasant_2.jpg 之前('1'<'9'),导致最大编号落到底部
// 字符串序会把 pheasant_101.jpg 排在 pheasant_2.jpg 之前('1'<'9'),导致最大编号落到底部
// pg 方言:regexp_match 提取「_ 后到扩展名前的数字段」,无下划线回退取前导数字
// (对齐 SQLite CAST 的前导数字解析语义);无法提取时 NULLS LAST 排底(对齐 SQLite CAST 非
// 数字返回 0 的排序效果)
func (d *datasetImageDao) ListByDataset(ctx context.Context, datasetId int64) ([]*entity.DatasetImage, error) {
var list []*entity.DatasetImage
err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).
Where("dataset_id", datasetId).
Order("CAST(substr(filename, instr(filename, '_') + 1) AS INTEGER) DESC").Scan(&list)
Order(`COALESCE((regexp_match(filename, '_([0-9]+)\.'))[1], (regexp_match(filename, '^([0-9]+)'))[1])::BIGINT DESC NULLS LAST`).Scan(&list)
if err != nil {
if common.IsNoRows(err) {
return []*entity.DatasetImage{}, nil
@@ -219,7 +214,7 @@ func (d *datasetImageDao) CountByDataset(ctx context.Context, datasetId int64) (
// UpdateLabels 覆写标注 JSON(整图粒度;空串=清空标注;AI 自动标注与人工保存共用)
func (d *datasetImageDao) UpdateLabels(ctx context.Context, id int64, labelsJson string) error {
_, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).Where("id", id).
Data(g.Map{"labels_json": labelsJson}).Update()
Data(g.Map{"labels_json": common.NilIfEmpty(labelsJson)}).Update()
return err
}
@@ -227,7 +222,7 @@ func (d *datasetImageDao) UpdateLabels(ctx context.Context, id int64, labelsJson
// 预标检出/人工保存/App 提交 → 1;人工清空保存 → 0(回到未标注池)
func (d *datasetImageDao) UpdateLabelsAndReview(ctx context.Context, id int64, labelsJson string, reviewStatus int) error {
_, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).Where("id", id).
Data(g.Map{"labels_json": labelsJson, "review_status": reviewStatus}).Update()
Data(g.Map{"labels_json": common.NilIfEmpty(labelsJson), "review_status": reviewStatus}).Update()
return err
}
+12 -12
View File
@@ -20,23 +20,23 @@ var FalseTargetReport = &falseTargetReportDao{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS false_target_report (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone_num TEXT NOT NULL,
file TEXT NOT NULL,
labels_json TEXT NOT NULL DEFAULT '',
species TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending', -- pending/approved(拒绝即删记录,无 rejected 存量)
image_hash INTEGER NOT NULL DEFAULT 0, -- 整帧 dHash 64 位(上报查重;0=未算/存量回填前)
suspect_real INTEGER NOT NULL DEFAULT 0, -- RF-DETR 高分检出疑似真目标预判标记(0/1)
created_at TEXT NOT NULL,
reviewed_at TEXT
id BIGSERIAL PRIMARY KEY,
phone_num VARCHAR(20) NOT NULL,
file VARCHAR(255) NOT NULL,
labels_json JSONB,
species VARCHAR(50) NOT NULL DEFAULT '',
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending/approved(拒绝即删记录,无 rejected 存量)
image_hash BIGINT NOT NULL DEFAULT 0, -- 整帧 dHash 64 位(上报查重;0=未算/存量回填前)
suspect_real SMALLINT NOT NULL DEFAULT 0, -- RF-DETR 高分检出疑似真目标预判标记(0/1)
created_at TIMESTAMP NOT NULL,
reviewed_at TIMESTAMP
)`)
if err != nil {
panic(err)
}
// 存量库迁移:查重/预判两列(2026-09-09;新库建表已含列,幂等跳过)
common.EnsureColumn(ctx, consts.TableFalseTargetReport, "image_hash", "image_hash INTEGER NOT NULL DEFAULT 0")
common.EnsureColumn(ctx, consts.TableFalseTargetReport, "suspect_real", "suspect_real INTEGER NOT NULL DEFAULT 0")
common.EnsureColumn(ctx, consts.TableFalseTargetReport, "image_hash", "image_hash BIGINT NOT NULL DEFAULT 0")
common.EnsureColumn(ctx, consts.TableFalseTargetReport, "suspect_real", "suspect_real SMALLINT NOT NULL DEFAULT 0")
}
// Insert 插入上报记录并返回自增 id
+5 -5
View File
@@ -19,15 +19,15 @@ var GenTask = &genTaskDao{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS gen_task (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dataset_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'running',
id BIGSERIAL PRIMARY KEY,
dataset_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'running',
total INTEGER NOT NULL DEFAULT 0,
done INTEGER NOT NULL DEFAULT 0,
rejected INTEGER NOT NULL DEFAULT 0,
error TEXT,
created_at TEXT NOT NULL,
finished_at TEXT
created_at TIMESTAMP NOT NULL,
finished_at TIMESTAMP
)`)
if err != nil {
panic(err)
+7 -7
View File
@@ -19,15 +19,15 @@ var LabelTask = &labelTaskDao{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS label_task (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dataset_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'running',
id BIGSERIAL PRIMARY KEY,
dataset_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'running',
total INTEGER NOT NULL DEFAULT 0,
done INTEGER NOT NULL DEFAULT 0,
filenames TEXT,
filenames JSONB,
error TEXT,
created_at TEXT NOT NULL,
finished_at TEXT
created_at TIMESTAMP NOT NULL,
finished_at TIMESTAMP
)`)
if err != nil {
panic(err)
@@ -41,7 +41,7 @@ func (d *labelTaskDao) Insert(ctx context.Context, m *entity.LabelTask) (int64,
"status": m.Status,
"total": m.Total,
"done": m.Done,
"filenames": m.Filenames,
"filenames": common.NilIfEmpty(m.Filenames),
"error": m.Error,
"created_at": m.CreatedAt,
"finished_at": m.FinishedAt,
+7 -7
View File
@@ -21,20 +21,20 @@ func init() {
ctx := context.Background()
common.DropLegacyTableIfHasColumn(ctx, consts.TableLicense, "device_id")
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS license (
phone_num TEXT PRIMARY KEY,
password TEXT NOT NULL,
expires_at TEXT,
phone_num VARCHAR(20) PRIMARY KEY,
password VARCHAR(100) NOT NULL,
expires_at TIMESTAMP,
remark TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)`)
if err != nil {
panic(err)
}
common.EnsureColumn(ctx, consts.TableLicense, "remark", "remark TEXT")
// 标注众包(2026-09-04):冻结到期标记 + 通过比例统计基线(技术设计.md「App 标注众包与时长激励」)
common.EnsureColumn(ctx, consts.TableLicense, "annotate_frozen_until", "annotate_frozen_until TEXT")
common.EnsureColumn(ctx, consts.TableLicense, "annotate_stats_since", "annotate_stats_since TEXT")
common.EnsureColumn(ctx, consts.TableLicense, "annotate_frozen_until", "annotate_frozen_until TIMESTAMP")
common.EnsureColumn(ctx, consts.TableLicense, "annotate_stats_since", "annotate_stats_since TIMESTAMP")
}
// GetByPhone 按手机号查询(走缓存,键含手机号)
+14 -12
View File
@@ -21,24 +21,26 @@ var Training = &modelTrainingDao{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS model_training (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'running',
variant TEXT NOT NULL DEFAULT 's',
dataset_id INTEGER NOT NULL,
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'running',
variant VARCHAR(5) NOT NULL DEFAULT 's',
dataset_id BIGINT NOT NULL,
kind VARCHAR(20) NOT NULL DEFAULT 'species',
dataset_ids JSONB,
imgsz INTEGER NOT NULL DEFAULT 1280,
epochs INTEGER NOT NULL DEFAULT 150,
batch INTEGER NOT NULL DEFAULT 16,
device TEXT NOT NULL DEFAULT '0',
device VARCHAR(20) NOT NULL DEFAULT '0',
current_epoch INTEGER NOT NULL DEFAULT 0,
total_epochs INTEGER NOT NULL DEFAULT 0,
metrics TEXT,
metrics JSONB,
log_tail TEXT,
pid INTEGER,
error TEXT,
started_at TEXT NOT NULL,
finished_at TEXT,
created_at TEXT NOT NULL
started_at TIMESTAMP NOT NULL,
finished_at TIMESTAMP,
created_at TIMESTAMP NOT NULL
)`)
if err != nil {
panic(err)
@@ -53,14 +55,14 @@ func (d *modelTrainingDao) Insert(ctx context.Context, m *entity.ModelTraining)
"variant": m.Variant,
"dataset_id": m.DatasetId,
"kind": m.Kind,
"dataset_ids": m.DatasetIds,
"dataset_ids": common.NilIfEmpty(m.DatasetIds),
"imgsz": m.Imgsz,
"epochs": m.Epochs,
"batch": m.Batch,
"device": m.Device,
"current_epoch": m.CurrentEpoch,
"total_epochs": m.TotalEpochs,
"metrics": m.Metrics,
"metrics": common.NilIfEmpty(m.Metrics),
"log_tail": m.LogTail,
"pid": m.Pid,
"error": m.Error,
+15 -13
View File
@@ -18,18 +18,20 @@ var ModelVersion = &modelVersionDao{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS model_version (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dataset_id INTEGER NOT NULL,
variant TEXT NOT NULL DEFAULT 's',
version TEXT NOT NULL,
training_id INTEGER,
metrics TEXT,
labels TEXT NOT NULL,
sha256 TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
is_latest INTEGER NOT NULL DEFAULT 0,
id BIGSERIAL PRIMARY KEY,
dataset_id BIGINT NOT NULL,
variant VARCHAR(5) NOT NULL DEFAULT 's',
version VARCHAR(50) NOT NULL,
training_id BIGINT,
kind VARCHAR(20) NOT NULL DEFAULT 'species',
dataset_ids JSONB,
metrics JSONB,
labels JSONB NOT NULL,
sha256 VARCHAR(64) NOT NULL,
size_bytes BIGINT NOT NULL,
is_latest SMALLINT NOT NULL DEFAULT 0,
notes TEXT,
created_at TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
UNIQUE (dataset_id, version)
)`)
if err != nil {
@@ -42,11 +44,11 @@ func (d *modelVersionDao) Insert(ctx context.Context, m *entity.ModelVersion) (i
res, err := g.DB().Model(consts.TableModelVersion).Ctx(ctx).Data(g.Map{
"dataset_id": m.DatasetId,
"kind": m.Kind,
"dataset_ids": m.DatasetIds,
"dataset_ids": common.NilIfEmpty(m.DatasetIds),
"variant": m.Variant,
"version": m.Version,
"training_id": m.TrainingId,
"metrics": m.Metrics,
"metrics": common.NilIfEmpty(m.Metrics),
"labels": m.Labels,
"sha256": m.Sha256,
"size_bytes": m.SizeBytes,
+9 -9
View File
@@ -22,16 +22,16 @@ func init() {
ctx := context.Background()
common.DropLegacyTableIfHasColumn(ctx, consts.TablePaymentOrder, "device_id")
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS payment_order (
order_id TEXT PRIMARY KEY,
phone_num TEXT NOT NULL,
plan_id TEXT NOT NULL,
channel TEXT NOT NULL,
order_id VARCHAR(64) PRIMARY KEY,
phone_num VARCHAR(20) NOT NULL,
plan_id VARCHAR(20) NOT NULL,
channel VARCHAR(20) NOT NULL,
amount_cents INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'created',
wx_trade_no TEXT UNIQUE,
alipay_trade_no TEXT UNIQUE,
created_at TEXT NOT NULL,
paid_at TEXT
status VARCHAR(20) NOT NULL DEFAULT 'created',
wx_trade_no VARCHAR(64) UNIQUE,
alipay_trade_no VARCHAR(64) UNIQUE,
created_at TIMESTAMP NOT NULL,
paid_at TIMESTAMP
)`)
if err != nil {
panic(err)
+4 -4
View File
@@ -18,11 +18,11 @@ var RewardLog = &rewardLogDao{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS reward_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone_num TEXT NOT NULL,
id BIGSERIAL PRIMARY KEY,
phone_num VARCHAR(20) NOT NULL,
minutes INTEGER NOT NULL,
task_id INTEGER NOT NULL DEFAULT 0,
granted_at TEXT NOT NULL
task_id BIGINT NOT NULL DEFAULT 0,
granted_at TIMESTAMP NOT NULL
)`)
if err != nil {
panic(err)
+26 -41
View File
@@ -4,14 +4,10 @@ import (
"context"
"database/sql"
"errors"
"os"
"path/filepath"
"regexp"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
// IsNoRows 判断 Scan 空结果(结构体 Scan 无行时返回 sql.ErrNoRows),
@@ -52,17 +48,30 @@ func ClearCache(ctx context.Context, keys ...string) {
}
}
// EnsureColumn 存量库迁移:列缺失时 ALTER TABLE ADD COLUMNSQLite 支持表尾追加)。
// 新库由 CREATE TABLE 直接含列、已迁移库列已存在,均跳过;失败 panic(启动即暴露)。
func EnsureColumn(ctx context.Context, table, column, ddl string) {
res, err := g.DB().GetAll(ctx, "PRAGMA table_info("+table+")")
// NilIfEmpty 空串转 nilJSONB/TIMESTAMP 列拒收空串,dao 写入可能为空的此类列时经此转换落 NULL;
// 读回 NULL 时 gf 转 string 为空串,业务侧 =="" 判断不受影响)。
func NilIfEmpty(s string) any {
if s == "" {
return nil
}
return s
}
// HasColumn 判断当前 schema 下表是否存在某列(迁移逻辑用,information_schema 通用)。
func HasColumn(ctx context.Context, table, column string) bool {
res, err := g.DB().GetOne(ctx, `SELECT COUNT(1) AS n FROM information_schema.columns
WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?`, table, column)
if err != nil {
panic("检查表结构失败 " + table + ": " + err.Error())
}
for _, r := range res {
if gconv.String(r["name"]) == column {
return
}
return res != nil && res["n"].Int() > 0
}
// EnsureColumn 存量库迁移:列缺失时 ALTER TABLE ADD COLUMN(表尾追加)。
// 新库由 CREATE TABLE 直接含列、已迁移库列已存在,均跳过;失败 panic(启动即暴露)。
func EnsureColumn(ctx context.Context, table, column, ddl string) {
if HasColumn(ctx, table, column) {
return
}
if _, err := g.DB().Exec(ctx, "ALTER TABLE "+table+" ADD COLUMN "+ddl); err != nil {
panic("迁移加列失败 " + table + "." + column + ": " + err.Error())
@@ -73,35 +82,11 @@ func EnsureColumn(ctx context.Context, table, column, ddl string) {
// DropLegacyTableIfHasColumn 存量库迁移:表存在旧版本废弃列(账号体系上线前的 device_id)
// 时 DROP 整表(存量数据作废,用户决策),由 dao init 以新结构重建。
func DropLegacyTableIfHasColumn(ctx context.Context, table, column string) {
res, err := g.DB().GetAll(ctx, "PRAGMA table_info("+table+")")
if err != nil {
panic("检查表结构失败 " + table + ": " + err.Error())
if !HasColumn(ctx, table, column) {
return
}
for _, r := range res {
if gconv.String(r["name"]) == column {
if _, err := g.DB().Exec(ctx, "DROP TABLE "+table); err != nil {
panic("DROP 旧表失败 " + table + ": " + err.Error())
}
g.Log().Warningf(ctx, "存量表 %s 为旧结构(列 %s),已 DROP 由新结构重建(存量数据作废)", table, column)
return
}
}
}
var fileLinkRegexp = regexp.MustCompile(`@file\(([^)]+)\)`)
// init 确保 SQLite 数据文件所在目录存在(驱动打开文件前必须已创建,否则报 unable to open)。
// 必须在任何 g.DB() 调用(含 dao 包 init 建表)之前执行。
func init() {
ctx := context.Background()
link := g.Cfg().MustGet(ctx, "database.default.link", "").String()
file := link
if m := fileLinkRegexp.FindStringSubmatch(link); len(m) == 2 {
file = m[1]
}
if dir := filepath.Dir(file); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
panic("创建数据目录失败: " + err.Error())
}
if _, err := g.DB().Exec(ctx, "DROP TABLE "+table); err != nil {
panic("DROP 旧表失败 " + table + ": " + err.Error())
}
g.Log().Warningf(ctx, "存量表 %s 为旧结构(列 %s),已 DROP 由新结构重建(存量数据作废)", table, column)
}
+3
View File
@@ -0,0 +1,3 @@
package common
import _ "github.com/gogf/gf/contrib/drivers/pgsql/v2"
-5
View File
@@ -1,5 +0,0 @@
package common
// 全局统一注册 SQLite 驱动(main 与服务层白盒测试共用同一注册点,
// 测试二进制不编译 main.go,驱动须在 common 层保证加载)
import _ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
+7 -3
View File
@@ -7,9 +7,13 @@ server:
database:
default:
type: sqlite
# SQLite 数据文件(运行时数据,不提交 git)
link: "sqlite::@file(./data/observer.db)"
type: pgsql
# PostgreSQL 连接(本机 Docker pg16 容器;分字段写法规避密码特殊字符的 URL 转义问题)
host: "127.0.0.1"
port: "5432"
user: "259278618@qq.com"
pass: "Tongli686^*^"
name: "animal_spot"
cache:
# 查询缓存 TTL(秒)
ttl: 30
+1 -1
View File
@@ -1,5 +1,5 @@
# 视野后端单机部署:前后端一体单端口,./data(数据库)与 ./workspace(APK 上传)挂载持久化(容器重建不丢数据)
name: redfuture-app
name: redfuture
networks:
redfuture-net:
+2
View File
@@ -3,6 +3,7 @@ module observer-server
go 1.26.1
require (
github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.2
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2
github.com/gogf/gf/v2 v2.10.2
github.com/smartwalle/alipay/v3 v3.2.31
@@ -24,6 +25,7 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/grokify/html-strip-tags-go v0.1.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
+4
View File
@@ -22,6 +22,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.2 h1:u8EpP24GkprogROnJ7htMov9Fc66pTP1eVYrWxiCYOs=
github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.2/go.mod h1:GmvM3r8GVByVMi4RD2+MCs5+CfxVXPMeT8mVDkAaAXE=
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2 h1:KLS68SWS2W749x7e+eCCOO3UD2Sbw+bIbLEPR8o1FXw=
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2/go.mod h1:uLcsu73PfpyhRc0Jq0gGAWQjN1tyGU9iBRrYgt/lu7g=
github.com/gogf/gf/v2 v2.10.2 h1:46IO0Uc8e85/FqdftJFskfDejJLBL0JBnGS5qOftUu8=
@@ -40,6 +42,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+51 -137
View File
@@ -6,7 +6,6 @@ import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/util/gconv"
"observer-server/biz/controller"
"observer-server/biz/service"
@@ -71,54 +70,30 @@ func main() {
s.Run()
}
// initDatabase 数据初始化:存量库以 PRAGMA user_version 版本化迁移;
// initDatabase 数据初始化:存量库以 schema_version 版本化迁移pg 无 PRAGMA,版本落表)
// v2 = 删 license.plan_id 列(无业务语义);v3 = 删 plan 表(套餐已配置化,清残留表)。
func initDatabase(ctx context.Context) {
res, err := g.DB().GetAll(ctx, "PRAGMA user_version")
if err != nil {
g.Log().Fatalf(ctx, "读取库版本失败: %+v", err)
}
version := 0
if len(res) > 0 {
version = gconv.Int(res[0]["user_version"])
}
version := dbVersion(ctx)
if version < 2 {
// v2:删除 license.plan_id(只留到期时间;PRAGMA table_info 检测列存在才 DROP
// v2:删除 license.plan_id(只留到期时间;HasColumn 检测列存在才 DROP
// 全新库建表已无此列、直接跳过,不丢账号数据)
cols, err := g.DB().GetAll(ctx, "PRAGMA table_info(license)")
if err != nil {
g.Log().Fatalf(ctx, "读取 license 表结构失败: %+v", err)
}
hasPlanId := false
for _, col := range cols {
if gconv.String(col["name"]) == "plan_id" {
hasPlanId = true
break
}
}
if hasPlanId {
if common.HasColumn(ctx, "license", "plan_id") {
if _, err := g.DB().Exec(ctx, "ALTER TABLE license DROP COLUMN plan_id"); err != nil {
g.Log().Fatalf(ctx, "删除 license.plan_id 列失败: %+v", err)
}
}
if _, err := g.DB().Exec(ctx, "PRAGMA user_version = 2"); err != nil {
g.Log().Fatalf(ctx, "库版本写入失败: %+v", err)
}
g.Log().Infof(ctx, "数据库初始化完成(version=2)")
setDbVersion(ctx, 2)
}
if version < 3 {
// v3:套餐改 config.yml 配置后清理残留 plan 表(订单快照 payment_order.plan_id 不受影响)
if _, err := g.DB().Exec(ctx, "DROP TABLE IF EXISTS plan"); err != nil {
g.Log().Fatalf(ctx, "删除残留 plan 表失败: %+v", err)
}
if _, err := g.DB().Exec(ctx, "PRAGMA user_version = 3"); err != nil {
g.Log().Fatalf(ctx, "库版本写入失败: %+v", err)
}
g.Log().Infof(ctx, "数据库初始化完成(version=3)")
setDbVersion(ctx, 3)
}
if version < 8 {
// v8dataset 加训练配置/展示列 + label_task 加 filenames(多选批量标注)。
// PRAGMA table_info 逐列检测缺失才 ADD COLUMN,全新库建表自带全列直接跳过
// HasColumn 逐列检测缺失才 ADD COLUMN,全新库建表自带全列直接跳过
addCols := []struct{ table, col, ddl string }{
{"dataset", "cover", "TEXT"},
{"dataset", "description", "TEXT"},
@@ -128,85 +103,43 @@ func initDatabase(ctx context.Context) {
{"dataset", "train_user", "TEXT"},
{"dataset", "train_password", "TEXT"},
{"dataset", "train_key", "TEXT"},
{"label_task", "filenames", "TEXT"},
{"label_task", "filenames", "JSONB"},
}
for _, c := range addCols {
cols, err := g.DB().GetAll(ctx, "PRAGMA table_info("+c.table+")")
if err != nil {
g.Log().Fatalf(ctx, "读取 %s 表结构失败: %+v", c.table, err)
}
exists := false
for _, col := range cols {
if gconv.String(col["name"]) == c.col {
exists = true
break
}
}
if exists {
if common.HasColumn(ctx, c.table, c.col) {
continue
}
if _, err := g.DB().Exec(ctx, "ALTER TABLE "+c.table+" ADD COLUMN "+c.col+" "+c.ddl); err != nil {
g.Log().Fatalf(ctx, "给 %s 加列 %s 失败: %+v", c.table, c.col, err)
}
}
if _, err := g.DB().Exec(ctx, "PRAGMA user_version = 8"); err != nil {
g.Log().Fatalf(ctx, "库版本写入失败: %+v", err)
}
g.Log().Infof(ctx, "数据库初始化完成(version=8)")
setDbVersion(ctx, 8)
}
if version < 10 {
// v10:标注流程简化,撤销候选确认两阶段 —— dataset_image 删 candidates_json 列
//(存量候选数据为空直接删;全新库建表已无此列、PRAGMA table_info 检测后跳过)
cols, err := g.DB().GetAll(ctx, "PRAGMA table_info(dataset_image)")
if err != nil {
g.Log().Fatalf(ctx, "读取 dataset_image 表结构失败: %+v", err)
}
hasCandidates := false
for _, col := range cols {
if gconv.String(col["name"]) == "candidates_json" {
hasCandidates = true
break
}
}
if hasCandidates {
//(存量候选数据为空直接删;全新库建表已无此列、HasColumn 检测后跳过)
if common.HasColumn(ctx, "dataset_image", "candidates_json") {
if _, err := g.DB().Exec(ctx, "ALTER TABLE dataset_image DROP COLUMN candidates_json"); err != nil {
g.Log().Fatalf(ctx, "删除 candidates_json 列失败: %+v", err)
}
}
if _, err := g.DB().Exec(ctx, "PRAGMA user_version = 10"); err != nil {
g.Log().Fatalf(ctx, "库版本写入失败: %+v", err)
}
g.Log().Infof(ctx, "数据库初始化完成(version=10)")
setDbVersion(ctx, 10)
}
if version < 11 {
// v11:移除模型存档回退机制 —— model_version 删 model_file 列
//(模型文件不落表:发布即写 latest.tflite,客户端固定下载;存量库删列、新库建表已无此列自动跳过)
cols, err := g.DB().GetAll(ctx, "PRAGMA table_info(model_version)")
if err != nil {
g.Log().Fatalf(ctx, "读取 model_version 表结构失败: %+v", err)
}
hasModelFile := false
for _, col := range cols {
if gconv.String(col["name"]) == "model_file" {
hasModelFile = true
break
}
}
if hasModelFile {
if common.HasColumn(ctx, "model_version", "model_file") {
if _, err := g.DB().Exec(ctx, "ALTER TABLE model_version DROP COLUMN model_file"); err != nil {
g.Log().Fatalf(ctx, "删除 model_file 列失败: %+v", err)
}
}
if _, err := g.DB().Exec(ctx, "PRAGMA user_version = 11"); err != nil {
g.Log().Fatalf(ctx, "库版本写入失败: %+v", err)
}
g.Log().Infof(ctx, "数据库初始化完成(version=11)")
setDbVersion(ctx, 11)
}
if version < 12 {
// v12:清理孤儿字段 —— dataset 删 6 列(AI 标注/训练机 SSH 配置统一走 config.yml 的
// localAi/training.ssh,零读写)+ model_version 删 artifact_filezip 产物布局移除后无人写)
// + label_task 删 boxes_file(标注已入库,候选框文件机制废弃)。
// PRAGMA table_info 逐列检测存在才 DROP,新库建表已无此列直接跳过
// HasColumn 逐列检测存在才 DROP,新库建表已无此列直接跳过
dropCols := []struct{ table, col string }{
{"dataset", "ai_endpoint"},
{"dataset", "ai_model"},
@@ -218,97 +151,78 @@ func initDatabase(ctx context.Context) {
{"label_task", "boxes_file"},
}
for _, c := range dropCols {
cols, err := g.DB().GetAll(ctx, "PRAGMA table_info("+c.table+")")
if err != nil {
g.Log().Fatalf(ctx, "读取 %s 表结构失败: %+v", c.table, err)
}
exists := false
for _, col := range cols {
if gconv.String(col["name"]) == c.col {
exists = true
break
}
}
if !exists {
if !common.HasColumn(ctx, c.table, c.col) {
continue
}
if _, err := g.DB().Exec(ctx, "ALTER TABLE "+c.table+" DROP COLUMN "+c.col); err != nil {
g.Log().Fatalf(ctx, "删除 %s.%s 列失败: %+v", c.table, c.col, err)
}
}
if _, err := g.DB().Exec(ctx, "PRAGMA user_version = 12"); err != nil {
g.Log().Fatalf(ctx, "库版本写入失败: %+v", err)
}
g.Log().Infof(ctx, "数据库初始化完成(version=12)")
setDbVersion(ctx, 12)
}
if version < 13 {
// v13:双档位训练 —— model_training + model_version 增 variant 列(s|n,默认 s 存量归 s 档)。
// 全新库建表自带该列;存量库 ADD COLUMNNOT NULL DEFAULT 's' 免回填)。
// PRAGMA table_info 逐列检测缺失才 ADD COLUMN
// HasColumn 逐列检测缺失才 ADD COLUMN
addCols := []struct{ table, col string }{
{"model_training", "variant"},
{"model_version", "variant"},
}
for _, c := range addCols {
cols, err := g.DB().GetAll(ctx, "PRAGMA table_info("+c.table+")")
if err != nil {
g.Log().Fatalf(ctx, "读取 %s 表结构失败: %+v", c.table, err)
}
exists := false
for _, col := range cols {
if gconv.String(col["name"]) == c.col {
exists = true
break
}
}
if exists {
if common.HasColumn(ctx, c.table, c.col) {
continue
}
if _, err := g.DB().Exec(ctx, "ALTER TABLE "+c.table+" ADD COLUMN "+c.col+" TEXT NOT NULL DEFAULT 's'"); err != nil {
if _, err := g.DB().Exec(ctx, "ALTER TABLE "+c.table+" ADD COLUMN "+c.col+" VARCHAR(5) NOT NULL DEFAULT 's'"); err != nil {
g.Log().Fatalf(ctx, "增加 %s.%s 列失败: %+v", c.table, c.col, err)
}
}
if _, err := g.DB().Exec(ctx, "PRAGMA user_version = 13"); err != nil {
g.Log().Fatalf(ctx, "库版本写入失败: %+v", err)
}
g.Log().Infof(ctx, "数据库初始化完成(version=13)")
setDbVersion(ctx, 13)
}
if version < 14 {
// v14:综合训练(2026-09-09 多物种合并模型)——model_training / model_version
// 加 kindspecies/combined+ dataset_idsJSON 覆盖数据集列表)。
// 存量行默认 species 零回填;全新库建表自带全列直接跳过
addCols := []struct{ table, col, ddl string }{
{"model_training", "kind", "TEXT NOT NULL DEFAULT 'species'"},
{"model_training", "dataset_ids", "TEXT NOT NULL DEFAULT ''"},
{"model_version", "kind", "TEXT NOT NULL DEFAULT 'species'"},
{"model_version", "dataset_ids", "TEXT NOT NULL DEFAULT ''"},
{"model_training", "kind", "VARCHAR(20) NOT NULL DEFAULT 'species'"},
{"model_training", "dataset_ids", "JSONB"},
{"model_version", "kind", "VARCHAR(20) NOT NULL DEFAULT 'species'"},
{"model_version", "dataset_ids", "JSONB"},
}
for _, c := range addCols {
cols, err := g.DB().GetAll(ctx, "PRAGMA table_info("+c.table+")")
if err != nil {
g.Log().Fatalf(ctx, "读取 %s 表结构失败: %+v", c.table, err)
}
exists := false
for _, col := range cols {
if gconv.String(col["name"]) == c.col {
exists = true
break
}
}
if exists {
if common.HasColumn(ctx, c.table, c.col) {
continue
}
if _, err := g.DB().Exec(ctx, "ALTER TABLE "+c.table+" ADD COLUMN "+c.col+" "+c.ddl); err != nil {
g.Log().Fatalf(ctx, "给 %s 加列 %s 失败: %+v", c.table, c.col, err)
}
}
if _, err := g.DB().Exec(ctx, "PRAGMA user_version = 14"); err != nil {
g.Log().Fatalf(ctx, "库版本写入失败: %+v", err)
}
g.Log().Infof(ctx, "数据库初始化完成(version=14)")
setDbVersion(ctx, 14)
}
// 死表清理:app_config 全局训练配置表已撤销(配置走 config.yml),存量库残留表启动即删
if _, err := g.DB().Exec(ctx, "DROP TABLE IF EXISTS app_config"); err != nil {
g.Log().Fatalf(ctx, "删除残留 app_config 表失败: %+v", err)
}
}
// dbVersion 读取库版本(schema_version 单行表);首次运行建表并初始化 0。
func dbVersion(ctx context.Context) int {
if _, err := g.DB().Exec(ctx, "CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)"); err != nil {
g.Log().Fatalf(ctx, "创建 schema_version 表失败: %+v", err)
}
if _, err := g.DB().Exec(ctx, "INSERT INTO schema_version (version) SELECT 0 WHERE NOT EXISTS (SELECT 1 FROM schema_version)"); err != nil {
g.Log().Fatalf(ctx, "初始化 schema_version 失败: %+v", err)
}
res, err := g.DB().GetOne(ctx, "SELECT version FROM schema_version")
if err != nil {
g.Log().Fatalf(ctx, "读取库版本失败: %+v", err)
}
return res["version"].Int()
}
// setDbVersion 写入库版本。
func setDbVersion(ctx context.Context, v int) {
if _, err := g.DB().Exec(ctx, "UPDATE schema_version SET version = ?", v); err != nil {
g.Log().Fatalf(ctx, "库版本写入失败: %+v", err)
}
g.Log().Infof(ctx, "数据库初始化完成(version=%d)", v)
}