refactor(server): decouple services and improve architecture
All checks were successful
Build Backend / build (push) Successful in 4m55s
Build Backend / build-docker (push) Successful in 10m34s

- Introduce interfaces for all major services (JWT, PostAI, Comment, Message, Notification, QRCodeLogin, Upload, Vote, etc.) to support dependency inversion.
- Move query parameters from `internal/dto` to a new `internal/query` package to separate request payloads from data transfer objects.
- Refactor `internal/router` to use embedded `RouterDeps` for cleaner dependency management.
- Decouple handlers from repositories by injecting services instead of direct repository access, ensuring proper layering.
- Improve database initialization by moving it from `internal/model` to `internal/database`.
- Optimize message decryption by implementing a more efficient `BatchDecrypt` method in `MessageEncryptor` using a worker pool.
- Enhance error handling and security by implementing fail-fast checks for encryption key length during startup.
- Clean up unused code, including the `avatar` package and several unused DTOs.
This commit is contained in:
2026-06-15 03:41:59 +08:00
parent 9951043034
commit d9aa4b46c3
78 changed files with 2156 additions and 1640 deletions

View File

@@ -0,0 +1,458 @@
// 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{},
)
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)
}
// 初始化频道配置种子数据
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")
}
// 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 初始化权限策略种子数据
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 {
return nil
}
// 预定义权限策略数据
// p = sub, obj, act (角色, 资源, 操作)
permissions := []model.CasbinRule{
// 超级管理员 - 拥有所有权限
{Ptype: "p", V0: model.RoleSuperAdmin, V1: "/*", V2: "*"},
// 管理员 - 拥有管理权限
{Ptype: "p", V0: model.RoleAdmin, V1: "/api/v1/admin/*", V2: "*"},
{Ptype: "p", V0: model.RoleAdmin, V1: "/api/v1/users/*", V2: "GET"},
{Ptype: "p", V0: model.RoleAdmin, V1: "/api/v1/posts/*", V2: "*"},
{Ptype: "p", V0: model.RoleAdmin, V1: "/api/v1/comments/*", V2: "*"},
{Ptype: "p", V0: model.RoleAdmin, V1: "/api/v1/groups/*", V2: "*"},
// 版主 - 拥有内容审核权限
{Ptype: "p", V0: model.RoleModerator, V1: "/api/v1/posts/*", V2: "GET"},
{Ptype: "p", V0: model.RoleModerator, V1: "/api/v1/posts/*", V2: "DELETE"},
{Ptype: "p", V0: model.RoleModerator, V1: "/api/v1/comments/*", V2: "GET"},
{Ptype: "p", V0: model.RoleModerator, V1: "/api/v1/comments/*", V2: "DELETE"},
{Ptype: "p", V0: model.RoleModerator, V1: "/api/v1/groups/*", V2: "GET"},
// 普通用户 - 拥有基本权限
{Ptype: "p", V0: model.RoleUser, V1: "/api/v1/posts", V2: "GET"},
{Ptype: "p", V0: model.RoleUser, V1: "/api/v1/posts/*", V2: "GET"},
{Ptype: "p", V0: model.RoleUser, V1: "/api/v1/posts", V2: "POST"},
{Ptype: "p", V0: model.RoleUser, V1: "/api/v1/comments/*", V2: "GET"},
{Ptype: "p", V0: model.RoleUser, V1: "/api/v1/comments", V2: "POST"},
{Ptype: "p", V0: model.RoleUser, V1: "/api/v1/users/me", V2: "GET"},
{Ptype: "p", V0: model.RoleUser, V1: "/api/v1/users/me", V2: "PUT"},
{Ptype: "p", V0: model.RoleUser, V1: "/api/v1/groups", V2: "GET"},
{Ptype: "p", V0: model.RoleUser, V1: "/api/v1/groups/*", V2: "GET"},
// 被封禁用户 - 无权限
}
// 批量插入权限策略
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
}

View File

