Files
web/server/pkg/schema/sync.go
whm 0800982224 feat: 分片上传断点续传、临时目录后台配置与清扫、宇恒云账号管理
- 管理端大文件分片上传与 sessionStorage 续传;Nginx 大请求体/超时
- .chunk-uploads 定期清扫;system_config 后台配置保留时长与扫描间隔
- 宇恒云 POST /register 对接与 yuheng_cloud_register_records 留痕;yuheng_cloud:manage 权限

Made-with: Cursor
2026-04-13 14:50:27 +08:00

208 lines
14 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 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}},
"yuheng_cloud_register_records": {{Keys: bson.D{{Key: "created_at", Value: -1}}, Name: "idx_created_at"}},
}
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='前台用户(弹幕)';",
"yuheng_cloud_register_records": "CREATE TABLE IF NOT EXISTS \x60yuheng_cloud_register_records\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60username\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '账号',\n \x60password\x60 VARCHAR(512) NOT NULL DEFAULT '' 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='宇恒云注册请求本地留痕';",
}
// 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)
}