Files
backend/internal/database/database.go
lafay eb931bf1c6
All checks were successful
Build Backend / build (push) Successful in 2m19s
Build Backend / build-docker (push) Successful in 46s
feat(auth): implement session-based token management with revocation support
Add Session model, SessionService, and SessionRepository to track user login
sessions and enable token revocation on auth-critical events.

- Introduce explicit TokenType (access/refresh) in JWT claims to prevent
  refresh token misuse via access token endpoints
- Add SessionID field to JWT claims, enabling stateless JWT validation
  against revoked sessions
- Replace legacy Auth middleware with RequireAuth/OptionalAuth pipeline
  that validates token type, account status, and session validity
- Implement session revocation on password change, reset, user ban/inactive,
  and explicit logout
- Add Principal cache with active invalidation for banned/role-changed users
- Fix IDOR vulnerability: GetMessagesByCursor now validates currentUserID
  is conversation participant via GetParticipantStrict
- Add group member visibility checks: announcements, group info, member list
  now require group membership
- Simplify Casbin policy: remove g grouping, use r.sub == p.sub matcher
  with globMatch; user_roles table is single source of truth for roles
- Add migration logic to clean legacy casbin g rules and migrate old p rules
  from path-style to admin/<domain> resource naming
2026-07-05 18:28:08 +08:00

536 lines
17 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 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/<domain>[/<sub>](路径式,配合 globMatch 中 * 不跨 /、** 跨 / 的语义)。
//
// 升级兼容:旧版本以 URL 路径式资源(如 /api/v1/admin/*、/*)作为 Casbin 策略,
// 新版本改为 admin/<domain> 资源命名后,存量部署需要迁移。本函数检测到任何 v1 以 '/' 开头的
// 旧策略时,将其视为旧版本部署:清空所有 p 策略并按新约定重新 seed。
// 新部署无此条件不触发幂等admin/<domain> 不以 '/' 开头,不会误判为新旧迁移)。
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/<domain> 命名下永远不会匹配,保留只会让管理后台权限全断。
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/<domain> 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/<domain>[/<sub>]路径式动作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
}