Files
geekai/api/service/migration_service.go
T
RockYang 9ccff4efbc feat(release): migrate GeekAI v4.3.0 to open source
- Sync backend and frontend from GeekAI Plus v4.3.0

- Remove commercial License flows and update open-source deployment defaults

- Preserve Docker Compose deployment and bump image tags to v4.3.0

BREAKING CHANGE: commercial License configuration and related endpoints are removed
2026-08-11 14:51:20 +08:00

706 lines
23 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 service
// ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
// Copyright 2023 The Geek-AI Authors. All rights reserved.
// Use of this source code is governed by a Apache-2.0 license
// that can be found in the LICENSE file.
// @Author yangjian102621@163.com
// ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
import (
"context"
"encoding/json"
"fmt"
"geekai/core/types"
"geekai/store/model"
"strings"
"github.com/go-redis/redis/v8"
"gorm.io/gorm"
)
const (
// 迁移状态Redis key
MigrationStatusKey = "config_migration:status"
// 迁移完成标志
MigrationCompleted = "completed"
)
// MigrationService 配置迁移服务
type MigrationService struct {
db *gorm.DB
redisClient *redis.Client
appConfig *types.AppConfig
}
func NewMigrationService(db *gorm.DB, redisClient *redis.Client, appConfig *types.AppConfig) *MigrationService {
return &MigrationService{
db: db,
redisClient: redisClient,
appConfig: appConfig,
}
}
func (s *MigrationService) StartMigrate() {
// 表结构迁移必须在业务服务启动前完成,避免新表和新列尚未创建就被后台任务查询。
s.TableMigration()
go func() {
s.MigrateConfig(s.appConfig)
}()
}
// 迁移配置内容
func (s *MigrationService) MigrateConfigContent() error {
// 用户协议
if err := s.saveConfig(types.ConfigKeyPrivacy, map[string]string{
"content": "用户协议内容",
}); err != nil {
return fmt.Errorf("迁移配置内容失败: %v", err)
}
// 隐私政策
if err := s.saveConfig(types.ConfigKeyAgreement, map[string]string{
"content": "隐私政策内容",
}); err != nil {
return fmt.Errorf("迁移配置内容失败: %v", err)
}
// 思维导图
if err := s.saveConfig(types.ConfigKeyMarkMap, map[string]string{
"content": `# GeekAI 演示站
- 完整的开源系统,前端应用和后台管理系统皆可开箱即用。
- 基于 Websocket 实现,完美的打字机体验。
- 内置了各种预训练好的角色应用,轻松满足你的各种聊天和应用需求。
- 支持 OPenAI,Azure,文心一言,讯飞星火,清华 ChatGLM等多个大语言模型。
- 支持 MidJourney / Stable Diffusion AI 绘画集成,开箱即用。
- 支持使用个人微信二维码作为充值收费的支付渠道,无需企业支付通道。
- 已集成支付宝支付功能,微信支付,支持多种会员套餐和点卡购买功能。
- 集成插件 API 功能,可结合大语言模型的 function 功能开发各种强大的插件。`,
}); err != nil {
return fmt.Errorf("迁移配置内容失败: %v", err)
}
// 微信登录配置
if err := s.saveConfig(types.ConfigKeyWxLogin, map[string]string{
"api_key": "",
"notify_url": "",
"enabled": "false",
}); err != nil {
return fmt.Errorf("迁移配置内容失败: %v", err)
}
// 验证码配置
if err := s.saveConfig(types.ConfigKeyCaptcha, map[string]string{
"api_key": "",
"type": "dot",
"enabled": "false",
}); err != nil {
return fmt.Errorf("迁移配置内容失败: %v", err)
}
// 文本审核
if err := s.saveConfig(types.ConfigKeyModeration, map[string]any{
"enable": "false",
"active": "gitee",
"enable_guide": "false",
"guide_prompt": "",
"gitee": map[string]string{
"api_key": "",
"model": "Security-semantic-filtering",
},
"baidu": map[string]string{
"access_key": "",
"secret_key": "",
},
"tencent": map[string]string{
"access_key": "",
"secret_key": "",
},
}); err != nil {
return fmt.Errorf("迁移配置内容失败: %v", err)
}
return nil
}
// fullTableMigration 第一步:全量表迁移,同步所有表结构(新增表、新增字段、字段类型)
// 适用于首次安装或导入旧版数据库后同步 schema,AutoMigrate 会补齐缺失的表和列
func (s *MigrationService) fullTableMigration() {
logger.Info("执行全量表迁移(同步 schema...")
models := []any{
&model.Config{},
&model.AdminUser{},
&model.ChatApp{},
&model.ApiKey{},
&model.AppType{},
&model.ChatModel{},
&model.User{},
&model.ChatItem{},
&model.ChatMessage{},
&model.Order{},
&model.Product{},
&model.Function{},
&model.Menu{},
&model.InviteCode{},
&model.InviteLog{},
&model.Redeem{},
&model.PowerLog{},
&model.File{},
&model.UserLoginLog{},
&model.MidJourneyJob{},
&model.SunoJob{},
&model.VideoJob{},
&model.JimengJob{},
&model.PPTJob{},
&model.Moderation{},
&model.ImageJob{},
}
if err := s.db.AutoMigrate(models...); err != nil {
logger.Errorf("全量表迁移失败: %v", err)
return
}
logger.Info("全量表迁移完成")
}
// fixTableConstraints 第一步之后:根据模型定义修复各表的主键、自增和关键索引
// 主要解决初始化 SQL 中缺少 AUTO_INCREMENT 或 PRIMARY KEY 导致插入失败的问题
func (s *MigrationService) fixTableConstraints() {
logger.Info("开始修复各表的主键、自增属性和索引...")
// 当前数据库名
var dbName string
if err := s.db.Raw("SELECT DATABASE()").Scan(&dbName).Error; err != nil {
logger.Errorf("获取当前数据库名失败: %v", err)
return
}
if dbName == "" {
logger.Warn("当前连接未选择数据库,跳过约束修复")
return
}
// === 修复所有包含 id 字段的表的主键 + 自增 ===
type columnInfo struct {
TableName string `gorm:"column:TABLE_NAME"`
ColumnName string `gorm:"column:COLUMN_NAME"`
ColumnKey string `gorm:"column:COLUMN_KEY"`
Extra string `gorm:"column:EXTRA"`
DataType string `gorm:"column:DATA_TYPE"`
}
var idColumns []columnInfo
if err := s.db.Raw(`
SELECT TABLE_NAME, COLUMN_NAME, COLUMN_KEY, EXTRA, DATA_TYPE
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = ? AND COLUMN_NAME = 'id'
`, dbName).Scan(&idColumns).Error; err != nil {
logger.Errorf("查询各表 id 字段信息失败: %v", err)
} else {
for _, col := range idColumns {
if col.ColumnName == "" {
continue
}
// 检查当前表是否已经存在主键
type pkInfo struct {
ColumnName string `gorm:"column:COLUMN_NAME"`
}
var pkColumns []pkInfo
if err := s.db.Raw(`
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND CONSTRAINT_NAME = 'PRIMARY'
`, dbName, col.TableName).Scan(&pkColumns).Error; err != nil {
logger.Errorf("查询表 %s 的主键信息失败: %v", col.TableName, err)
continue
}
hasPK := len(pkColumns) > 0
pkOnIdOnly := hasPK && len(pkColumns) == 1 && pkColumns[0].ColumnName == "id"
needAlter := false
switch {
case !hasPK:
// 没有任何主键:允许将 id 设置为自增主键
if col.ColumnKey != "PRI" || col.Extra == "" || !strings.Contains(col.Extra, "auto_increment") {
needAlter = true
}
case pkOnIdOnly:
// 只有 id 作为主键:只补充自增属性
if col.Extra == "" || !strings.Contains(col.Extra, "auto_increment") {
needAlter = true
}
default:
// 已存在非 id 或组合主键:避免破坏原有主键,直接跳过
logger.Infof("表 %s 已存在非 id 主键,跳过 id 自增主键修复", col.TableName)
}
if !needAlter {
continue
}
// 维持原来的数据类型,避免与历史 SQL 冲突
dataType := col.DataType
if dataType == "" {
dataType = "int"
}
alterSQL := fmt.Sprintf(
"ALTER TABLE `%s` MODIFY COLUMN id %s NOT NULL AUTO_INCREMENT PRIMARY KEY",
col.TableName,
dataType,
)
if err := s.db.Exec(alterSQL).Error; err != nil {
logger.Errorf("修复表 %s 的 id 自增主键失败: %v", col.TableName, err)
} else {
logger.Infof("已修复表 %s 的 id 为 AUTO_INCREMENT PRIMARY KEY", col.TableName)
}
}
}
// === 为 geekai_users.username 补充唯一索引(根据模型 uniqueIndex 定义) ===
var usernameUniqueCount int64
err := s.db.Raw(`
SELECT COUNT(1)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = ?
AND TABLE_NAME = 'geekai_users'
AND COLUMN_NAME = 'username'
AND NON_UNIQUE = 0
`, dbName).Scan(&usernameUniqueCount).Error
if err != nil {
logger.Errorf("检查 geekai_users.username 唯一索引失败: %v", err)
} else if usernameUniqueCount == 0 {
// 索引名尽量固定,避免重复创建
if err := s.db.Exec("ALTER TABLE geekai_users ADD UNIQUE KEY idx_geekai_users_username (username)").Error; err != nil {
logger.Errorf("创建 geekai_users.username 唯一索引失败: %v", err)
} else {
logger.Info("已为 geekai_users.username 创建唯一索引 idx_geekai_users_username")
}
} else {
logger.Info("geekai_users.username 唯一索引已存在,跳过创建")
}
logger.Info("关键表主键、自增和索引修复完成")
}
// incrementalTableMigration 第二步:增量迁移,仅处理删除字段与数据迁移
// AutoMigrate 不会删除列,故需在此显式 DropColumn;字段重命名需先拷贝数据再删除旧列
func (s *MigrationService) incrementalTableMigration() {
logger.Info("执行增量迁移(删除字段 + 数据迁移)...")
// ========== 字段重命名:全量迁移已添加新列,需将旧列数据拷贝到新列后删除旧列 ==========
// ChatApp(geekai_chat_roles): context_json -> system_prompt 历史数据迁移
if s.db.Migrator().HasColumn(&model.ChatApp{}, "context_json") {
// 将旧列 context_json 的值拷贝到 system_promptNULL 转为空字符串,保证 NOT NULL 约束)
if err := s.db.Exec(`
UPDATE geekai_chat_roles
SET system_prompt = IFNULL(NULLIF(TRIM(COALESCE(context_json, '')), ''), '')
`).Error; err != nil {
logger.Errorf("迁移 geekai_chat_roles.context_json -> system_prompt 失败: %v", err)
} else {
if err := s.db.Migrator().DropColumn(&model.ChatApp{}, "context_json"); err != nil {
logger.Errorf("删除 geekai_chat_roles.context_json 失败: %v", err)
} else {
logger.Info("geekai_chat_roles: context_json 已迁移至 system_prompt 并删除旧列")
}
}
}
// ChatApp: 将 user_id 为 NULL 的历史记录置为 0(系统内置)
if s.db.Migrator().HasColumn(&model.ChatApp{}, "user_id") {
if err := s.db.Exec(`UPDATE geekai_chat_roles SET user_id = 0 WHERE user_id IS NULL`).Error; err != nil {
logger.Errorf("初始化 geekai_chat_roles.user_id 失败: %v", err)
}
}
// ChatApp(geekai_chat_roles): 删除 marker 列(应用仅通过 id 区分)
var hasMarker int
if s.db.Raw("SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'geekai_chat_roles' AND COLUMN_NAME = 'marker'").Scan(&hasMarker).Error == nil && hasMarker > 0 {
// 先删除可能存在的唯一索引(不同版本 SQL 索引名不同)
for _, idxName := range []string{"marker", "idx_chatgpt_chat_roles_marker", "idx_chatgpt_chat_roles_key", "idx_geekai_chat_roles_marker"} {
_ = s.db.Exec(fmt.Sprintf("ALTER TABLE geekai_chat_roles DROP INDEX `%s`", idxName)).Error
}
if err := s.db.Exec("ALTER TABLE geekai_chat_roles DROP COLUMN marker").Error; err != nil {
logger.Errorf("删除 geekai_chat_roles.marker 失败: %v", err)
} else {
logger.Info("geekai_chat_roles: 已删除 marker 列")
}
}
// Config: config_json -> value, marker -> name
if s.db.Migrator().HasColumn(&model.Config{}, "config_json") {
s.db.Exec("UPDATE geekai_configs SET `value` = config_json WHERE config_json IS NOT NULL AND config_json != ''")
s.db.Migrator().DropColumn(&model.Config{}, "config_json")
}
if s.db.Migrator().HasColumn(&model.Config{}, "marker") {
s.db.Exec("UPDATE geekai_configs SET `name` = marker WHERE marker IS NOT NULL AND marker != ''")
s.db.Migrator().DropColumn(&model.Config{}, "marker")
}
if s.db.Migrator().HasIndex(&model.Config{}, "idx_chatgpt_configs_key") {
s.db.Migrator().DropIndex(&model.Config{}, "idx_chatgpt_configs_key")
}
if s.db.Migrator().HasIndex(&model.Config{}, "marker") {
s.db.Migrator().DropIndex(&model.Config{}, "marker")
}
// Order: pay_type -> channel
if s.db.Migrator().HasColumn(&model.Order{}, "pay_type") {
s.db.Exec("UPDATE geekai_orders SET channel = pay_type WHERE pay_type IS NOT NULL AND pay_type != ''")
s.db.Migrator().DropColumn(&model.Order{}, "pay_type")
}
// JimengJob: task_params -> params
if s.db.Migrator().HasColumn(&model.JimengJob{}, "task_params") {
s.db.Exec("UPDATE geekai_jimeng_jobs SET params = task_params WHERE task_params IS NOT NULL AND task_params != ''")
s.db.Migrator().DropColumn(&model.JimengJob{}, "task_params")
}
// VideoJob: task_info -> params, raw_data -> output
if s.db.Migrator().HasColumn(&model.VideoJob{}, "task_info") {
s.db.Exec("UPDATE geekai_video_jobs SET params = task_info WHERE task_info IS NOT NULL AND task_info != ''")
s.db.Migrator().DropColumn(&model.VideoJob{}, "task_info")
}
if s.db.Migrator().HasColumn(&model.VideoJob{}, "raw_data") {
s.db.Exec("UPDATE geekai_video_jobs SET `output` = raw_data WHERE raw_data IS NOT NULL AND raw_data != ''")
s.db.Migrator().DropColumn(&model.VideoJob{}, "raw_data")
}
// SunoJob: task_info -> params, raw_data -> output
if s.db.Migrator().HasColumn(&model.SunoJob{}, "task_info") {
s.db.Exec("UPDATE geekai_suno_jobs SET params = task_info WHERE task_info IS NOT NULL AND task_info != ''")
s.db.Migrator().DropColumn(&model.SunoJob{}, "task_info")
}
if s.db.Migrator().HasColumn(&model.SunoJob{}, "raw_data") {
s.db.Exec("UPDATE geekai_suno_jobs SET `output` = raw_data WHERE raw_data IS NOT NULL AND raw_data != ''")
s.db.Migrator().DropColumn(&model.SunoJob{}, "raw_data")
}
// ========== 删除不再使用的字段 ==========
if s.db.Migrator().HasColumn(&model.Order{}, "deleted_at") {
s.db.Migrator().DropColumn(&model.Order{}, "deleted_at")
}
if s.db.Migrator().HasColumn(&model.ChatItem{}, "deleted_at") {
s.db.Migrator().DropColumn(&model.ChatItem{}, "deleted_at")
}
if s.db.Migrator().HasColumn(&model.ChatMessage{}, "deleted_at") {
s.db.Migrator().DropColumn(&model.ChatMessage{}, "deleted_at")
}
if s.db.Migrator().HasColumn(&model.User{}, "chat_config") {
s.db.Migrator().DropColumn(&model.User{}, "chat_config")
}
if s.db.Migrator().HasColumn(&model.ChatModel{}, "category") {
s.db.Migrator().DropColumn(&model.ChatModel{}, "category")
}
if s.db.Migrator().HasColumn(&model.ChatModel{}, "description") {
s.db.Migrator().DropColumn(&model.ChatModel{}, "description")
}
if s.db.Migrator().HasColumn(&model.Product{}, "discount") {
s.db.Migrator().DropColumn(&model.Product{}, "discount")
}
if s.db.Migrator().HasColumn(&model.Product{}, "days") {
s.db.Migrator().DropColumn(&model.Product{}, "days")
}
if s.db.Migrator().HasColumn(&model.Product{}, "app_url") {
s.db.Migrator().DropColumn(&model.Product{}, "app_url")
}
if s.db.Migrator().HasColumn(&model.Product{}, "url") {
s.db.Migrator().DropColumn(&model.Product{}, "url")
}
if s.db.Migrator().HasColumn(&model.VideoJob{}, "water_url") {
s.db.Migrator().DropColumn(&model.VideoJob{}, "water_url")
}
if s.db.Migrator().HasColumn(&model.VideoJob{}, "cover_url") {
s.db.Migrator().DropColumn(&model.VideoJob{}, "cover_url")
}
if s.db.Migrator().HasColumn(&model.VideoJob{}, "prompt_ext") {
s.db.Migrator().DropColumn(&model.VideoJob{}, "prompt_ext")
}
if s.db.Migrator().HasColumn(&model.SunoJob{}, "instrumental") {
s.db.Migrator().DropColumn(&model.SunoJob{}, "instrumental")
}
if s.db.Migrator().HasColumn(&model.SunoJob{}, "tags") {
s.db.Migrator().DropColumn(&model.SunoJob{}, "tags")
}
if s.db.Migrator().HasColumn(&model.SunoJob{}, "extend_secs") {
s.db.Migrator().DropColumn(&model.SunoJob{}, "extend_secs")
}
if s.db.Migrator().HasColumn(&model.SunoJob{}, "model_name") {
s.db.Migrator().DropColumn(&model.SunoJob{}, "model_name")
}
// ========== 数据迁移:根据业务逻辑更新现有数据 ==========
// video_job: 根据 progress 填充 status
if s.db.Migrator().HasColumn(&model.VideoJob{}, "status") {
s.db.Exec(`UPDATE geekai_video_jobs SET status = CASE
WHEN progress < 100 THEN 'in_progress'
WHEN progress = 100 THEN 'success'
WHEN progress = 101 THEN 'failed'
WHEN progress = 102 THEN 'downloading'
ELSE 'pending'
END WHERE status = '' OR status IS NULL`)
}
// suno_job: 从 output 提取 tags/model_name 填入 params
s.migrateSunoJobData()
logger.Info("增量迁移完成")
}
// TableMigration 数据表迁移入口:先全量同步 schema,再增量删除字段并迁移数据
func (s *MigrationService) TableMigration() {
s.fullTableMigration()
s.fixTableConstraints()
s.incrementalTableMigration()
s.migrateChatAppSystemPromptFromJSON()
}
// migrateChatAppSystemPromptFromJSON 将智能体 system_prompt 字段中历史 JSON 数组
// 解析后取出 role 为 system 的 content,覆盖回 system_prompt(纯文本)
func (s *MigrationService) migrateChatAppSystemPromptFromJSON() {
key := "migrate:chat_app_system_prompt_json"
if s.redisClient.Get(context.Background(), key).Val() == "1" {
logger.Info("ChatApp system_prompt JSON 已迁移,跳过")
return
}
logger.Info("开始迁移智能体 system_prompt 历史 JSON 数据...")
var apps []model.ChatApp
if err := s.db.Find(&apps).Error; err != nil {
logger.Errorf("查询 ChatApp 失败: %v", err)
return
}
updated := 0
for i := range apps {
raw := strings.TrimSpace(apps[i].SystemPrompt)
if raw == "" {
continue
}
if len(raw) < 2 || raw[0] != '[' {
continue
}
var messages []types.Message
if err := json.Unmarshal([]byte(raw), &messages); err != nil {
continue
}
var systemContent string
for _, m := range messages {
if strings.ToLower(strings.TrimSpace(m.Role)) == "system" && m.Content != "" {
systemContent = m.Content
break
}
}
if err := s.db.Model(&model.ChatApp{}).Where("id = ?", apps[i].Id).Update("system_prompt", systemContent).Error; err != nil {
logger.Warnf("更新 ChatApp id=%d system_prompt 失败: %v", apps[i].Id, err)
continue
}
updated++
}
logger.Infof("智能体 system_prompt JSON 迁移完成,共更新 %d 条", updated)
s.redisClient.Set(context.Background(), key, "1", 0)
}
// 迁移配置数据
func (s *MigrationService) MigrateConfig(config *types.AppConfig) error {
logger.Info("开始迁移配置到数据库...")
// 迁移支付配置
if err := s.migratePaymentConfig(config); err != nil {
logger.Errorf("迁移支付配置失败: %v", err)
return err
}
// 迁移存储配置
if err := s.migrateStorageConfig(config); err != nil {
logger.Errorf("迁移存储配置失败: %v", err)
return err
}
// 迁移通信配置
if err := s.migrateCommunicationConfig(config); err != nil {
logger.Errorf("迁移通信配置失败: %v", err)
return err
}
// 迁移配置内容
if err := s.MigrateConfigContent(); err != nil {
logger.Errorf("迁移配置内容失败: %v", err)
return err
}
logger.Info("配置迁移完成")
return nil
}
// 迁移支付配置
func (s *MigrationService) migratePaymentConfig(config *types.AppConfig) error {
paymentConfig := types.PaymentConfig{
Alipay: config.AlipayConfig,
Epay: config.GeekPayConfig,
WxPay: config.WechatPayConfig,
}
if err := s.saveConfig(types.ConfigKeyPayment, paymentConfig); err != nil {
return err
}
return nil
}
// 迁移存储配置
func (s *MigrationService) migrateStorageConfig(config *types.AppConfig) error {
ossConfig := types.OSSConfig{
Active: config.OSS.Active,
Local: config.OSS.Local,
Minio: config.OSS.Minio,
QiNiu: config.OSS.QiNiu,
AliYun: config.OSS.AliYun,
}
return s.saveConfig(types.ConfigKeyOss, ossConfig)
}
// 迁移通信配置
func (s *MigrationService) migrateCommunicationConfig(config *types.AppConfig) error {
// SMTP配置
smtpConfig := map[string]any{
"use_tls": config.SmtpConfig.UseTls,
"host": config.SmtpConfig.Host,
"port": config.SmtpConfig.Port,
"app_name": config.SmtpConfig.AppName,
"from": config.SmtpConfig.From,
"password": config.SmtpConfig.Password,
}
if err := s.saveConfig(types.ConfigKeySmtp, smtpConfig); err != nil {
return err
}
// 短信配置
smsConfig := map[string]any{
"active": strings.ToLower(config.SMS.Active),
"aliyun": map[string]any{
"access_key": config.SMS.Ali.AccessKey,
"access_secret": config.SMS.Ali.AccessSecret,
"sign": config.SMS.Ali.Sign,
"code_temp_id": config.SMS.Ali.CodeTempId,
},
"bao": map[string]any{
"username": config.SMS.Bao.Username,
"password": config.SMS.Bao.Password,
"sign": config.SMS.Bao.Sign,
"code_template": config.SMS.Bao.CodeTemplate,
},
"tencent": map[string]any{
"secret_id": config.SMS.Tencent.SecretId,
"secret_key": config.SMS.Tencent.SecretKey,
"sms_sdk_app_id": config.SMS.Tencent.SmsSdkAppId,
"sign": config.SMS.Tencent.Sign,
"code_temp_id": config.SMS.Tencent.CodeTempId,
"region": config.SMS.Tencent.Region,
},
}
return s.saveConfig(types.ConfigKeySms, smsConfig)
}
// 保存配置到数据库
func (s *MigrationService) saveConfig(key string, config any) error {
// 检查是否已存在
var existingConfig model.Config
if err := s.db.Where("name", key).First(&existingConfig).Error; err == nil {
// 配置已存在,跳过
logger.Infof("配置 %s 已存在,跳过迁移", key)
return nil
}
// 序列化配置
configJSON, err := json.Marshal(config)
if err != nil {
return err
}
// 保存到数据库
newConfig := model.Config{
Name: key,
Value: string(configJSON),
}
if err := s.db.Create(&newConfig).Error; err != nil {
return err
}
logger.Infof("成功迁移配置 %s", key)
return nil
}
// migrateSunoJobData 合并 suno_job 数据:从 Output 原始数据中解析出 tags 和 model_name 填入 params 字段
func (s *MigrationService) migrateSunoJobData() {
key := "migrate:suno_job_data"
if s.redisClient.Get(context.Background(), key).Val() == "1" {
logger.Info("SunoJob 数据已合并,跳过迁移")
return
}
logger.Info("开始合并 SunoJob 数据...")
// 查询所有有 output 数据的记录
var jobs []model.SunoJob
if err := s.db.Where("output != ? AND output != ''", "").Find(&jobs).Error; err != nil {
logger.Errorf("查询 SunoJob 数据失败: %v", err)
return
}
updatedCount := 0
for _, job := range jobs {
if job.Output == "" {
continue
}
// 解析 Output JSON 数据
var outputData struct {
Metadata struct {
Tags string `json:"tags"`
} `json:"metadata"`
ModelName string `json:"model_name"`
}
if err := json.Unmarshal([]byte(job.Output), &outputData); err != nil {
logger.Warnf("解析 Output 数据失败 (ID: %d): %v", job.Id, err)
continue
}
// 检查是否需要更新 params
needUpdate := false
params := job.Params
// 如果 params 中的 tags 为空,但 output 中有 tags,则更新
if params.Tags == "" && outputData.Metadata.Tags != "" {
params.Tags = outputData.Metadata.Tags
// 修复 tags 字段过长导致更新失败
if len(params.Tags) > 255 {
params.Tags = params.Tags[:255]
}
needUpdate = true
}
// 如果 params 中的 model 为空,但 output 中有 model_name,则更新
if params.Model == "" && outputData.ModelName != "" {
params.Model = outputData.ModelName
needUpdate = true
}
// 如果需要更新,则保存
if needUpdate {
if err := s.db.Model(&model.SunoJob{}).Where("id = ?", job.Id).Update("params", params).Error; err != nil {
logger.Errorf("更新 SunoJob 数据失败 (ID: %d): %v", job.Id, err)
continue
}
updatedCount++
}
}
logger.Infof("SunoJob 数据合并完成,共更新 %d 条记录", updatedCount)
s.redisClient.Set(context.Background(), key, "1", 0)
}