@@ -0,0 +1,222 @@
package database
import (
"os"
"path/filepath"
"testing"
"with_you/internal/config"
"with_you/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// 注意:完整的 NewDB→autoMigrate 在 SQLite 下会因 model 包多个表共用
// 同名索引 idx_createddata_change_log / login_log / operation_log而冲突。
// 这是 model 包的既有约束PostgreSQL 经 CREATE INDEX IF NOT EXISTS 容忍),
// 与本次分层拆分无关。因此种子逻辑测试在隔离的 SQLite 库上验证:
// 仅迁移被种子函数读写的表,避开跨表索引冲突,专注验证种子正确性与幂等性。
// newSeedTestDB 创建一个仅迁移种子相关表的 SQLite 测试库。
func newSeedTestDB(t *testing.T) *gorm.DB {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "seed_test.db")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite failed: %v", err)
}
// 仅迁移种子函数涉及的表(无 idx_created 冲突)
if err := db.AutoMigrate(
&model.Role{},
&model.CasbinRule{},
&model.Channel{},
&model.MaterialSubject{},
&model.Post{}, // dropPostsHotScoreColumnIfExists 依赖 posts 表存在
); err != nil {
t.Fatalf("autoMigrate seed tables failed: %v", err)
}
t.Cleanup(func() {
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
})
return db
}
// TestSeedRoles 验证角色种子写入 5 个预定义角色。
func TestSeedRoles(t *testing.T) {
db := newSeedTestDB(t)
if err := seedRoles(db); err != nil {
t.Fatalf("seedRoles failed: %v", err)
}
var count int64
db.Model(&model.Role{}).Count(&count)
if count != 5 {
t.Errorf("expected 5 seeded roles, got %d", count)
}
expected := []string{
model.RoleSuperAdmin, model.RoleAdmin, model.RoleModerator,
model.RoleUser, model.RoleBanned,
}
for _, name := range expected {
var role model.Role
if err := db.Where("name = ?", name).First(&role).Error; err != nil {
t.Errorf("expected role %q: %v", name, err)
}
}
}
// TestSeedRoles_Idempotent 验证角色种子幂等:表非空时跳过。
func TestSeedRoles_Idempotent(t *testing.T) {
db := newSeedTestDB(t)
if err := seedRoles(db); err != nil {
t.Fatalf("first seedRoles: %v", err)
}
// 第二次调用应跳过(表已有数据)
if err := seedRoles(db); err != nil {
t.Fatalf("second seedRoles: %v", err)
}
var count int64
db.Model(&model.Role{}).Count(&count)
if count != 5 {
t.Errorf("idempotent seed failed: expected 5 roles, got %d", count)
}
}
// TestSeedPermissions 验证权限策略种子写入,含超级管理员通配规则。
func TestSeedPermissions(t *testing.T) {
db := newSeedTestDB(t)
if err := seedPermissions(db); err != nil {
t.Fatalf("seedPermissions failed: %v", err)
}
var count int64
db.Model(&model.CasbinRule{}).Where("ptype = ?", "p").Count(&count)
if count == 0 {
t.Fatal("expected seeded permissions, got 0")
}
// 超级管理员 /* 通配
var rule model.CasbinRule
if err := db.Where("ptype=? AND v0=? AND v1=?", "p", model.RoleSuperAdmin, "/*").First(&rule).Error; err != nil {
t.Errorf("expected superadmin wildcard permission: %v", err)
}
}
// TestSeedPermissions_Idempotent 验证权限种子幂等。
func TestSeedPermissions_Idempotent(t *testing.T) {
db := newSeedTestDB(t)
if err := seedPermissions(db); err != nil {
t.Fatalf("first seedPermissions: %v", err)
}
var count1 int64
db.Model(&model.CasbinRule{}).Where("ptype = ?", "p").Count(&count1)
if err := seedPermissions(db); err != nil {
t.Fatalf("second seedPermissions: %v", err)
}
var count2 int64
db.Model(&model.CasbinRule{}).Where("ptype = ?", "p").Count(&count2)
if count2 != count1 {
t.Errorf("seed not idempotent: first=%d, second=%d", count1, count2)
}
}
// TestSeedChannels 验证频道种子写入 5 个默认频道。
func TestSeedChannels(t *testing.T) {
db := newSeedTestDB(t)
if err := seedChannels(db); err != nil {
t.Fatalf("seedChannels failed: %v", err)
}
var count int64
db.Model(&model.Channel{}).Count(&count)
if count != 5 {
t.Errorf("expected 5 channels, got %d", count)
}
// 抽查一个频道
var ch model.Channel
if err := db.Where("slug = ?", "market").First(&ch).Error; err != nil {
t.Errorf("expected market channel: %v", err)
}
}
// TestSeedMaterialSubjects 验证学科种子写入 8 个默认学科。
func TestSeedMaterialSubjects(t *testing.T) {
db := newSeedTestDB(t)
if err := seedMaterialSubjects(db); err != nil {
t.Fatalf("seedMaterialSubjects failed: %v", err)
}
var count int64
db.Model(&model.MaterialSubject{}).Count(&count)
if count != 8 {
t.Errorf("expected 8 material subjects, got %d", count)
}
}
// TestParseGormLogLevel 验证日志级别解析映射。
func TestParseGormLogLevel(t *testing.T) {
cases := []struct {
in string
want logger.LogLevel
}{
{"silent", logger.Silent},
{"error", logger.Error},
{"warn", logger.Warn},
{"info", logger.Info},
{"", logger.Warn}, // 默认
{"bogus", logger.Warn}, // 未知值降级为 warn
}
for _, c := range cases {
if got := parseGormLogLevel(c.in); got != c.want {
t.Errorf("parseGormLogLevel(%q) = %v, want %v", c.in, got, c.want)
}
}
}
// TestDropPostsHotScoreColumnIfExists_NoTable 验证当 posts 表不存在时安全跳过。
func TestDropPostsHotScoreColumnIfExists_NoTable(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "empty.db")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
})
// 未迁移 posts 表 → 应安全返回 nil
if err := dropPostsHotScoreColumnIfExists(db); err != nil {
t.Errorf("expected nil when posts table absent, got: %v", err)
}
}
// 编译期断言:确保 NewDB 签名符合 (*config.DatabaseConfig) -> (*gorm.DB, error)
var _ = func() (*gorm.DB, error) {
return NewDB(&config.DatabaseConfig{})
}
var _ = os.RemoveAll // 保持 os 引用可用(供未来临时文件场景扩展)