feat(cloud-agent): 云端 Agent - 完善运行时与资源持久化

This commit is contained in:
ddcat666
2026-09-26 17:05:36 +08:00
parent 48e6a705ef
commit 993580a7bd
40 changed files with 1739 additions and 440 deletions

View File

@@ -11,7 +11,7 @@ import (
"gorm.io/gorm"
)
const CurrentSchemaVersion int64 = 37
const CurrentSchemaVersion int64 = 38
const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830"
const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830"
@@ -25,6 +25,7 @@ const creationRuntimeChecksum = "sha256:creation-runtime-v10-20260909"
const authNotificationsChecksum = "sha256:auth-notifications-v35-20260924"
const cloudAgentGeminiCacheChecksum = "sha256:cloud-agent-gemini-cache-v36-20260924"
const cloudAgentGeminiCacheIdentityChecksum = "sha256:cloud-agent-gemini-cache-identity-v37-20260925"
const prefixedIDSequenceReconcileChecksum = "sha256:prefixed-id-sequence-reconcile-v38-20260926"
const postgresSchemaMigrationLockID int64 = 73123910420260830
@@ -125,6 +126,14 @@ var schemaMigrations = []migration{
return tx.AutoMigrate(&model.CloudAgentGeminiCache{})
}},
{version: 37, name: "cloud_agent_gemini_cache_identity", checksum: cloudAgentGeminiCacheIdentityChecksum, apply: migrateCloudAgentGeminiCacheIdentity},
{version: 38, name: "prefixed_id_sequence_reconcile", checksum: prefixedIDSequenceReconcileChecksum, apply: migratePrefixedIDSequenceReconcile},
}
func migratePrefixedIDSequenceReconcile(tx *gorm.DB) error {
if err := tx.AutoMigrate(&model.IDSequence{}); err != nil {
return fmt.Errorf("创建可读 ID 序列表:%w", err)
}
return reconcilePrefixedIDSequences(tx)
}
func migrateCloudAgentGeminiCacheIdentity(tx *gorm.DB) error {

View File

@@ -0,0 +1,224 @@
package database
import (
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
"infinite-canvas/backend/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// prefixedIDDefinition is the single source of truth for the readable IDs that
// are allocated from id_sequences. The business table is used to reconcile a
// sequence after a SQLite -> PostgreSQL copy or an interrupted write.
type prefixedIDDefinition struct {
prefix string
model any
}
var prefixedIDDefinitions = []prefixedIDDefinition{
{prefix: "CHANNEL", model: &model.ModelChannel{}},
{prefix: "MODEL", model: &model.ChannelModel{}},
{prefix: "PTIER", model: &model.ChannelModelPriceTier{}},
{prefix: "ATTEMPT", model: &model.RouteAttempt{}},
{prefix: "LMODEL", model: &model.LogicalModel{}},
{prefix: "REVISION", model: &model.LogicalModelRevision{}},
{prefix: "ROUTE", model: &model.LogicalModelRoute{}},
}
// ReconcilePrefixedIDSequences advances all known readable-ID sequences to at
// least the largest valid business ID. It never moves a sequence backwards.
// Soft-deleted rows are included deliberately: their IDs remain reserved.
func ReconcilePrefixedIDSequences(db *gorm.DB) error {
if db == nil {
return errors.New("database handle is nil")
}
return db.Transaction(func(tx *gorm.DB) error {
return reconcilePrefixedIDSequences(tx)
})
}
func reconcilePrefixedIDSequences(tx *gorm.DB) error {
for _, definition := range prefixedIDDefinitions {
if !tx.Migrator().HasTable(definition.model) {
continue
}
maximum, err := maxPrefixedBusinessID(tx, definition)
if err != nil {
return fmt.Errorf("读取 %s 业务 ID:%w", definition.prefix, err)
}
if err := ensureSequenceRow(tx, definition.prefix); err != nil {
return fmt.Errorf("初始化 %s 序列:%w", definition.prefix, err)
}
item, err := lockedSequence(tx, definition.prefix)
if err != nil {
return fmt.Errorf("读取 %s 序列:%w", definition.prefix, err)
}
if item.Value >= maximum {
continue
}
if err := tx.Model(&model.IDSequence{}).Where("name = ?", item.Name).Updates(map[string]any{
"value": maximum,
"updated_at": time.Now(),
}).Error; err != nil {
return fmt.Errorf("校准 %s 序列到 %d:%w", definition.prefix, maximum, err)
}
}
return nil
}
// AllocatePrefixedID allocates the next readable ID. For known prefixes it
// first reconciles the sequence with the business table and checks the
// candidate against that table, so stale sequences and soft-deleted rows
// cannot produce duplicate primary keys.
func AllocatePrefixedID(db *gorm.DB, prefix string) (string, error) {
if db == nil {
return "", errors.New("database handle is nil")
}
var allocated string
if err := db.Transaction(func(tx *gorm.DB) error {
var err error
allocated, err = allocatePrefixedID(tx, prefix)
return err
}); err != nil {
return "", err
}
return allocated, nil
}
func allocatePrefixedID(tx *gorm.DB, rawPrefix string) (string, error) {
prefix := normalizeIDPrefix(rawPrefix)
if prefix == "" || len(prefix) > 16 {
return "", errors.New("invalid id prefix")
}
if err := ensureSequenceRow(tx, prefix); err != nil {
return "", err
}
item, err := lockedSequence(tx, prefix)
if err != nil {
return "", err
}
definition, known := prefixedIDDefinitionFor(prefix)
candidateValue := item.Value
if known {
if !tx.Migrator().HasTable(definition.model) {
return "", fmt.Errorf("ID 前缀 %s 对应的业务表不存在", prefix)
}
maximum, err := maxPrefixedBusinessID(tx, definition)
if err != nil {
return "", fmt.Errorf("读取 %s 业务 ID:%w", prefix, err)
}
if candidateValue < maximum {
candidateValue = maximum
}
}
if candidateValue == math.MaxInt64 {
return "", fmt.Errorf("ID 前缀 %s 序列已达到上限", prefix)
}
for {
candidateValue++
candidate := fmt.Sprintf("%s_%06d", prefix, candidateValue)
if !known {
if err := updateSequence(tx, item.Name, candidateValue); err != nil {
return "", err
}
return candidate, nil
}
var count int64
if err := tx.Unscoped().Model(definition.model).Where("id = ?", candidate).Count(&count).Error; err != nil {
return "", fmt.Errorf("检查 %s 候选 ID:%w", prefix, err)
}
if count != 0 {
if candidateValue == math.MaxInt64 {
return "", fmt.Errorf("ID 前缀 %s 序列已达到上限", prefix)
}
continue
}
if err := updateSequence(tx, item.Name, candidateValue); err != nil {
return "", err
}
return candidate, nil
}
}
func normalizeIDPrefix(raw string) string {
return strings.ToUpper(strings.TrimSpace(raw))
}
func prefixedIDDefinitionFor(prefix string) (prefixedIDDefinition, bool) {
for _, definition := range prefixedIDDefinitions {
if definition.prefix == prefix {
return definition, true
}
}
return prefixedIDDefinition{}, false
}
func ensureSequenceRow(tx *gorm.DB, prefix string) error {
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.IDSequence{
Name: "id:" + prefix,
UpdatedAt: time.Now(),
}).Error
}
func lockedSequence(tx *gorm.DB, prefix string) (model.IDSequence, error) {
query := tx.Model(&model.IDSequence{}).Where("name = ?", "id:"+prefix)
if tx.Dialector.Name() == "postgres" {
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
}
var item model.IDSequence
if err := query.First(&item).Error; err != nil {
return model.IDSequence{}, err
}
return item, nil
}
func updateSequence(tx *gorm.DB, name string, value int64) error {
return tx.Model(&model.IDSequence{}).Where("name = ?", name).Updates(map[string]any{
"value": value,
"updated_at": time.Now(),
}).Error
}
func maxPrefixedBusinessID(tx *gorm.DB, definition prefixedIDDefinition) (int64, error) {
var ids []string
if err := tx.Unscoped().Model(definition.model).Select("id").Pluck("id", &ids).Error; err != nil {
return 0, err
}
prefix := definition.prefix + "_"
var maximum int64
for _, id := range ids {
if !strings.HasPrefix(id, prefix) {
continue
}
digits := strings.TrimPrefix(id, prefix)
if digits == "" {
continue
}
for _, character := range digits {
if character < '0' || character > '9' {
digits = ""
break
}
}
if digits == "" {
continue
}
value, err := strconv.ParseInt(digits, 10, 64)
if err != nil {
continue
}
if value > maximum {
maximum = value
}
}
return maximum, nil
}