// Package database 负责数据库连接初始化、自动迁移与种子数据。 // // 这些引导职责从 model 包拆分而来:model 包应只包含纯数据结构定义与 // GORM 钩子,不应依赖 config 或持有启动逻辑。本包依赖 config + model, // 方向合法(引导层 → 模型层 / 配置层)。 package database import ( "fmt" "log" "os" "time" "go.uber.org/zap" "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "gorm.io/plugin/dbresolver" "with_you/internal/config" "with_you/internal/model" ) // NewDB 创建数据库连接(用于 Wire 依赖注入) func NewDB(cfg *config.DatabaseConfig) (*gorm.DB, error) { var err error var db *gorm.DB gormLogger := logger.New( log.New(os.Stdout, "\r\n", log.LstdFlags), logger.Config{ SlowThreshold: time.Duration(cfg.SlowThresholdMs) * time.Millisecond, LogLevel: parseGormLogLevel(cfg.LogLevel), IgnoreRecordNotFoundError: cfg.IgnoreRecordNotFound, ParameterizedQueries: cfg.ParameterizedQueries, Colorful: false, }, ) // 根据数据库类型选择驱动 switch cfg.Type { case "sqlite": db, err = gorm.Open(sqlite.Open(cfg.SQLite.Path), &gorm.Config{ Logger: gormLogger, }) case "postgres", "postgresql": dsn := cfg.Postgres.DSN() db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{ Logger: gormLogger, }) default: // 默认使用PostgreSQL dsn := cfg.Postgres.DSN() db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{ Logger: gormLogger, }) } if err != nil { return nil, fmt.Errorf("failed to connect to database: %w", err) } // 配置连接池(SQLite不支持连接池配置,跳过) if cfg.Type != "sqlite" { sqlDB, err := db.DB() if err != nil { return nil, fmt.Errorf("failed to get database instance: %w", err) } sqlDB.SetMaxIdleConns(cfg.MaxIdleConns) sqlDB.SetMaxOpenConns(cfg.MaxOpenConns) } // 配置读写分离(仅 PostgreSQL 且配置了读副本时生效) if (cfg.Type == "postgres" || cfg.Type == "postgresql") && cfg.HasReplica() { allReplicas := cfg.AllReplicas() replicaDialectors := make([]gorm.Dialector, len(allReplicas)) for i, replica := range allReplicas { replicaDialectors[i] = postgres.Open(replica.DSN()) } replicaMaxIdle := cfg.ReplicaMaxIdle if replicaMaxIdle == 0 { replicaMaxIdle = cfg.MaxIdleConns } replicaMaxOpen := cfg.ReplicaMaxOpen if replicaMaxOpen == 0 { replicaMaxOpen = cfg.MaxOpenConns } resolver := dbresolver.Register(dbresolver.Config{ Replicas: replicaDialectors, }). SetMaxIdleConns(replicaMaxIdle). SetMaxOpenConns(replicaMaxOpen). SetConnMaxLifetime(time.Hour) if err := db.Use(resolver); err != nil { return nil, fmt.Errorf("failed to register dbresolver plugin: %w", err) } zap.L().Info("Database read/write splitting configured", zap.String("type", cfg.Type), zap.Int("replica_count", len(allReplicas)), ) } // 自动迁移 if err := autoMigrate(db); err != nil { return nil, fmt.Errorf("failed to auto migrate: %w", err) } zap.L().Info("Database connected and migrated successfully", zap.String("type", cfg.Type), ) return db, nil } func parseGormLogLevel(level string) logger.LogLevel { switch level { case "silent": return logger.Silent case "error": return logger.Error case "warn": return logger.Warn case "info": return logger.Info default: return logger.Warn } } // autoMigrate 自动迁移数据库表 func autoMigrate(db *gorm.DB) error { err := db.AutoMigrate( // 用户相关 &model.User{}, // 帖子相关 &model.Post{}, &model.PostImage{}, &model.Channel{}, // 评论相关 &model.Comment{}, &model.CommentLike{}, // 消息相关(使用雪花算法ID和seq机制) // 已读位置存储在 ConversationParticipant.LastReadSeq 中 &model.Conversation{}, &model.ConversationParticipant{}, &model.Message{}, // 系统通知(独立表,每个用户只能看到自己的通知) &model.SystemNotification{}, // 通知 &model.Notification{}, // 推送中心相关 &model.PushRecord{}, // 推送记录 &model.DeviceToken{}, // 设备Token // 社交 &model.Follow{}, &model.UserBlock{}, &model.PostLike{}, &model.Favorite{}, // 投票 &model.VoteOption{}, &model.UserVote{}, // 敏感词和审核 &model.SensitiveWord{}, // &model.AuditLog{}, // TODO: define AuditLog model // 举报 &model.Report{}, // 日志相关 &model.OperationLog{}, // 操作日志 &model.LoginLog{}, // 登录日志 &model.DataChangeLog{}, // 数据变更日志 // 群组相关 &model.Group{}, &model.GroupMember{}, &model.GroupAnnouncement{}, &model.GroupJoinRequest{}, // 自定义表情 &model.UserSticker{}, // 课表 &model.ScheduleCourse{}, // 成绩与考试 &model.Grade{}, &model.GpaSummary{}, &model.Exam{}, &model.EmptyClassroom{}, // 用户活跃相关 &model.UserActiveLog{}, &model.UserActivityStat{}, // 角色和权限相关 &model.Role{}, &model.UserRole{}, &model.CasbinRule{}, // 学习资料相关 &model.MaterialSubject{}, &model.MaterialFile{}, // 通话相关 &model.CallSession{}, &model.CallParticipant{}, // 身份认证相关 &model.VerificationRecord{}, // 用户资料审核相关 &model.UserProfileAudit{}, // 帖子内链引用关系 &model.PostReference{}, // 二手交易相关 &model.TradeItem{}, &model.TradeImage{}, &model.TradeFavorite{}, // 文件上传记录(用于聊天文件 TTL 过期清理) &model.UploadedFile{}, // 会话(令牌撤销支持) &model.Session{}, ) if err != nil { return err } // AutoMigrate 不会删除列:清理已废弃的 posts.hot_score if err := dropPostsHotScoreColumnIfExists(db); err != nil { return fmt.Errorf("drop legacy posts.hot_score: %w", err) } // 初始化角色种子数据 if err := seedRoles(db); err != nil { return fmt.Errorf("failed to seed roles: %w", err) } // 初始化权限策略种子数据 if err := seedPermissions(db); err != nil { return fmt.Errorf("failed to seed permissions: %w", err) } // 清理历史 Casbin g 分组规则(真源已统一为 user_roles 表,Casbin 仅保留 p 策略)。 // 幂等:仅在 casbin_rule 中存在 ptype='g' 记录时删除。 if err := cleanupLegacyCasbinGRules(db); err != nil { return fmt.Errorf("failed to cleanup legacy casbin g rules: %w", err) } // 初始化频道配置种子数据 if err := seedChannels(db); err != nil { return fmt.Errorf("failed to seed channels: %w", err) } // 初始化学习资料学科种子数据 if err := seedMaterialSubjects(db); err != nil { return fmt.Errorf("failed to seed material subjects: %w", err) } return nil } // postWithHotScore 仅用于 Migrator 检测/删除旧列(Post 模型已移除 HotScore) type postWithHotScore struct { HotScore float64 `gorm:"column:hot_score"` } func (postWithHotScore) TableName() string { return "posts" } func dropPostsHotScoreColumnIfExists(db *gorm.DB) error { m := db.Migrator() if !m.HasTable(&model.Post{}) { return nil } if !m.HasColumn(&postWithHotScore{}, "HotScore") { return nil } return m.DropColumn(&postWithHotScore{}, "HotScore") } // cleanupLegacyCasbinGRules 清理历史 Casbin g 分组规则。 // // 真源策略变更后:user_roles 表为用户-角色唯一真源,Casbin 仅保留 p, role, resource, action 策略。 // 旧的 g, user, role 记录不再被 matcher 使用(model.conf 已移除 g 依赖),保留只会造成管理后台误解。 // 幂等:仅删除 ptype='g' 的记录;若不存在则无操作。 func cleanupLegacyCasbinGRules(db *gorm.DB) error { if !db.Migrator().HasTable(&model.CasbinRule{}) { return nil } result := db.Where("ptype = ?", "g").Delete(&model.CasbinRule{}) if result.Error != nil { return result.Error } if result.RowsAffected > 0 { zap.L().Info("Cleaned up legacy casbin g rules", zap.Int64("count", result.RowsAffected), ) } return nil } // seedRoles 初始化角色种子数据 func seedRoles(db *gorm.DB) error { // 检查是否已有角色数据 var count int64 if err := db.Model(&model.Role{}).Count(&count).Error; err != nil { return err } // 如果已有数据,跳过种子初始化 if count > 0 { return nil } // 预定义角色数据 roles := []model.Role{ { Name: model.RoleSuperAdmin, DisplayName: "超级管理员", Description: "拥有系统最高权限,可以管理所有用户和内容", Priority: 100, }, { Name: model.RoleAdmin, DisplayName: "管理员", Description: "系统管理员,可以管理用户和内容", Priority: 80, }, { Name: model.RoleModerator, DisplayName: "版主", Description: "内容审核员,可以审核和管理内容", Priority: 60, }, { Name: model.RoleUser, DisplayName: "普通用户", Description: "注册用户,拥有基本权限", Priority: 40, }, { Name: model.RoleBanned, DisplayName: "被封禁用户", Description: "被禁止访问的用户", Priority: 0, }, } // 批量插入角色 if err := db.Create(&roles).Error; err != nil { return err } zap.L().Info("Seeded roles successfully", zap.Int("count", len(roles)), ) return nil } // seedPermissions 初始化权限策略种子数据 // // 真源策略:user_roles 表为用户-角色真源,Casbin 仅维护 p, role, resource, action 策略。 // 资源命名约定:admin/[/](路径式,配合 globMatch 中 * 不跨 /、** 跨 / 的语义)。 // // 升级兼容:旧版本以 URL 路径式资源(如 /api/v1/admin/*、/*)作为 Casbin 策略, // 新版本改为 admin/ 资源命名后,存量部署需要迁移。本函数检测到任何 v1 以 '/' 开头的 // 旧策略时,将其视为旧版本部署:清空所有 p 策略并按新约定重新 seed。 // 新部署无此条件不触发,幂等(admin/ 不以 '/' 开头,不会误判为新旧迁移)。 func seedPermissions(db *gorm.DB) error { // 检查是否已有权限策略数据 var count int64 if err := db.Model(&model.CasbinRule{}).Where("ptype = ?", "p").Count(&count).Error; err != nil { return err } if count > 0 { // 检测是否存在旧版本路径式策略(v1 以 '/' 开头)。 // 旧策略例如:{p, super_admin, /*, *} / {p, admin, /api/v1/admin/*, *} 等。 // 新约定资源形如 admin/users(不以 '/' 开头),不会被本条件误命中。 // 旧策略在新 model.conf 的 admin/ 命名下永远不会匹配,保留只会让管理后台权限全断。 var legacyCount int64 if err := db.Model(&model.CasbinRule{}). Where("ptype = ? AND v1 LIKE '/%'", "p"). Count(&legacyCount).Error; err != nil { return err } if legacyCount == 0 { // 已是新版本策略,无需重复 seed。 return nil } // 命中旧策略:迁移。先 dump 待删除策略到日志便于回查,再删除全部 p 策略。 zap.L().Info("Migrating legacy path-based casbin p rules to admin/ resource naming", zap.Int64("legacy_count", legacyCount), zap.Int64("total_p_count", count), ) var legacyRules []model.CasbinRule if err := db.Where("ptype = ?", "p").Find(&legacyRules).Error; err != nil { return err } for _, r := range legacyRules { zap.L().Debug("legacy casbin p rule will be removed", zap.String("v0", r.V0), zap.String("v1", r.V1), zap.String("v2", r.V2), ) } if err := db.Where("ptype = ?", "p").Delete(&model.CasbinRule{}).Error; err != nil { return err } // 注意:count > 0 分支到此已清空所有 p 策略,下面统一重新 seed。 } // 预定义权限策略数据 // p = sub, obj, act (角色, 资源, 操作) // // 资源命名约定:admin/[/](路径式),动作:read / write / *。 // super_admin 通过 admin/** 通配获得所有管理能力(globMatch 中 ** 跨 '/'); // admin 仅获得业务管理能力,角色与权限管理(admin/roles/*、admin/users/roles/*) // 与日志导出(admin/logs/export)仅 super_admin 拥有(admin/logs 单层匹配不跨 /)。 permissions := []model.CasbinRule{ // 超级管理员 - 通配所有 admin 资源(globMatch:** 跨 '/' 分隔的多级资源) {Ptype: "p", V0: model.RoleSuperAdmin, V1: "admin/**", V2: "*"}, // 管理员 - 业务管理能力(不含角色/权限管理与日志导出) {Ptype: "p", V0: model.RoleAdmin, V1: "admin/users", V2: "read"}, {Ptype: "p", V0: model.RoleAdmin, V1: "admin/users/status", V2: "write"}, {Ptype: "p", V0: model.RoleAdmin, V1: "admin/users/devices", V2: "read"}, {Ptype: "p", V0: model.RoleAdmin, V1: "admin/posts", V2: "*"}, {Ptype: "p", V0: model.RoleAdmin, V1: "admin/comments", V2: "*"}, {Ptype: "p", V0: model.RoleAdmin, V1: "admin/groups", V2: "*"}, {Ptype: "p", V0: model.RoleAdmin, V1: "admin/channels", V2: "*"}, {Ptype: "p", V0: model.RoleAdmin, V1: "admin/dashboard", V2: "read"}, {Ptype: "p", V0: model.RoleAdmin, V1: "admin/reports", V2: "*"}, {Ptype: "p", V0: model.RoleAdmin, V1: "admin/verifications", V2: "*"}, {Ptype: "p", V0: model.RoleAdmin, V1: "admin/profile_audits", V2: "*"}, {Ptype: "p", V0: model.RoleAdmin, V1: "admin/materials", V2: "*"}, {Ptype: "p", V0: model.RoleAdmin, V1: "admin/logs", V2: "read"}, // 版主 - 内容审核 {Ptype: "p", V0: model.RoleModerator, V1: "admin/posts", V2: "read"}, {Ptype: "p", V0: model.RoleModerator, V1: "admin/posts", V2: "write"}, {Ptype: "p", V0: model.RoleModerator, V1: "admin/comments", V2: "read"}, {Ptype: "p", V0: model.RoleModerator, V1: "admin/comments", V2: "write"}, {Ptype: "p", V0: model.RoleModerator, V1: "admin/reports", V2: "read"}, {Ptype: "p", V0: model.RoleModerator, V1: "admin/reports", V2: "write"}, // 普通用户与被封禁用户:admin 路由外的资源由路由层中间件保障,Casbin 不维护。 } // 批量插入权限策略 if err := db.Create(&permissions).Error; err != nil { return err } zap.L().Info("Seeded permissions successfully", zap.Int("count", len(permissions)), ) return nil } // seedChannels 初始化频道种子数据(仅在表为空时) func seedChannels(db *gorm.DB) error { // 检查是否已有频道数据 var count int64 if err := db.Model(&model.Channel{}).Count(&count).Error; err != nil { return err } if count > 0 { zap.L().Info("Channels already exist, skipping seed", zap.Int64("count", count)) return nil } defaultChannels := []model.Channel{ {Name: "跳蚤市场", Slug: "market", Description: "二手交易与闲置发布", SortOrder: 10, IsActive: true}, {Name: "课程交流", Slug: "courses", Description: "课程资料、选课与学习讨论", SortOrder: 20, IsActive: true}, {Name: "工作实习", Slug: "jobs", Description: "实习、内推与求职信息", SortOrder: 30, IsActive: true}, {Name: "考研考公", Slug: "exam", Description: "备考经验、资料与互助", SortOrder: 40, IsActive: true}, {Name: "保研出国", Slug: "graduate-abroad", Description: "保研、留学申请与经验分享", SortOrder: 50, IsActive: true}, } if err := db.Create(&defaultChannels).Error; err != nil { return err } zap.L().Info("Seeded channels successfully", zap.Int("count", len(defaultChannels))) return nil } // seedMaterialSubjects 初始化学习资料学科种子数据(仅在表为空时) func seedMaterialSubjects(db *gorm.DB) error { // 检查是否已有数据 var count int64 if err := db.Model(&model.MaterialSubject{}).Count(&count).Error; err != nil { return err } if count > 0 { zap.L().Info("Material subjects already exist, skipping seed", zap.Int64("count", count)) return nil } defaultSubjects := []model.MaterialSubject{ {Name: "数学", Icon: "calculator-variant", Color: "#FF6B6B", Description: "高等数学、线性代数、概率论等", SortOrder: 10, IsActive: true}, {Name: "英语", Icon: "translate", Color: "#4ECDC4", Description: "四六级、考研英语、托福雅思等", SortOrder: 20, IsActive: true}, {Name: "物理", Icon: "atom", Color: "#45B7D1", Description: "大学物理、电磁学、力学等", SortOrder: 30, IsActive: true}, {Name: "化学", Icon: "flask", Color: "#96CEB4", Description: "有机化学、无机化学、分析化学等", SortOrder: 40, IsActive: true}, {Name: "计算机", Icon: "laptop", Color: "#FFEAA7", Description: "数据结构、算法、操作系统等", SortOrder: 50, IsActive: true}, {Name: "经济管理", Icon: "chart-line", Color: "#DDA0DD", Description: "微观经济学、宏观经济学、管理学等", SortOrder: 60, IsActive: true}, {Name: "法学", Icon: "scale-balance", Color: "#F39C12", Description: "民法、刑法、宪法等", SortOrder: 70, IsActive: true}, {Name: "语文文学", Icon: "book-open-page-variant", Color: "#3498DB", Description: "古代文学、现代文学、写作等", SortOrder: 80, IsActive: true}, } if err := db.Create(&defaultSubjects).Error; err != nil { return err } zap.L().Info("Seeded material subjects successfully", zap.Int("count", len(defaultSubjects))) return nil }