package schema import ( "context" "fmt" "os" "path/filepath" "sort" "strings" "time" "yh_web/server/config" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" ) // 应用需要的集合及索引(与 MongoDB/create_collections.js、sql/init.sql 一致) var requiredCollections = map[string][]indexSpec{ "sites": {{Keys: bson.D{{Key: "created_at", Value: -1}}, Name: "idx_created_at"}}, "pages": {{Keys: bson.D{{Key: "site_id", Value: 1}, {Key: "slug", Value: 1}}, Name: "idx_site_slug", Unique: true}}, "site_assets": {{Keys: bson.D{{Key: "site_id", Value: 1}}, Name: "idx_site_id"}}, "users": {{Keys: bson.D{{Key: "username", Value: 1}}, Name: "idx_username", Unique: true}, {Keys: bson.D{{Key: "mobile", Value: 1}}, Name: "idx_mobile", Sparse: true}}, "workspaces": {{Keys: bson.D{{Key: "user_id", Value: 1}}, Name: "idx_user_id"}}, "conversations": {{Keys: bson.D{{Key: "user_id", Value: 1}}, Name: "idx_user_id"}, {Keys: bson.D{{Key: "workspace_id", Value: 1}}, Name: "idx_workspace_id"}}, "messages": {{Keys: bson.D{{Key: "conversation_id", Value: 1}}, Name: "idx_conversation_id"}}, "files": {{Keys: bson.D{{Key: "user_id", Value: 1}}, Name: "idx_user_id"}}, "system_config": {}, "role_permissions": {{Keys: bson.D{{Key: "role_id", Value: 1}}, Name: "idx_role_id", Unique: true}}, "site_users": {{Keys: bson.D{{Key: "username", Value: 1}}, Name: "idx_username", Unique: true}}, } type indexSpec struct { Keys bson.D Name string Unique bool Sparse bool } // 每个集合对应的单表 SQL DDL(仅 CREATE TABLE 段落,用于 created_*.sql);反引号用 \x60 表示 var tableDDL = map[string]string{ "sites": "CREATE TABLE IF NOT EXISTS \x60sites\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键,与 MongoDB ObjectID 字符串一致',\n \x60name\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '站点名称',\n \x60domain\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '域名',\n \x60description\x60 TEXT COMMENT '描述',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_created_at\x60 (\x60created_at\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='站点表';", "pages": "CREATE TABLE IF NOT EXISTS \x60pages\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60site_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '站点ID',\n \x60slug\x60 VARCHAR(128) NOT NULL DEFAULT '' COMMENT '路径标识 index, about, ...',\n \x60title\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '标题',\n \x60type\x60 VARCHAR(32) NOT NULL DEFAULT 'page' COMMENT '类型 homepage, page',\n \x60content\x60 LONGTEXT COMMENT 'HTML 或 JSON 字符串',\n \x60updated_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_site_slug\x60 (\x60site_id\x60, \x60slug\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='网页表';", "site_assets": "CREATE TABLE IF NOT EXISTS \x60site_assets\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60site_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '站点ID',\n \x60name\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '文件名/显示名',\n \x60file_path\x60 VARCHAR(512) NOT NULL DEFAULT '' COMMENT '相对路径',\n \x60size\x60 BIGINT NOT NULL DEFAULT 0 COMMENT '字节数',\n \x60content_type\x60 VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'MIME 类型',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_site_id\x60 (\x60site_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='站点资源表';", "users": "CREATE TABLE IF NOT EXISTS \x60users\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60username\x60 VARCHAR(64) NOT NULL DEFAULT '' COMMENT '用户名',\n \x60mobile\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '手机号',\n \x60email\x60 VARCHAR(128) NOT NULL DEFAULT '' COMMENT '邮箱',\n \x60password\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '密码哈希',\n \x60role\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '角色名',\n \x60role_id\x60 INT NOT NULL DEFAULT 1 COMMENT '9527=超级管理员 1=普通用户',\n \x60is_beta\x60 TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否体验用户',\n \x60trial_start_date\x60 VARCHAR(16) NOT NULL DEFAULT '' COMMENT '试用开始日期',\n \x60trial_end_date\x60 VARCHAR(16) NOT NULL DEFAULT '' COMMENT '试用结束日期',\n \x60last_login\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '最后登录时间',\n \x60llm\x60 VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'LLM 配置',\n PRIMARY KEY (\x60id\x60),\n UNIQUE KEY \x60uk_username\x60 (\x60username\x60),\n KEY \x60idx_mobile\x60 (\x60mobile\x60),\n KEY \x60idx_email\x60 (\x60email\x60),\n KEY \x60idx_role_id\x60 (\x60role_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';", "workspaces": "CREATE TABLE IF NOT EXISTS \x60workspaces\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60name\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '名称',\n \x60user_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '用户ID',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_user_id\x60 (\x60user_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工作空间表';", "conversations": "CREATE TABLE IF NOT EXISTS \x60conversations\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60title\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '标题',\n \x60user_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '用户ID',\n \x60workspace_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '工作空间ID',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n \x60updated_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_user_id\x60 (\x60user_id\x60),\n KEY \x60idx_workspace_id\x60 (\x60workspace_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='对话表';", "messages": "CREATE TABLE IF NOT EXISTS \x60messages\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60conversation_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '对话ID',\n \x60role\x60 VARCHAR(16) NOT NULL DEFAULT '' COMMENT 'role: user/assistant/system',\n \x60content\x60 LONGTEXT COMMENT '内容',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_conversation_id\x60 (\x60conversation_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息表';", "files": "CREATE TABLE IF NOT EXISTS \x60files\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60user_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '用户ID',\n \x60name\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '文件名',\n \x60file_path\x60 VARCHAR(512) NOT NULL DEFAULT '' COMMENT '存储路径',\n \x60size\x60 BIGINT NOT NULL DEFAULT 0 COMMENT '字节数',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_user_id\x60 (\x60user_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文件表';", "system_config": "CREATE TABLE IF NOT EXISTS \x60system_config\x60 (\n \x60id\x60 VARCHAR(64) NOT NULL COMMENT '配置键 payment, sms_platform 等',\n \x60payload\x60 JSON COMMENT '配置内容(支付/短信等)',\n \x60updated_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新时间',\n PRIMARY KEY (\x60id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置表';", "site_users": "CREATE TABLE IF NOT EXISTS \x60site_users\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60username\x60 VARCHAR(64) NOT NULL DEFAULT '' COMMENT '登录名',\n \x60password_hash\x60 VARCHAR(128) NOT NULL DEFAULT '' COMMENT '密码哈希',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间(UTC)',\n PRIMARY KEY (\x60id\x60),\n UNIQUE KEY \x60uk_username\x60 (\x60username\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='前台用户(弹幕)';", } // CollectionInfo 线上集合信息(名称、文档数、索引) type CollectionInfo struct { Name string `json:"name"` Count int64 `json:"count"` Indexes []string `json:"indexes"` // 索引名或 key 描述 } // Sync 连接 MongoDB 后调用:获取线上表结构并生成 sql;缺失的集合在线上创建,并生成本次创建的 sql // projectRoot 为项目根目录(sql/ 所在目录) func Sync(ctx context.Context, projectRoot string) error { db := config.GetDB(config.DBName) if db == nil { return nil } sqlDir := filepath.Join(projectRoot, "sql") _ = os.MkdirAll(sqlDir, 0755) // 1. 获取当前已有集合 existing, err := db.ListCollectionNames(ctx, bson.M{}) if err != nil { return fmt.Errorf("列出集合失败: %w", err) } existingSet := make(map[string]bool) for _, n := range existing { existingSet[n] = true } // 2. 缺失的集合在线上创建(集合 + 索引) var created []string for collName, idxSpecs := range requiredCollections { if !existingSet[collName] { if err := db.CreateCollection(ctx, collName); err != nil { return fmt.Errorf("创建集合 %s 失败: %w", collName, err) } created = append(created, collName) coll := db.Collection(collName) for _, spec := range idxSpecs { opts := options.Index().SetName(spec.Name) if spec.Unique { opts.SetUnique(true) } if spec.Sparse { opts.SetSparse(true) } _, _ = coll.Indexes().CreateOne(ctx, mongo.IndexModel{Keys: spec.Keys, Options: opts}) } existingSet[collName] = true } } sort.Strings(created) // 3. 拉取线上结构:每个集合的名称、文档数、索引列表 allNames := make([]string, 0, len(existingSet)) for n := range existingSet { allNames = append(allNames, n) } sort.Strings(allNames) infos := make([]CollectionInfo, 0, len(allNames)) for _, name := range allNames { coll := db.Collection(name) count, _ := coll.CountDocuments(ctx, bson.M{}) indexLines := listIndexes(ctx, coll) infos = append(infos, CollectionInfo{Name: name, Count: count, Indexes: indexLines}) } ts := time.Now().Format("20060102_150405") // 4. 生成「线上表结构」的 sql 文件 if err := writeOnlineSchemaSQL(sqlDir, config.DBName, ts, infos); err != nil { return err } // 5. 若有本次创建的集合,生成「需要创建的 sql」并在本地写入(线上已通过上面 CreateCollection 创建) if len(created) > 0 { if err := writeCreatedSQL(sqlDir, ts, created); err != nil { return err } } return nil } func listIndexes(ctx context.Context, coll *mongo.Collection) []string { cursor, err := coll.Indexes().List(ctx) if err != nil { return nil } defer cursor.Close(ctx) var lines []string for cursor.Next(ctx) { var spec bson.M if err := cursor.Decode(&spec); err != nil { continue } name, _ := spec["name"].(string) key, _ := spec["key"].(bson.M) if name != "" { lines = append(lines, fmt.Sprintf("%s: %v", name, key)) } } return lines } func writeOnlineSchemaSQL(sqlDir, dbName, ts string, infos []CollectionInfo) error { var b strings.Builder b.WriteString("-- 线上 MongoDB 表结构快照(对应数据库: " + dbName + ")\n") b.WriteString("-- 生成时间: " + time.Now().Format("2006-01-02 15:04:05") + "\n") b.WriteString("-- 以下为集合与索引说明;等效 MySQL 建表见 sql/init.sql\n\n") b.WriteString("SET NAMES utf8mb4;\n\n") for _, info := range infos { b.WriteString("-- -------------------------------\n") b.WriteString("-- 集合: " + info.Name + " (文档数: " + fmt.Sprintf("%d", info.Count) + ")\n") b.WriteString("-- -------------------------------\n") for _, idx := range info.Indexes { b.WriteString("-- 索引: " + idx + "\n") } if len(info.Indexes) == 0 { b.WriteString("-- 索引: _id\n") } b.WriteString("\n") // 附上等效 CREATE TABLE(便于对照) if ddl, ok := tableDDL[info.Name]; ok { b.WriteString(ddl + "\n\n") } } fpath := filepath.Join(sqlDir, "online_schema_"+ts+".sql") return os.WriteFile(fpath, []byte(b.String()), 0644) } func writeCreatedSQL(sqlDir, ts string, created []string) error { var b strings.Builder b.WriteString("-- 本次启动时在线上缺失并已创建的集合,对应 SQL 建表(MySQL 等效)\n") b.WriteString("-- 生成时间: " + time.Now().Format("2006-01-02 15:04:05") + "\n") b.WriteString("-- 线上 MongoDB 已通过 CreateCollection 创建;本文件供留档与 SQL 环境对照。\n\n") b.WriteString("SET NAMES utf8mb4;\n\n") for _, name := range created { if ddl, ok := tableDDL[name]; ok { b.WriteString("-- " + name + "\n") b.WriteString(ddl + "\n\n") } } fpath := filepath.Join(sqlDir, "created_"+ts+".sql") return os.WriteFile(fpath, []byte(b.String()), 0644) }