diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..bb1b0f3 --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,5 @@ +# 运行时数据目录(compose 挂载卷,不进构建上下文,workspace 含 1.3GB APK 上传) +workspace/ +data/ +cache/ +training/ diff --git a/server/biz/dao/annotate_record.go b/server/biz/dao/annotate_record.go index b2e7c3c..5078319 100644 --- a/server/biz/dao/annotate_record.go +++ b/server/biz/dao/annotate_record.go @@ -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") // 领取锁:同一张图同时只允许一条 pending(partial unique index,SQLite 原生支持) 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() diff --git a/server/biz/dao/annotate_task.go b/server/biz/dao/annotate_task.go index e385b0d..fa6a050 100644 --- a/server/biz/dao/annotate_task.go +++ b/server/biz/dao/annotate_task.go @@ -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) diff --git a/server/biz/dao/app_version.go b/server/biz/dao/app_version.go index a4d6503..01c1cc0 100644 --- a/server/biz/dao/app_version.go +++ b/server/biz/dao/app_version.go @@ -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 列") } } diff --git a/server/biz/dao/dataset.go b/server/biz/dao/dataset.go index 2de9491..97cad98 100644 --- a/server/biz/dao/dataset.go +++ b/server/biz/dao/dataset.go @@ -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) } diff --git a/server/biz/dao/dataset_image.go b/server/biz/dao/dataset_image.go index 18e85e4..ab05755 100644 --- a/server/biz/dao/dataset_image.go +++ b/server/biz/dao/dataset_image.go @@ -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 ) - 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 } diff --git a/server/biz/dao/false_target_report.go b/server/biz/dao/false_target_report.go index de68103..f2efba5 100644 --- a/server/biz/dao/false_target_report.go +++ b/server/biz/dao/false_target_report.go @@ -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 diff --git a/server/biz/dao/gen_task.go b/server/biz/dao/gen_task.go index c2e430f..ffea428 100644 --- a/server/biz/dao/gen_task.go +++ b/server/biz/dao/gen_task.go @@ -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) diff --git a/server/biz/dao/label_task.go b/server/biz/dao/label_task.go index 24dc643..1e42eca 100644 --- a/server/biz/dao/label_task.go +++ b/server/biz/dao/label_task.go @@ -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, diff --git a/server/biz/dao/license.go b/server/biz/dao/license.go index ce5eb26..d5a70df 100644 --- a/server/biz/dao/license.go +++ b/server/biz/dao/license.go @@ -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 按手机号查询(走缓存,键含手机号) diff --git a/server/biz/dao/model_training.go b/server/biz/dao/model_training.go index 604b3a7..a1edb90 100644 --- a/server/biz/dao/model_training.go +++ b/server/biz/dao/model_training.go @@ -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, diff --git a/server/biz/dao/model_version.go b/server/biz/dao/model_version.go index fcf2cae..a4bd813 100644 --- a/server/biz/dao/model_version.go +++ b/server/biz/dao/model_version.go @@ -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, diff --git a/server/biz/dao/payment_order.go b/server/biz/dao/payment_order.go index 3d97e22..aa3412c 100644 --- a/server/biz/dao/payment_order.go +++ b/server/biz/dao/payment_order.go @@ -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) diff --git a/server/biz/dao/reward_log.go b/server/biz/dao/reward_log.go index 2a11c4a..a99b4cd 100644 --- a/server/biz/dao/reward_log.go +++ b/server/biz/dao/reward_log.go @@ -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) diff --git a/server/common/base_dao.go b/server/common/base_dao.go index 9cb19b8..7b0dec5 100644 --- a/server/common/base_dao.go +++ b/server/common/base_dao.go @@ -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 COLUMN(SQLite 支持表尾追加)。 -// 新库由 CREATE TABLE 直接含列、已迁移库列已存在,均跳过;失败 panic(启动即暴露)。 -func EnsureColumn(ctx context.Context, table, column, ddl string) { - res, err := g.DB().GetAll(ctx, "PRAGMA table_info("+table+")") +// NilIfEmpty 空串转 nil(JSONB/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) } diff --git a/server/common/driver.go b/server/common/driver.go new file mode 100644 index 0000000..5259a3f --- /dev/null +++ b/server/common/driver.go @@ -0,0 +1,3 @@ +package common + +import _ "github.com/gogf/gf/contrib/drivers/pgsql/v2" diff --git a/server/common/sqlite.go b/server/common/sqlite.go deleted file mode 100644 index 03a8788..0000000 --- a/server/common/sqlite.go +++ /dev/null @@ -1,5 +0,0 @@ -package common - -// 全局统一注册 SQLite 驱动(main 与服务层白盒测试共用同一注册点, -// 测试二进制不编译 main.go,驱动须在 common 层保证加载) -import _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" diff --git a/server/config.yml b/server/config.yml index b31ea79..cb7b9c9 100644 --- a/server/config.yml +++ b/server/config.yml @@ -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 diff --git a/server/docker-compose.yml b/server/docker-compose.yml index 708360b..74a81b7 100644 --- a/server/docker-compose.yml +++ b/server/docker-compose.yml @@ -1,5 +1,5 @@ # 视野后端单机部署:前后端一体单端口,./data(数据库)与 ./workspace(APK 上传)挂载持久化(容器重建不丢数据) -name: redfuture-app +name: redfuture networks: redfuture-net: diff --git a/server/go.mod b/server/go.mod index 4ccb5ae..9afda6a 100644 --- a/server/go.mod +++ b/server/go.mod @@ -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 diff --git a/server/go.sum b/server/go.sum index 3c7675f..6e7e67e 100644 --- a/server/go.sum +++ b/server/go.sum @@ -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= diff --git a/server/main.go b/server/main.go index 3d99dbc..b0ac093 100644 --- a/server/main.go +++ b/server/main.go @@ -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 { // v8:dataset 加训练配置/展示列 + 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_file(zip 产物布局移除后无人写) // + 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 COLUMN(NOT 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 // 加 kind(species/combined)+ dataset_ids(JSON 覆盖数据集列表)。 // 存量行默认 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) +}