refactor(server): decouple services and improve architecture
- 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:
@@ -1,453 +0,0 @@
|
||||
package model
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// 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(
|
||||
// 用户相关
|
||||
&User{},
|
||||
|
||||
// 帖子相关
|
||||
&Post{},
|
||||
&PostImage{},
|
||||
&Channel{},
|
||||
|
||||
// 评论相关
|
||||
&Comment{},
|
||||
&CommentLike{},
|
||||
|
||||
// 消息相关(使用雪花算法ID和seq机制)
|
||||
// 已读位置存储在 ConversationParticipant.LastReadSeq 中
|
||||
&Conversation{},
|
||||
&ConversationParticipant{},
|
||||
&Message{},
|
||||
|
||||
// 系统通知(独立表,每个用户只能看到自己的通知)
|
||||
&SystemNotification{},
|
||||
|
||||
// 通知
|
||||
&Notification{},
|
||||
|
||||
// 推送中心相关
|
||||
&PushRecord{}, // 推送记录
|
||||
&DeviceToken{}, // 设备Token
|
||||
|
||||
// 社交
|
||||
&Follow{},
|
||||
&UserBlock{},
|
||||
&PostLike{},
|
||||
&Favorite{},
|
||||
|
||||
// 投票
|
||||
&VoteOption{},
|
||||
&UserVote{},
|
||||
|
||||
// 敏感词和审核
|
||||
&SensitiveWord{},
|
||||
// &AuditLog{}, // TODO: define AuditLog model
|
||||
|
||||
// 举报
|
||||
&Report{},
|
||||
|
||||
// 日志相关
|
||||
&OperationLog{}, // 操作日志
|
||||
&LoginLog{}, // 登录日志
|
||||
&DataChangeLog{}, // 数据变更日志
|
||||
|
||||
// 群组相关
|
||||
&Group{},
|
||||
&GroupMember{},
|
||||
&GroupAnnouncement{},
|
||||
&GroupJoinRequest{},
|
||||
|
||||
// 自定义表情
|
||||
&UserSticker{},
|
||||
|
||||
// 课表
|
||||
&ScheduleCourse{},
|
||||
|
||||
// 成绩与考试
|
||||
&Grade{},
|
||||
&GpaSummary{},
|
||||
&Exam{},
|
||||
&EmptyClassroom{},
|
||||
|
||||
// 用户活跃相关
|
||||
&UserActiveLog{},
|
||||
&UserActivityStat{},
|
||||
|
||||
// 角色和权限相关
|
||||
&Role{},
|
||||
&UserRole{},
|
||||
&CasbinRule{},
|
||||
|
||||
// 学习资料相关
|
||||
&MaterialSubject{},
|
||||
&MaterialFile{},
|
||||
|
||||
// 通话相关
|
||||
&CallSession{},
|
||||
&CallParticipant{},
|
||||
|
||||
// 身份认证相关
|
||||
&VerificationRecord{},
|
||||
|
||||
// 用户资料审核相关
|
||||
&UserProfileAudit{},
|
||||
|
||||
// 帖子内链引用关系
|
||||
&PostReference{},
|
||||
|
||||
// 二手交易相关
|
||||
&TradeItem{},
|
||||
&TradeImage{},
|
||||
&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(&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(&Role{}).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果已有数据,跳过种子初始化
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 预定义角色数据
|
||||
roles := []Role{
|
||||
{
|
||||
Name: RoleSuperAdmin,
|
||||
DisplayName: "超级管理员",
|
||||
Description: "拥有系统最高权限,可以管理所有用户和内容",
|
||||
Priority: 100,
|
||||
},
|
||||
{
|
||||
Name: RoleAdmin,
|
||||
DisplayName: "管理员",
|
||||
Description: "系统管理员,可以管理用户和内容",
|
||||
Priority: 80,
|
||||
},
|
||||
{
|
||||
Name: RoleModerator,
|
||||
DisplayName: "版主",
|
||||
Description: "内容审核员,可以审核和管理内容",
|
||||
Priority: 60,
|
||||
},
|
||||
{
|
||||
Name: RoleUser,
|
||||
DisplayName: "普通用户",
|
||||
Description: "注册用户,拥有基本权限",
|
||||
Priority: 40,
|
||||
},
|
||||
{
|
||||
Name: 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(&CasbinRule{}).Where("ptype = ?", "p").Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果已有数据,跳过种子初始化
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 预定义权限策略数据
|
||||
// p = sub, obj, act (角色, 资源, 操作)
|
||||
permissions := []CasbinRule{
|
||||
// 超级管理员 - 拥有所有权限
|
||||
{Ptype: "p", V0: RoleSuperAdmin, V1: "/*", V2: "*"},
|
||||
|
||||
// 管理员 - 拥有管理权限
|
||||
{Ptype: "p", V0: RoleAdmin, V1: "/api/v1/admin/*", V2: "*"},
|
||||
{Ptype: "p", V0: RoleAdmin, V1: "/api/v1/users/*", V2: "GET"},
|
||||
{Ptype: "p", V0: RoleAdmin, V1: "/api/v1/posts/*", V2: "*"},
|
||||
{Ptype: "p", V0: RoleAdmin, V1: "/api/v1/comments/*", V2: "*"},
|
||||
{Ptype: "p", V0: RoleAdmin, V1: "/api/v1/groups/*", V2: "*"},
|
||||
|
||||
// 版主 - 拥有内容审核权限
|
||||
{Ptype: "p", V0: RoleModerator, V1: "/api/v1/posts/*", V2: "GET"},
|
||||
{Ptype: "p", V0: RoleModerator, V1: "/api/v1/posts/*", V2: "DELETE"},
|
||||
{Ptype: "p", V0: RoleModerator, V1: "/api/v1/comments/*", V2: "GET"},
|
||||
{Ptype: "p", V0: RoleModerator, V1: "/api/v1/comments/*", V2: "DELETE"},
|
||||
{Ptype: "p", V0: RoleModerator, V1: "/api/v1/groups/*", V2: "GET"},
|
||||
|
||||
// 普通用户 - 拥有基本权限
|
||||
{Ptype: "p", V0: RoleUser, V1: "/api/v1/posts", V2: "GET"},
|
||||
{Ptype: "p", V0: RoleUser, V1: "/api/v1/posts/*", V2: "GET"},
|
||||
{Ptype: "p", V0: RoleUser, V1: "/api/v1/posts", V2: "POST"},
|
||||
{Ptype: "p", V0: RoleUser, V1: "/api/v1/comments/*", V2: "GET"},
|
||||
{Ptype: "p", V0: RoleUser, V1: "/api/v1/comments", V2: "POST"},
|
||||
{Ptype: "p", V0: RoleUser, V1: "/api/v1/users/me", V2: "GET"},
|
||||
{Ptype: "p", V0: RoleUser, V1: "/api/v1/users/me", V2: "PUT"},
|
||||
{Ptype: "p", V0: RoleUser, V1: "/api/v1/groups", V2: "GET"},
|
||||
{Ptype: "p", V0: 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(&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 := []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(&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 := []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
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
package model
|
||||
package model
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -303,9 +302,14 @@ func (m *Message) IsInteractionNotification() bool {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// BatchDecryptMessagesParallel 使用 worker pool 并行解密
|
||||
// 这是一个更高效的版本,适用于大量消息
|
||||
// BatchDecryptMessagesParallel 批量解密消息段。
|
||||
// 并发逻辑收敛在 crypto.MessageEncryptor.BatchDecrypt(worker pool),
|
||||
// 本函数仅负责「提取密文 → 回填明文到 Message.Segments」,不再自起 goroutine。
|
||||
//
|
||||
// 行为保持与历史版本一致:
|
||||
// - 加密器未初始化时,降级为明文 JSON 直接解析(兼容旧数据);
|
||||
// - 单条解密失败时回退尝试直接 JSON 解析(兼容未加密旧数据);
|
||||
// - 已解密或无加密内容的消息跳过。
|
||||
func BatchDecryptMessagesParallel(messages []*Message) {
|
||||
if len(messages) == 0 {
|
||||
return
|
||||
@@ -313,7 +317,7 @@ func BatchDecryptMessagesParallel(messages []*Message) {
|
||||
|
||||
encryptor := crypto.GetMessageEncryptor()
|
||||
if encryptor == nil {
|
||||
// 加密器未初始化,串行解析
|
||||
// 加密器未初始化,串行解析为明文 JSON(兼容模式)
|
||||
for _, m := range messages {
|
||||
if !m.decrypted && m.SegmentsEncrypted != "" {
|
||||
_ = json.Unmarshal([]byte(m.SegmentsEncrypted), &m.Segments)
|
||||
@@ -323,46 +327,34 @@ func BatchDecryptMessagesParallel(messages []*Message) {
|
||||
return
|
||||
}
|
||||
|
||||
// 确定并行度
|
||||
workerCount := 4
|
||||
if len(messages) < 20 {
|
||||
workerCount = 2
|
||||
} else if len(messages) > 100 {
|
||||
workerCount = 8
|
||||
// 提取待解密的密文索引与值
|
||||
indices := make([]int, 0, len(messages))
|
||||
ciphertexts := make([]string, 0, len(messages))
|
||||
for i, m := range messages {
|
||||
if m.decrypted || m.SegmentsEncrypted == "" {
|
||||
m.decrypted = true
|
||||
continue
|
||||
}
|
||||
indices = append(indices, i)
|
||||
ciphertexts = append(ciphertexts, m.SegmentsEncrypted)
|
||||
}
|
||||
if len(ciphertexts) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
jobs := make(chan int, len(messages))
|
||||
var wg sync.WaitGroup
|
||||
// 并发解密(worker pool 由 crypto.BatchDecrypt 内部管理)
|
||||
plaintexts := encryptor.BatchDecrypt(ciphertexts, 0)
|
||||
|
||||
// 启动 workers
|
||||
for w := 0; w < workerCount; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := range jobs {
|
||||
m := messages[i]
|
||||
if m.decrypted || m.SegmentsEncrypted == "" {
|
||||
m.decrypted = true
|
||||
continue
|
||||
}
|
||||
|
||||
plaintext, err := encryptor.Decrypt(m.SegmentsEncrypted)
|
||||
if err != nil {
|
||||
// 尝试直接解析
|
||||
_ = json.Unmarshal([]byte(m.SegmentsEncrypted), &m.Segments)
|
||||
} else {
|
||||
_ = json.Unmarshal(plaintext, &m.Segments)
|
||||
}
|
||||
m.decrypted = true
|
||||
}
|
||||
}()
|
||||
// 回填明文;解密失败的位置回退直接 JSON 解析
|
||||
for j, idx := range indices {
|
||||
m := messages[idx]
|
||||
pt := plaintexts[j]
|
||||
if pt == nil {
|
||||
// 解密失败,兼容未加密的旧数据
|
||||
_ = json.Unmarshal([]byte(m.SegmentsEncrypted), &m.Segments)
|
||||
} else {
|
||||
_ = json.Unmarshal(pt, &m.Segments)
|
||||
}
|
||||
m.decrypted = true
|
||||
}
|
||||
|
||||
// 分发任务
|
||||
for i := range messages {
|
||||
jobs <- i
|
||||
}
|
||||
close(jobs)
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
140
internal/model/message_crypto_test.go
Normal file
140
internal/model/message_crypto_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"with_you/internal/pkg/crypto"
|
||||
)
|
||||
|
||||
const testKey = "12345678901234567890123456789012"
|
||||
|
||||
// initEnc 初始化(幂等,因 crypto 用 sync.Once)加密器。
|
||||
func initEnc(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := crypto.InitMessageEncryptor(testKey, 1); err != nil {
|
||||
t.Fatalf("InitMessageEncryptor: %v", err)
|
||||
}
|
||||
if crypto.GetMessageEncryptor() == nil {
|
||||
t.Fatal("encryptor not initialized")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMessage_EncryptDecryptRoundTrip 验证:BeforeCreate 加密落库 →
|
||||
// 清空内存 Segments → Decrypt 还原 → 内容与原文一致。
|
||||
func TestMessage_EncryptDecryptRoundTrip(t *testing.T) {
|
||||
initEnc(t)
|
||||
|
||||
original := MessageSegments{
|
||||
{Type: "text", Data: MessageSegmentData{"content": "你好世界"}},
|
||||
{Type: "image", Data: MessageSegmentData{"url": "https://example.com/a.png"}},
|
||||
}
|
||||
|
||||
msg := &Message{Segments: original}
|
||||
|
||||
// 模拟 GORM 创建钩子:加密
|
||||
if err := msg.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("BeforeCreate (encrypt) failed: %v", err)
|
||||
}
|
||||
|
||||
// 加密后应写入 SegmentsEncrypted,且不应是明文 JSON(除非降级)
|
||||
if msg.SegmentsEncrypted == "" {
|
||||
t.Fatal("SegmentsEncrypted should be populated after BeforeCreate")
|
||||
}
|
||||
// 校验确实加密了:明文 JSON 中应包含 "content",密文 base64 中不应直接出现该明文
|
||||
if contains(msg.SegmentsEncrypted, "你好世界") {
|
||||
t.Errorf("SegmentsEncrypted appears to contain plaintext: %q", msg.SegmentsEncrypted)
|
||||
}
|
||||
if msg.SegmentsKeyVersion != crypto.GetMessageEncryptor().GetKeyVersion() {
|
||||
t.Errorf("SegmentsKeyVersion = %d, want %d",
|
||||
msg.SegmentsKeyVersion, crypto.GetMessageEncryptor().GetKeyVersion())
|
||||
}
|
||||
|
||||
// 模拟从 DB 取回后清空内存段(仅 SegmentsEncrypted 落库)
|
||||
msg.Segments = nil
|
||||
msg.decrypted = false
|
||||
|
||||
// 解密还原
|
||||
if err := msg.Decrypt(); err != nil {
|
||||
t.Fatalf("Decrypt failed: %v", err)
|
||||
}
|
||||
|
||||
if len(msg.Segments) != len(original) {
|
||||
t.Fatalf("after Decrypt, len(Segments) = %d, want %d", len(msg.Segments), len(original))
|
||||
}
|
||||
for i, seg := range msg.Segments {
|
||||
if seg.Type != original[i].Type {
|
||||
t.Errorf("segment[%d].Type = %q, want %q", i, seg.Type, original[i].Type)
|
||||
}
|
||||
if seg.Data["content"] != original[i].Data["content"] {
|
||||
t.Errorf("segment[%d].content mismatch: got %v, want %v",
|
||||
i, seg.Data["content"], original[i].Data["content"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMessage_EncryptEmptySegments 验证:空 Segments 时加密为 no-op。
|
||||
func TestMessage_EncryptEmptySegments(t *testing.T) {
|
||||
initEnc(t)
|
||||
|
||||
msg := &Message{}
|
||||
if err := msg.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("BeforeCreate failed: %v", err)
|
||||
}
|
||||
if msg.SegmentsEncrypted != "" {
|
||||
t.Errorf("empty Segments should not produce ciphertext, got %q", msg.SegmentsEncrypted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchDecryptMessagesParallel_RoundTrip 验证批量解密路径与单条一致。
|
||||
func TestBatchDecryptMessagesParallel_RoundTrip(t *testing.T) {
|
||||
initEnc(t)
|
||||
|
||||
contents := []string{"消息A", "消息B", "消息C"}
|
||||
msgs := make([]*Message, len(contents))
|
||||
for i, c := range contents {
|
||||
m := &Message{
|
||||
Segments: MessageSegments{
|
||||
{Type: "text", Data: MessageSegmentData{"content": c}},
|
||||
},
|
||||
}
|
||||
if err := m.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("BeforeCreate[%d]: %v", i, err)
|
||||
}
|
||||
m.Segments = nil // 模拟从 DB 取回
|
||||
msgs[i] = m
|
||||
}
|
||||
|
||||
BatchDecryptMessagesParallel(msgs)
|
||||
|
||||
for i, m := range msgs {
|
||||
if !m.decrypted {
|
||||
t.Errorf("msg[%d] not marked decrypted", i)
|
||||
}
|
||||
if len(m.Segments) != 1 {
|
||||
t.Errorf("msg[%d] segments len = %d, want 1", i, len(m.Segments))
|
||||
continue
|
||||
}
|
||||
if got := m.Segments[0].Data["content"]; got != contents[i] {
|
||||
t.Errorf("msg[%d].content = %v, want %q", i, got, contents[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// contains 简单子串判断(避免引入 strings 仅为此)。
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (indexOf(s, sub) >= 0)
|
||||
}
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
bs := []byte(s)
|
||||
for i := 0; i+len(sub) <= len(bs); i++ {
|
||||
if string(bs[i:i+len(sub)]) == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// 编译期保证 json 被使用(兼容路径用到)。
|
||||
var _ = json.Marshal
|
||||
Reference in New Issue
Block a user