Merge remote-tracking branch 'upstream/main'
Some checks failed
Publish container images / Quality checks (push) Has been cancelled
Publish container images / Resolve build version (push) Has been cancelled
Publish container images / Build open-ai-canvas-backend (amd64) (push) Has been cancelled
Publish container images / Build open-ai-canvas-backend (arm64) (push) Has been cancelled
Publish container images / Build open-ai-canvas-web (multi) (push) Has been cancelled
Publish container images / Build open-ai-canvas-yingce-agent (multi) (push) Has been cancelled
Publish container images / Publish open-ai-canvas-backend manifest (push) Has been cancelled
Publish container images / Publish open-ai-canvas-web manifest (push) Has been cancelled
Publish container images / Publish open-ai-canvas-yingce-agent manifest (push) Has been cancelled
Publish container images / Publish release assets (push) Has been cancelled
Some checks failed
Publish container images / Quality checks (push) Has been cancelled
Publish container images / Resolve build version (push) Has been cancelled
Publish container images / Build open-ai-canvas-backend (amd64) (push) Has been cancelled
Publish container images / Build open-ai-canvas-backend (arm64) (push) Has been cancelled
Publish container images / Build open-ai-canvas-web (multi) (push) Has been cancelled
Publish container images / Build open-ai-canvas-yingce-agent (multi) (push) Has been cancelled
Publish container images / Publish open-ai-canvas-backend manifest (push) Has been cancelled
Publish container images / Publish open-ai-canvas-web manifest (push) Has been cancelled
Publish container images / Publish open-ai-canvas-yingce-agent manifest (push) Has been cancelled
Publish container images / Publish release assets (push) Has been cancelled
This commit is contained in:
@@ -19,13 +19,16 @@ func main() {
|
||||
command = strings.ToLower(strings.TrimSpace(os.Args[1]))
|
||||
}
|
||||
apply := false
|
||||
if command == "repair-asset-bytes" {
|
||||
if command == "repair-asset-bytes" || command == "repair-canvas-resources" {
|
||||
if len(os.Args) == 3 && os.Args[2] == "--apply" {
|
||||
apply = true
|
||||
} else if len(os.Args) > 2 {
|
||||
log.Fatal("用法:migrate-schema repair-asset-bytes [--apply];默认只检查")
|
||||
log.Fatalf("用法:migrate-schema %s [--apply];默认只检查", command)
|
||||
}
|
||||
}
|
||||
if command == "repair-canvas-resources" && strings.TrimSpace(os.Getenv("CANVAS_BACKEND_DATA_DIR")) == "" && strings.TrimSpace(os.Getenv("DATABASE_URL")) == "" {
|
||||
log.Fatal("资源修复必须显式设置 CANVAS_BACKEND_DATA_DIR 或 DATABASE_URL;拒绝使用默认数据库")
|
||||
}
|
||||
db, err := database.Open(database.Config{
|
||||
Driver: env("CANVAS_DATABASE_DRIVER", "sqlite"),
|
||||
DSN: os.Getenv("DATABASE_URL"),
|
||||
@@ -44,6 +47,23 @@ func main() {
|
||||
defer sqlDB.Close()
|
||||
|
||||
switch command {
|
||||
case "repair-canvas-resources":
|
||||
if err := database.RequireSchemaVersion(db); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
report, repairErr := canvas.RepairCanvasResources(db, apply)
|
||||
encoded, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println(string(encoded))
|
||||
if repairErr != nil {
|
||||
log.Fatal(repairErr)
|
||||
}
|
||||
if report.Unresolved > 0 {
|
||||
log.Fatal("存在需人工处理的画布,请按报告恢复历史或重新上传;未删除主媒体")
|
||||
}
|
||||
return
|
||||
case "repair-asset-bytes":
|
||||
report, repairErr := canvas.RepairAssetBytes(db, apply)
|
||||
encoded, err := json.Marshal(report)
|
||||
@@ -68,7 +88,7 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
default:
|
||||
log.Fatalf("未知命令 %q;可用命令:up、status、verify、repair-asset-bytes", command)
|
||||
log.Fatalf("未知命令 %q;可用命令:up、status、verify、repair-asset-bytes、repair-canvas-resources", command)
|
||||
}
|
||||
status, err := database.ReadSchemaStatus(db)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"infinite-canvas/backend/internal/config"
|
||||
"infinite-canvas/backend/internal/database"
|
||||
@@ -15,6 +19,7 @@ import (
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
type tableMigration struct {
|
||||
@@ -52,17 +57,17 @@ func main() {
|
||||
|
||||
// PostgreSQL 的 DDL 参与事务;任一表复制或核对失败都会回滚整个新库结构。
|
||||
if err := target.Transaction(func(tx *gorm.DB) error {
|
||||
tableCount, err := publicTableCount(tx)
|
||||
tableNames, err := publicTableNames(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
copyRows := tableCount == 0
|
||||
copyRows := len(tableNames) == 0
|
||||
if copyRows {
|
||||
if err := database.MigrateSchema(tx); err != nil {
|
||||
return fmt.Errorf("创建目标表结构:%w", err)
|
||||
}
|
||||
} else if tableCount != int64(len(migrations())) {
|
||||
return fmt.Errorf("PostgreSQL public schema 已有 %d 张表,拒绝覆盖或补写", tableCount)
|
||||
} else if err := validateTargetTableNames(tableNames); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
total := 0
|
||||
@@ -79,6 +84,9 @@ func main() {
|
||||
} else {
|
||||
log.Printf("目标库已有完整迁移结果,未重复写入:%d 张表,%d 行", len(migrations()), total)
|
||||
}
|
||||
if err := database.ReconcilePrefixedIDSequences(tx); err != nil {
|
||||
return fmt.Errorf("校准可读 ID 序列:%w", err)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -96,10 +104,52 @@ func verifySQLite(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func publicTableCount(db *gorm.DB) (int64, error) {
|
||||
var count int64
|
||||
err := db.Raw("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'").Scan(&count).Error
|
||||
return count, err
|
||||
func publicTableNames(db *gorm.DB) ([]string, error) {
|
||||
var names []string
|
||||
err := db.Raw(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name
|
||||
`).Scan(&names).Error
|
||||
return names, err
|
||||
}
|
||||
|
||||
// validateTargetTableNames compares the existing PostgreSQL schema against the
|
||||
// same business-table list used by Models() and migrations().
|
||||
// schema_migrations is created by database.MigrateSchema and is intentionally
|
||||
// not part of the row-copy list, so it is allowed as the only system table.
|
||||
func validateTargetTableNames(actual []string) error {
|
||||
expected := make(map[string]struct{}, len(migrations()))
|
||||
for _, migration := range migrations() {
|
||||
expected[migration.name] = struct{}{}
|
||||
}
|
||||
|
||||
actualSet := make(map[string]struct{}, len(actual))
|
||||
for _, name := range actual {
|
||||
actualSet[name] = struct{}{}
|
||||
}
|
||||
missing := make([]string, 0)
|
||||
for name := range expected {
|
||||
if _, ok := actualSet[name]; !ok {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
extra := make([]string, 0)
|
||||
for name := range actualSet {
|
||||
if name == "schema_migrations" {
|
||||
continue
|
||||
}
|
||||
if _, ok := expected[name]; !ok {
|
||||
extra = append(extra, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
sort.Strings(extra)
|
||||
if len(missing) > 0 || len(extra) > 0 {
|
||||
return fmt.Errorf("PostgreSQL public schema 与当前模型清单不一致,缺少表:%s,额外表:%s;拒绝覆盖或补写", strings.Join(missing, ", "), strings.Join(extra, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateTable[T any](name string) tableMigration {
|
||||
@@ -114,6 +164,9 @@ func migrateTable[T any](name string) tableMigration {
|
||||
if err := source.Order(primaryKey).Find(&sourceRows).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := validateExplicitStringLengths(source, name, sourceRows); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if copyRows && len(sourceRows) > 0 {
|
||||
if err := target.CreateInBatches(&sourceRows, 100).Error; err != nil {
|
||||
return 0, err
|
||||
@@ -132,6 +185,102 @@ func migrateTable[T any](name string) tableMigration {
|
||||
}
|
||||
}
|
||||
|
||||
// validateExplicitStringLengths catches values that SQLite accepts but
|
||||
// PostgreSQL would reject for a GORM size:N column. SQLite does not enforce
|
||||
// VARCHAR(N), so this check must happen before the first target insert.
|
||||
func validateExplicitStringLengths[T any](db *gorm.DB, tableName string, rows []T) error {
|
||||
statement := &gorm.Statement{DB: db}
|
||||
if err := statement.Parse(new(T)); err != nil {
|
||||
return err
|
||||
}
|
||||
if statement.Schema == nil {
|
||||
return fmt.Errorf("表 %s 缺少 GORM schema", tableName)
|
||||
}
|
||||
|
||||
for rowIndex := range rows {
|
||||
rowValue := reflect.ValueOf(rows[rowIndex])
|
||||
key := primaryKeyValue(statement.Schema.PrimaryFields, rowValue)
|
||||
for _, field := range statement.Schema.Fields {
|
||||
limit, ok := explicitStringSize(field)
|
||||
if !ok || field.Serializer != nil || !field.Creatable || !field.Readable {
|
||||
continue
|
||||
}
|
||||
value, zero := field.ValueOf(context.Background(), rowValue)
|
||||
if zero {
|
||||
continue
|
||||
}
|
||||
text, ok := stringValue(value)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
actual := utf8.RuneCountInString(text)
|
||||
if actual <= limit {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("表 %s 第 %d 行(主键 %s)列 %s 长度 %d,超过 size:%d", tableName, rowIndex+1, key, field.DBName, actual, limit)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func explicitStringSize(field *schema.Field) (int, bool) {
|
||||
// This adapter keeps the validation logic independent from GORM's inferred
|
||||
// default size (often 255). Only an explicit size:N tag is a PostgreSQL
|
||||
// contract we can prove from the model.
|
||||
sizeText, ok := field.TagSettings["SIZE"]
|
||||
if !ok || strings.TrimSpace(sizeText) == "" {
|
||||
return 0, false
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(field.TagSettings["TYPE"]), "text") {
|
||||
return 0, false
|
||||
}
|
||||
if field.IndirectFieldType.Kind() != reflect.String {
|
||||
return 0, false
|
||||
}
|
||||
limit, err := strconv.Atoi(sizeText)
|
||||
if err != nil || limit < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return limit, true
|
||||
}
|
||||
|
||||
func stringValue(value any) (string, bool) {
|
||||
if value == nil {
|
||||
return "", false
|
||||
}
|
||||
rv := reflect.ValueOf(value)
|
||||
for rv.Kind() == reflect.Interface || rv.Kind() == reflect.Pointer {
|
||||
if rv.IsNil() {
|
||||
return "", false
|
||||
}
|
||||
rv = rv.Elem()
|
||||
}
|
||||
if rv.Kind() != reflect.String {
|
||||
return "", false
|
||||
}
|
||||
return rv.String(), true
|
||||
}
|
||||
|
||||
func primaryKeyValue(fields []*schema.Field, rowValue reflect.Value) string {
|
||||
if len(fields) == 0 {
|
||||
return "<无主键>"
|
||||
}
|
||||
parts := make([]string, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
value, zero := field.ValueOf(context.Background(), rowValue)
|
||||
if zero || value == nil {
|
||||
parts = append(parts, field.DBName+"=<空>")
|
||||
continue
|
||||
}
|
||||
if text, ok := stringValue(value); ok {
|
||||
parts = append(parts, field.DBName+"="+text)
|
||||
continue
|
||||
}
|
||||
parts = append(parts, field.DBName+"="+fmt.Sprint(value))
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func primaryKeyColumn[T any](db *gorm.DB) (string, error) {
|
||||
statement := &gorm.Statement{DB: db}
|
||||
if err := statement.Parse(new(T)); err != nil {
|
||||
@@ -227,6 +376,10 @@ func migrations() []tableMigration {
|
||||
migrateTable[model.UserIdentity]("user_identities"),
|
||||
migrateTable[model.OAuthState]("o_auth_states"),
|
||||
migrateTable[model.EmailVerificationCode]("email_verification_codes"),
|
||||
migrateTable[model.AuthVerification]("auth_verifications"),
|
||||
migrateTable[model.NotificationQuota]("notification_quota"),
|
||||
migrateTable[model.SMSChannel]("sms_channels"),
|
||||
migrateTable[model.SMSRecord]("sms_records"),
|
||||
migrateTable[model.ModelChannel]("model_channels"),
|
||||
migrateTable[model.ChannelModel]("channel_models"),
|
||||
migrateTable[model.ChannelModelPriceTier]("channel_model_price_tiers"),
|
||||
@@ -257,10 +410,13 @@ func migrations() []tableMigration {
|
||||
migrateTable[model.UserOSSSetting]("user_oss_settings"),
|
||||
migrateTable[model.StorageLocation]("storage_locations"),
|
||||
migrateTable[model.UserDailyUploadUsage]("user_daily_upload_usages"),
|
||||
migrateTable[model.UploadReservation]("upload_reservations"),
|
||||
migrateTable[model.Skill]("skills"),
|
||||
migrateTable[model.SkillLibraryCategory]("skill_library_categories"),
|
||||
migrateTable[model.SkillVersion]("skill_versions"),
|
||||
migrateTable[model.SkillFile]("skill_files"),
|
||||
migrateTable[model.UserSkillState]("user_skill_states"),
|
||||
migrateTable[model.BuiltinSkillTombstone]("builtin_skill_tombstones"),
|
||||
migrateTable[model.Resource]("resources"),
|
||||
migrateTable[model.ResourceDeletionJob]("resource_deletion_jobs"),
|
||||
migrateTable[model.AnnouncementImageDraft]("announcement_image_drafts"),
|
||||
@@ -303,6 +459,8 @@ func migrations() []tableMigration {
|
||||
migrateTable[model.CloudAgentMessageRecord]("cloud_agent_message_records"),
|
||||
migrateTable[model.CloudAgentCanvasMutation]("cloud_agent_canvas_mutations"),
|
||||
migrateTable[model.CloudAgentResourceLease]("cloud_agent_resource_leases"),
|
||||
migrateTable[model.CloudAgentPiSession]("cloud_agent_pi_sessions"),
|
||||
migrateTable[model.CloudAgentGeminiCache]("cloud_agent_gemini_caches"),
|
||||
migrateTable[model.AgentProfile]("agent_profiles"),
|
||||
migrateTable[model.AgentLesson]("agent_lessons"),
|
||||
migrateTable[model.AgentMemorySetting]("agent_memory_settings"),
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"infinite-canvas/backend/internal/config"
|
||||
"infinite-canvas/backend/internal/database"
|
||||
"infinite-canvas/backend/internal/handler"
|
||||
"infinite-canvas/backend/internal/logging"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"infinite-canvas/backend/internal/service"
|
||||
"infinite-canvas/backend/internal/updaterclient"
|
||||
@@ -26,6 +28,11 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
logConfig, err := logging.ConfigFromEnv()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
logging.Setup(logConfig)
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
if err := run(ctx); err != nil {
|
||||
@@ -100,14 +107,13 @@ func run(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
if summary, err := svc.MigrateLegacyStorage(); err != nil {
|
||||
log.Printf("storage migration skipped after error: %v", err)
|
||||
slog.Warn("storage migration skipped after error", "error", err)
|
||||
} else if summary.Backup != "" {
|
||||
log.Printf("storage migration completed: tasks=%d assets=%d projects=%d backup=%s", summary.Tasks, summary.Assets, summary.Projects, summary.Backup)
|
||||
slog.Info("storage migration completed", "tasks", summary.Tasks, "assets", summary.Assets, "projects", summary.Projects, "backup", summary.Backup)
|
||||
}
|
||||
r := gin.New()
|
||||
r.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
|
||||
return fmt.Sprintf("%s - [%s] \"%s %s\" %d %s %s\n", param.ClientIP, param.TimeStamp.Format(time.RFC3339), param.Method, redactCanvasSharePath(param.Path), param.StatusCode, param.Latency, param.ErrorMessage)
|
||||
}), gin.Recovery())
|
||||
// 访问日志:成功的查询类请求只在 debug 级别输出,避免轮询和列表查询把日志打爆。
|
||||
r.Use(logging.AccessLog(redactCanvasSharePath), logging.Recovery())
|
||||
r.Use(handler.RequestCorrelationMiddleware())
|
||||
corsMiddleware, err := cors()
|
||||
if err != nil {
|
||||
@@ -138,7 +144,7 @@ func run(ctx context.Context) error {
|
||||
status.markStarted()
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- httpServer.Serve(listener) }()
|
||||
log.Printf("backend listening on %s", addr)
|
||||
slog.Info("backend listening", "addr", addr)
|
||||
|
||||
var serveFailure error
|
||||
select {
|
||||
@@ -168,7 +174,7 @@ func run(ctx context.Context) error {
|
||||
if err := errors.Join(shutdownFailures...); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("backend stopped gracefully")
|
||||
slog.Info("backend stopped gracefully")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user