Files
sundynix-agentix/sundynix-gateway/internal/store/migrate.go
T
Blizzard 348f1e0249 feat(jarvis): 能动的手(写文件/执行命令,三道闸) + 定时任务调度
此前 JARVIS 只能看不能动(本地工具纯只读)、也不会调度,补齐这两块。

【能动的手】local_write_file / local_exec,在用户自选工作目录内动手:
- 独立开关:只开只读访问不给这能力,须单独勾「允许写文件/执行命令」
- 原生确认框逐次审批:展示命令原文,默认按钮=拒绝,60s 无人应答按拒绝
  (防无人值守被静默批准);可选「本次会话都允许」,关开关即失效
- 硬黑名单:删库/提权/管道下载执行/写系统路径/装开机项/摸凭据等,
  用户点同意也不执行,连审批框都不弹。20 条危险命令 + 10 条正常命令单测
- 命令 cwd 锁沙箱根、60s 超时、输出 16KB 截断;非零退出不算失败(编译/测试
  错误对模型是有用信息)

【定时任务】sundynix_schedule + leader 锁 ticker(30s 扫) + 三个平台工具:
- 存自然语言指令而非编排图,到点走语音同一条关卡(preflightCore/launchCore)
  执行,跑完经语音事件主动播报结果
- 先推进 NextRunAt 再提交:提交失败也不会下轮重复捞起反复烧钱
- 停机期间错过的不补跑(补一堆历史提醒是骚扰),直接顺推到下一个未来时刻

【顺带修一个必崩的 bug】dispatcher 工具超时硬编码 3 秒,而审批要等人点
(60s)+执行(60s)——local_exec 100% 超时。改成工具在 list_tools 自报
timeout_sec(不在 dispatcher 硬编码工具名),超时链外松内紧:
dispatcher 160s > 网关 150s > runner 转发 140s > 桌面端 60+60s。

live 验证:①「写个 hello.sh 打印日期然后跑一下」→ 写+执行两步,文件真落磁盘
②「建个定时任务 35 秒后跑 wc -l」→ 到点自动触发 → 自主调 local_exec → 出结果

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 13:57:49 +08:00

204 lines
9.2 KiB
Go
Raw 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 (
"context"
"log"
"os"
"strings"
"time"
"gorm.io/gorm"
)
// 迁移机制(务实硬化,不引外部工具,见 production_readiness.md B1):
// 1. 整段迁移在 **PG advisory lock** 内串行 —— 多实例同时启动不再并发 ALTER/建索引竞争
// (此前无锁:两实例并发 AutoMigrate 一方报错即掉降级模式)。
// 2. 两处**破坏性** legacy 迁移(DROP TABLE CASCADE)默认**不在启动路径跑**,移到
// ALLOW_LEGACY_SCHEMA_MIGRATION=1 显式开关后;检测到旧 schema 但未开则只告警不动手。
// 3. **版本化 runner**AutoMigrate 之外的步骤(部分唯一索引、数据回填、将来 AutoMigrate
// 做不了的破坏性/数据迁移)登记在 schemaSteps,各跑一次并记入 schema_migration 表,
// 下次启动跳过。给了「有序、记录、跑一次」的真迁移语义,而不重写 gorm 结构体基线。
// migrationLockKey 是 pg_advisory_lock 的固定键(所有实例一致才能互斥)。
const migrationLockKey int64 = 20260721
// SchemaMigration 记录已应用的版本化迁移步骤(用模型而非裸 DDL,PG/sqlite 都可建,便于测试)。
type SchemaMigration struct {
ID int `gorm:"primaryKey"`
Name string `gorm:"size:128"`
AppliedAt time.Time `gorm:"autoCreateTime"`
}
func (SchemaMigration) TableName() string { return "sundynix_schema_migration" }
// migrationStep 是一步版本化迁移。fn 幂等更稳(存量库重跑无害),但 runner 靠 schema_migration
// 记录保证「已应用即跳过」,故不强求幂等——将来的破坏性步骤可以是非幂等的一次性 DDL。
type migrationStep struct {
id int
name string
fn func(*gorm.DB) error
}
// schemaSteps 是 AutoMigrate 之外的有序迁移。往后加破坏性/数据迁移 = 在末尾追加新 id,别改历史。
var schemaSteps = []migrationStep{
{1, "ledger_grant_ref_unique", func(db *gorm.DB) error {
// 支付入账幂等兜底闸:grant 分录按 ref(=订单号) 唯一。部分索引放行手工发放(ref 空)。
return db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_grant_ref ON sundynix_credit_ledger (kind, ref) WHERE kind = 'grant' AND ref <> ''`).Error
}},
{2, "ledger_refund_ref_unique", func(db *gorm.DB) error {
// 退款幂等兜底闸:adjust 分录按 ref 唯一,与 grant 双闸对称。
return db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_refund_ref ON sundynix_credit_ledger (kind, ref) WHERE kind = 'adjust' AND ref <> ''`).Error
}},
{3, "backfill_null_tenant_balance", func(db *gorm.DB) error {
// 回填历史 NULL 余额(credit_balance_micro 后加列,早于它的租户行为 NULL → 充值 NULL+N=NULL
// 永不到账)。存量重跑 WHERE IS NULL 无命中,安全。让「余额=SUM(ledger)」不变量重立。
return db.Exec(`UPDATE sundynix_tenant SET credit_balance_micro = COALESCE(
(SELECT SUM(credits_micro) FROM sundynix_credit_ledger l WHERE l.tenant_id = sundynix_tenant.id), 0)
WHERE credit_balance_micro IS NULL`).Error
}},
{4, "user_wechat_openid_unique", func(db *gorm.DB) error {
// 微信 openid 部分唯一索引:只约束非空。存量邮箱用户该列空串,普通唯一索引会互撞。
return db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_wechat_openid ON sundynix_user (wechat_openid) WHERE wechat_openid <> ''`).Error
}},
}
// migratedModels 是 AutoMigrate 的基线模型清单(新增性 DDL,安全)。加表在此追加。
func migratedModels() []any {
return []any{
&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{},
&AuditLog{}, &GuardrailEvent{}, &Tenant{}, &TenantMember{}, &TenantInvite{}, &Space{}, &SpaceMember{},
&UsageEvent{}, &CreditLedger{}, &UsageRollup{}, &Setting{}, &CreditPack{}, &PaymentOrder{},
&RedeemCode{}, &SubscriptionPlan{}, &Subscription{}, &UserJarvis{}, &Schedule{}, &SchemaMigration{},
}
}
// runMigrations 在 advisory lock 内跑全部迁移:legacy(默认关) → AutoMigrate 基线 → 版本化步骤。
// 只有 AutoMigrate 失败才返回 error(→ 调用方降级);版本化步骤失败只记日志、下次启动重试。
func runMigrations(db *gorm.DB) error {
return withMigrationLock(db, func() error {
if allowLegacyMigration() {
migrateLegacyIntIDs(db)
migrateDocLinkToID(db)
} else {
warnIfLegacySchema(db)
}
if err := db.AutoMigrate(migratedModels()...); err != nil {
return err
}
if err := runVersionedMigrations(db, schemaSteps); err != nil {
log.Printf("[store] 版本化迁移失败: %v(下次启动重试)", err)
}
return nil
})
}
// withMigrationLock 取一条专用连接持 pg_advisory_lock,在锁内跑 fn,结束释放。
// 多实例同时启动只有一个进锁跑迁移,其余阻塞等待(避免并发 DDL 竞争)。
// 取锁给 60s 超时兜底:极端情况取不到就带告警继续(AutoMigrate/索引多为幂等,退一步不致命)。
func withMigrationLock(db *gorm.DB, fn func() error) error {
sqlDB, err := db.DB()
if err != nil {
return fn() // 拿不到底层连接(如测试用非标准驱动)→ 不阻塞,直接跑
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
conn, err := sqlDB.Conn(ctx)
if err != nil {
log.Printf("[store] 取迁移锁连接失败,跳过加锁继续: %v", err)
return fn()
}
defer conn.Close()
if _, err := conn.ExecContext(ctx, "SELECT pg_advisory_lock($1)", migrationLockKey); err != nil {
log.Printf("[store] 取迁移 advisory lock 失败,跳过加锁继续: %v", err)
return fn()
}
defer func() {
uctx, ucancel := context.WithTimeout(context.Background(), 5*time.Second)
defer ucancel()
_, _ = conn.ExecContext(uctx, "SELECT pg_advisory_unlock($1)", migrationLockKey)
}()
return fn()
}
// runVersionedMigrations 顺序跑未应用的步骤,每步成功即记入 schema_migration 表。
// 某步失败即停(后续步骤可能依赖它),不记录 → 下次启动从该步重试。
func runVersionedMigrations(db *gorm.DB, steps []migrationStep) error {
if err := db.AutoMigrate(&SchemaMigration{}); err != nil {
return err
}
var ids []int
db.Model(&SchemaMigration{}).Pluck("id", &ids)
applied := make(map[int]bool, len(ids))
for _, id := range ids {
applied[id] = true
}
for _, s := range steps {
if applied[s.id] {
continue
}
if err := s.fn(db); err != nil {
return err
}
if err := db.Create(&SchemaMigration{ID: s.id, Name: s.name}).Error; err != nil {
return err
}
log.Printf("[store] 迁移 #%d(%s) 已应用", s.id, s.name)
}
return nil
}
// allowLegacyMigration 报告是否允许跑破坏性 legacy 迁移(默认关:不在启动路径 DROP 表)。
func allowLegacyMigration() bool {
v := os.Getenv("ALLOW_LEGACY_SCHEMA_MIGRATION")
return v == "1" || strings.EqualFold(v, "true")
}
// warnIfLegacySchema 检测到旧整型 id schema 但未开 legacy 开关时,只告警不动手(不 DROP)。
func warnIfLegacySchema(db *gorm.DB) {
var dt string
db.Raw(`SELECT data_type FROM information_schema.columns WHERE table_name='sundynix_model' AND column_name='id'`).Scan(&dt)
if dt == "bigint" || dt == "integer" {
log.Printf("[store] ⚠️ 检测到旧整型 id schema。破坏性迁移默认已关(不会自动 DROP 重建)。" +
"如确需迁移,设 ALLOW_LEGACY_SCHEMA_MIGRATION=1 后重启(会 DROP 并重建部分表)")
}
}
// migrateLegacyIntIDs 检测到旧整型 id 表则备份模型密钥、删旧表(AutoMigrate 随后按新规约重建)。
// **破坏性**:仅在 ALLOW_LEGACY_SCHEMA_MIGRATION=1 时经 runMigrations 调用。
func migrateLegacyIntIDs(db *gorm.DB) {
var dt string
db.Raw(`SELECT data_type FROM information_schema.columns WHERE table_name='sundynix_model' AND column_name='id'`).Scan(&dt)
if dt != "bigint" && dt != "integer" {
return // 全新库或已是新规约
}
log.Println("[store] 检测到旧整型 id 表,执行雪花 id 迁移(保模型密钥,重置其它测试表)")
var saved []map[string]any
db.Table("sundynix_model").Find(&saved)
for _, t := range []string{"sundynix_doc_link", "sundynix_doc", "sundynix_agent", "sundynix_kb", "sundynix_model", "sundynix_task", "sundynix_user"} {
db.Exec("DROP TABLE IF EXISTS " + t + " CASCADE")
}
_ = db.AutoMigrate(&LLMModel{}) // 先建模型表以回灌
for _, r := range saved {
s := func(k string) string { v, _ := r[k].(string); return v }
b, _ := r["active"].(bool)
_ = db.Create(&LLMModel{
Kind: s("kind"), Provider: s("provider"), BaseURL: s("base_url"),
APIKey: s("api_key"), Model: s("model"), Active: b,
}).Error
}
log.Printf("[store] 已回灌 %d 条模型配置(新雪花 id)", len(saved))
}
// migrateDocLinkToID 把旧的按名双链表迁到按 Doc.ID 关联的新表。**破坏性**(DROP 重建):
// 仅在 ALLOW_LEGACY_SCHEMA_MIGRATION=1 时调用。
func migrateDocLinkToID(db *gorm.DB) {
if !db.Migrator().HasTable("sundynix_doc_link") {
return
}
if db.Migrator().HasColumn(&DocLink{}, "from_id") {
return // 已是按 ID 关联的新 schema
}
log.Println("[store] 双链表升级为按文件 ID 关联,重建 sundynix_doc_link(链接随文档再入库重建)")
db.Exec("DROP TABLE IF EXISTS sundynix_doc_link CASCADE")
}