Compare commits
27 Commits
d2894066f8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a971fb0e29 | ||
|
|
eb931bf1c6 | ||
|
|
d240485d0e | ||
|
|
d8e56e60dc | ||
|
|
28cfcbf9a8 | ||
|
|
322aa9eebb | ||
|
|
cb85c62093 | ||
|
|
821e066446 | ||
|
|
5b83f98fb8 | ||
|
|
755392e999 | ||
|
|
a0e210feab | ||
|
|
d9aa4b46c3 | ||
|
|
9951043034 | ||
|
|
60654bc0f4 | ||
|
|
c741a7d61d | ||
|
|
34992030b9 | ||
|
|
14114db68d | ||
|
|
9356cb6876 | ||
|
|
2084473fb5 | ||
|
|
2748c80095 | ||
|
|
3fc8b38184 | ||
|
|
6bf87fec46 | ||
|
|
f63c795dcb | ||
|
|
de0766df5e | ||
|
|
6c14309624 | ||
|
|
570b2d7afe | ||
|
|
9a1851f023 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
# Build artifacts
|
# Build artifacts
|
||||||
/server
|
/server
|
||||||
|
*.exe
|
||||||
*.tar
|
*.tar
|
||||||
|
|
||||||
# Runtime files
|
# Runtime files
|
||||||
|
|||||||
@@ -18,16 +18,19 @@ import (
|
|||||||
|
|
||||||
// App 应用程序结构体
|
// App 应用程序结构体
|
||||||
type App struct {
|
type App struct {
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
DB *gorm.DB
|
DB *gorm.DB
|
||||||
Router *router.Router
|
Router *router.Router
|
||||||
PushService service.PushService
|
PushService service.PushService
|
||||||
HotRankWorker *service.HotRankWorker
|
HotRankWorker *service.HotRankWorker
|
||||||
GRPCServer *runner.Server
|
FileCleanupWorker *service.FileCleanupWorker
|
||||||
Server *http.Server
|
DeviceCleanupWorker *service.DeviceCleanupWorker
|
||||||
Publisher ws.MessagePublisher
|
GRPCServer *runner.Server
|
||||||
TaskDispatcher runner.TaskDispatcher
|
Server *http.Server
|
||||||
Logger *zap.Logger
|
Publisher ws.MessagePublisher
|
||||||
|
TaskDispatcher runner.TaskDispatcher
|
||||||
|
Logger *zap.Logger
|
||||||
|
PushWorker *service.PushWorker
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewApp 创建应用程序
|
// NewApp 创建应用程序
|
||||||
@@ -37,21 +40,27 @@ func NewApp(
|
|||||||
r *router.Router,
|
r *router.Router,
|
||||||
pushService service.PushService,
|
pushService service.PushService,
|
||||||
hotRankWorker *service.HotRankWorker,
|
hotRankWorker *service.HotRankWorker,
|
||||||
|
fileCleanupWorker *service.FileCleanupWorker,
|
||||||
|
deviceCleanupWorker *service.DeviceCleanupWorker,
|
||||||
grpcServer *runner.Server,
|
grpcServer *runner.Server,
|
||||||
publisher ws.MessagePublisher,
|
publisher ws.MessagePublisher,
|
||||||
taskDispatcher runner.TaskDispatcher,
|
taskDispatcher runner.TaskDispatcher,
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
|
pushWorker *service.PushWorker,
|
||||||
) *App {
|
) *App {
|
||||||
return &App{
|
return &App{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
DB: db,
|
DB: db,
|
||||||
Router: r,
|
Router: r,
|
||||||
PushService: pushService,
|
PushService: pushService,
|
||||||
HotRankWorker: hotRankWorker,
|
HotRankWorker: hotRankWorker,
|
||||||
GRPCServer: grpcServer,
|
FileCleanupWorker: fileCleanupWorker,
|
||||||
Publisher: publisher,
|
DeviceCleanupWorker: deviceCleanupWorker,
|
||||||
TaskDispatcher: taskDispatcher,
|
GRPCServer: grpcServer,
|
||||||
Logger: logger,
|
Publisher: publisher,
|
||||||
|
TaskDispatcher: taskDispatcher,
|
||||||
|
Logger: logger,
|
||||||
|
PushWorker: pushWorker,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +82,21 @@ func (a *App) Start(ctx context.Context) error {
|
|||||||
a.HotRankWorker.Start(ctx)
|
a.HotRankWorker.Start(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2.1.1 聊天文件过期清理
|
||||||
|
if a.FileCleanupWorker != nil {
|
||||||
|
a.FileCleanupWorker.Start(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.1.2 设备 registration_id 清理(清理不活跃设备 token)
|
||||||
|
if a.DeviceCleanupWorker != nil {
|
||||||
|
a.DeviceCleanupWorker.Start(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.2 启动 PushWorker(Redis Stream 异步推送)
|
||||||
|
if a.PushWorker != nil {
|
||||||
|
a.PushWorker.Start(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 创建 HTTP 服务器
|
// 3. 创建 HTTP 服务器
|
||||||
addr := fmt.Sprintf("%s:%d", a.Config.Server.Host, a.Config.Server.Port)
|
addr := fmt.Sprintf("%s:%d", a.Config.Server.Host, a.Config.Server.Port)
|
||||||
a.Logger.Info("Starting HTTP server",
|
a.Logger.Info("Starting HTTP server",
|
||||||
@@ -148,6 +172,22 @@ func (a *App) Stop(ctx context.Context) error {
|
|||||||
a.HotRankWorker.Stop()
|
a.HotRankWorker.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if a.FileCleanupWorker != nil {
|
||||||
|
a.Logger.Info("Stopping file cleanup worker")
|
||||||
|
a.FileCleanupWorker.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.DeviceCleanupWorker != nil {
|
||||||
|
a.Logger.Info("Stopping device cleanup worker")
|
||||||
|
a.DeviceCleanupWorker.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5.1 停止 PushWorker
|
||||||
|
if a.PushWorker != nil {
|
||||||
|
a.Logger.Info("Stopping push worker (stream)")
|
||||||
|
a.PushWorker.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
// 6. 关闭数据库连接
|
// 6. 关闭数据库连接
|
||||||
a.Logger.Info("Closing database connection")
|
a.Logger.Info("Closing database connection")
|
||||||
sqlDB, err := a.DB.DB()
|
sqlDB, err := a.DB.DB()
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
//go:build wireinject
|
//go:build wireinject
|
||||||
// +build wireinject
|
// +build wireinject
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/google/wire"
|
||||||
|
|
||||||
|
"with_you/internal/cache"
|
||||||
"with_you/internal/handler"
|
"with_you/internal/handler"
|
||||||
|
"with_you/internal/middleware"
|
||||||
"with_you/internal/repository"
|
"with_you/internal/repository"
|
||||||
"with_you/internal/router"
|
"with_you/internal/router"
|
||||||
"with_you/internal/service"
|
"with_you/internal/service"
|
||||||
appwire "with_you/internal/wire"
|
appwire "with_you/internal/wire"
|
||||||
|
|
||||||
"github.com/google/wire"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// InitializeApp 初始化应用程序
|
// InitializeApp 初始化应用程序
|
||||||
@@ -32,7 +34,10 @@ func ProvideRouter(
|
|||||||
messageHandler *handler.MessageHandler,
|
messageHandler *handler.MessageHandler,
|
||||||
notificationHandler *handler.NotificationHandler,
|
notificationHandler *handler.NotificationHandler,
|
||||||
uploadHandler *handler.UploadHandler,
|
uploadHandler *handler.UploadHandler,
|
||||||
jwtService *service.JWTService,
|
jwtService service.JWTService,
|
||||||
|
sessionService service.SessionService,
|
||||||
|
identityProvider middleware.IdentityProvider,
|
||||||
|
cache cache.Cache,
|
||||||
pushHandler *handler.PushHandler,
|
pushHandler *handler.PushHandler,
|
||||||
systemMessageHandler *handler.SystemMessageHandler,
|
systemMessageHandler *handler.SystemMessageHandler,
|
||||||
groupHandler *handler.GroupHandler,
|
groupHandler *handler.GroupHandler,
|
||||||
@@ -42,6 +47,7 @@ func ProvideRouter(
|
|||||||
scheduleHandler *handler.ScheduleHandler,
|
scheduleHandler *handler.ScheduleHandler,
|
||||||
gradeHandler *handler.GradeHandler,
|
gradeHandler *handler.GradeHandler,
|
||||||
examHandler *handler.ExamHandler,
|
examHandler *handler.ExamHandler,
|
||||||
|
emptyClassroomHandler *handler.EmptyClassroomHandler,
|
||||||
roleHandler *handler.RoleHandler,
|
roleHandler *handler.RoleHandler,
|
||||||
adminUserHandler *handler.AdminUserHandler,
|
adminUserHandler *handler.AdminUserHandler,
|
||||||
adminPostHandler *handler.AdminPostHandler,
|
adminPostHandler *handler.AdminPostHandler,
|
||||||
@@ -62,7 +68,9 @@ func ProvideRouter(
|
|||||||
logService *service.LogService,
|
logService *service.LogService,
|
||||||
activityService service.UserActivityService,
|
activityService service.UserActivityService,
|
||||||
casbinService service.CasbinService,
|
casbinService service.CasbinService,
|
||||||
|
userService service.UserService,
|
||||||
wsHandler *handler.WSHandler,
|
wsHandler *handler.WSHandler,
|
||||||
|
liveKitHandler *handler.LiveKitHandler,
|
||||||
) *router.Router {
|
) *router.Router {
|
||||||
return router.New(router.RouterDeps{
|
return router.New(router.RouterDeps{
|
||||||
UserRepo: userRepo,
|
UserRepo: userRepo,
|
||||||
@@ -73,6 +81,9 @@ func ProvideRouter(
|
|||||||
NotificationHandler: notificationHandler,
|
NotificationHandler: notificationHandler,
|
||||||
UploadHandler: uploadHandler,
|
UploadHandler: uploadHandler,
|
||||||
JWTService: jwtService,
|
JWTService: jwtService,
|
||||||
|
SessionService: sessionService,
|
||||||
|
IdentityProvider: identityProvider,
|
||||||
|
Cache: cache,
|
||||||
PushHandler: pushHandler,
|
PushHandler: pushHandler,
|
||||||
SystemMessageHandler: systemMessageHandler,
|
SystemMessageHandler: systemMessageHandler,
|
||||||
GroupHandler: groupHandler,
|
GroupHandler: groupHandler,
|
||||||
@@ -82,6 +93,7 @@ func ProvideRouter(
|
|||||||
ScheduleHandler: scheduleHandler,
|
ScheduleHandler: scheduleHandler,
|
||||||
GradeHandler: gradeHandler,
|
GradeHandler: gradeHandler,
|
||||||
ExamHandler: examHandler,
|
ExamHandler: examHandler,
|
||||||
|
EmptyClassroomHandler: emptyClassroomHandler,
|
||||||
RoleHandler: roleHandler,
|
RoleHandler: roleHandler,
|
||||||
AdminUserHandler: adminUserHandler,
|
AdminUserHandler: adminUserHandler,
|
||||||
AdminPostHandler: adminPostHandler,
|
AdminPostHandler: adminPostHandler,
|
||||||
@@ -92,6 +104,7 @@ func ProvideRouter(
|
|||||||
QRCodeHandler: qrcodeHandler,
|
QRCodeHandler: qrcodeHandler,
|
||||||
MaterialHandler: materialHandler,
|
MaterialHandler: materialHandler,
|
||||||
CallHandler: callHandler,
|
CallHandler: callHandler,
|
||||||
|
LiveKitHandler: liveKitHandler,
|
||||||
ReportHandler: reportHandler,
|
ReportHandler: reportHandler,
|
||||||
AdminReportHandler: adminReportHandler,
|
AdminReportHandler: adminReportHandler,
|
||||||
VerificationHandler: verificationHandler,
|
VerificationHandler: verificationHandler,
|
||||||
@@ -102,6 +115,7 @@ func ProvideRouter(
|
|||||||
LogService: logService,
|
LogService: logService,
|
||||||
ActivityService: activityService,
|
ActivityService: activityService,
|
||||||
CasbinService: casbinService,
|
CasbinService: casbinService,
|
||||||
|
UserService: userService,
|
||||||
WSHandler: wsHandler,
|
WSHandler: wsHandler,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"with_you/internal/cache"
|
||||||
"with_you/internal/handler"
|
"with_you/internal/handler"
|
||||||
|
"with_you/internal/middleware"
|
||||||
"with_you/internal/repository"
|
"with_you/internal/repository"
|
||||||
"with_you/internal/router"
|
"with_you/internal/router"
|
||||||
"with_you/internal/service"
|
"with_you/internal/service"
|
||||||
@@ -35,7 +37,7 @@ func InitializeApp() (*App, error) {
|
|||||||
messagePublisher := wire.ProvideWSMessagePublisher(config, client)
|
messagePublisher := wire.ProvideWSMessagePublisher(config, client)
|
||||||
logger := wire.ProvideLogger()
|
logger := wire.ProvideLogger()
|
||||||
jpushClient := wire.ProvideJPushClient(config, logger)
|
jpushClient := wire.ProvideJPushClient(config, logger)
|
||||||
pushService := wire.ProvidePushService(pushRecordRepository, deviceTokenRepository, messageRepository, messagePublisher, jpushClient)
|
pushService := wire.ProvidePushService(pushRecordRepository, deviceTokenRepository, messageRepository, messagePublisher, jpushClient, config)
|
||||||
postRepository := repository.NewPostRepository(db)
|
postRepository := repository.NewPostRepository(db)
|
||||||
cache := wire.ProvideCache(config, client)
|
cache := wire.ProvideCache(config, client)
|
||||||
systemMessageService := wire.ProvideSystemMessageService(systemNotificationRepository, pushService, userRepository, postRepository, cache)
|
systemMessageService := wire.ProvideSystemMessageService(systemNotificationRepository, pushService, userRepository, postRepository, cache)
|
||||||
@@ -49,7 +51,10 @@ func InitializeApp() (*App, error) {
|
|||||||
loginLogService := wire.ProvideLoginLogService(asyncLogManager, loginLogRepository)
|
loginLogService := wire.ProvideLoginLogService(asyncLogManager, loginLogRepository)
|
||||||
dataChangeLogService := wire.ProvideDataChangeLogService(asyncLogManager, dataChangeLogRepository)
|
dataChangeLogService := wire.ProvideDataChangeLogService(asyncLogManager, dataChangeLogRepository)
|
||||||
logService := wire.ProvideLogService(operationLogService, loginLogService, dataChangeLogService)
|
logService := wire.ProvideLogService(operationLogService, loginLogService, dataChangeLogService)
|
||||||
userService := wire.ProvideUserService(userRepository, systemMessageService, emailService, cache, logService)
|
sessionRepository := repository.NewSessionRepository(db)
|
||||||
|
jwtService := wire.ProvideJWTService(config)
|
||||||
|
sessionService := wire.ProvideSessionService(sessionRepository, jwtService)
|
||||||
|
userService := wire.ProvideUserService(userRepository, systemMessageService, emailService, cache, logService, sessionService)
|
||||||
userActivityRepository := wire.ProvideUserActivityRepository(db, cache)
|
userActivityRepository := wire.ProvideUserActivityRepository(db, cache)
|
||||||
userActivityService := wire.ProvideUserActivityService(userActivityRepository)
|
userActivityService := wire.ProvideUserActivityService(userActivityRepository)
|
||||||
userProfileAuditRepository := repository.NewUserProfileAuditRepository(db)
|
userProfileAuditRepository := repository.NewUserProfileAuditRepository(db)
|
||||||
@@ -77,18 +82,25 @@ func InitializeApp() (*App, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
uploadService := wire.ProvideUploadService(s3Client, userService)
|
uploadedFileRepository := repository.NewUploadedFileRepository(db)
|
||||||
chatService := wire.ProvideChatService(messageRepository, userRepository, messagePublisher, cache, uploadService, pushService)
|
uploadService := wire.ProvideUploadService(s3Client, userService, uploadedFileRepository)
|
||||||
messageService := wire.ProvideMessageService(messageRepository, cache, uploadService)
|
seqBufferManager := wire.ProvideSeqBufferManager(config, client, cache)
|
||||||
|
pushWorker := wire.ProvidePushWorker(config, client, messagePublisher, pushService, messageRepository, userRepository)
|
||||||
|
conversationVersionLogRepository := repository.NewConversationVersionLogRepository(db)
|
||||||
groupRepository := repository.NewGroupRepository(db)
|
groupRepository := repository.NewGroupRepository(db)
|
||||||
groupJoinRequestRepository := repository.NewGroupJoinRequestRepository(db)
|
groupJoinRequestRepository := repository.NewGroupJoinRequestRepository(db)
|
||||||
groupService := wire.ProvideGroupService(groupRepository, userRepository, messageRepository, groupJoinRequestRepository, systemNotificationRepository, messagePublisher, cache)
|
groupService := wire.ProvideGroupService(groupRepository, userRepository, messageRepository, groupJoinRequestRepository, systemNotificationRepository, messagePublisher, cache)
|
||||||
messageHandler := wire.ProvideMessageHandler(chatService, messageService, userService, groupService, messagePublisher)
|
chatService := wire.ProvideChatService(messageRepository, userRepository, messagePublisher, cache, uploadService, pushService, seqBufferManager, pushWorker, client, conversationVersionLogRepository, config, groupService)
|
||||||
|
messageService := wire.ProvideMessageService(messageRepository, cache, uploadService)
|
||||||
|
messageHandler := wire.ProvideMessageHandler(chatService, messageService, userService, groupService, uploadService, messagePublisher)
|
||||||
notificationRepository := repository.NewNotificationRepository(db)
|
notificationRepository := repository.NewNotificationRepository(db)
|
||||||
notificationService := wire.ProvideNotificationService(notificationRepository, cache)
|
notificationService := wire.ProvideNotificationService(notificationRepository, cache)
|
||||||
notificationHandler := handler.NewNotificationHandler(notificationService)
|
notificationHandler := handler.NewNotificationHandler(notificationService)
|
||||||
uploadHandler := handler.NewUploadHandler(uploadService)
|
uploadHandler := handler.NewUploadHandler(uploadService)
|
||||||
jwtService := wire.ProvideJWTService(config)
|
enforcer := wire.ProvideCasbinEnforcer(config, db)
|
||||||
|
roleRepository := wire.ProvideRoleRepository(db)
|
||||||
|
casbinService := wire.ProvideCasbinService(enforcer, cache, roleRepository, config)
|
||||||
|
identityProvider := wire.ProvideIdentityProvider(userService, casbinService, sessionRepository)
|
||||||
pushHandler := handler.NewPushHandler(pushService)
|
pushHandler := handler.NewPushHandler(pushService)
|
||||||
systemMessageHandler := wire.ProvideSystemMessageHandler(systemMessageService)
|
systemMessageHandler := wire.ProvideSystemMessageHandler(systemMessageService)
|
||||||
groupHandler := wire.ProvideGroupHandler(groupService, userService)
|
groupHandler := wire.ProvideGroupHandler(groupService, userService)
|
||||||
@@ -107,20 +119,20 @@ func InitializeApp() (*App, error) {
|
|||||||
scheduleHandler := wire.ProvideScheduleHandler(scheduleService, scheduleSyncService)
|
scheduleHandler := wire.ProvideScheduleHandler(scheduleService, scheduleSyncService)
|
||||||
gradeRepository := repository.NewGradeRepository(db)
|
gradeRepository := repository.NewGradeRepository(db)
|
||||||
gradeSyncService := wire.ProvideGradeSyncService(taskManager, gradeRepository, logger)
|
gradeSyncService := wire.ProvideGradeSyncService(taskManager, gradeRepository, logger)
|
||||||
gradeHandler := handler.NewGradeHandler(gradeSyncService, gradeRepository)
|
gradeHandler := handler.NewGradeHandler(gradeSyncService)
|
||||||
examRepository := repository.NewExamRepository(db)
|
examRepository := repository.NewExamRepository(db)
|
||||||
examSyncService := wire.ProvideExamSyncService(taskManager, examRepository, logger)
|
examSyncService := wire.ProvideExamSyncService(taskManager, examRepository, logger)
|
||||||
examHandler := handler.NewExamHandler(examSyncService, examRepository)
|
examHandler := handler.NewExamHandler(examSyncService)
|
||||||
roleRepository := wire.ProvideRoleRepository(db)
|
emptyClassroomRepository := repository.NewEmptyClassroomRepository(db)
|
||||||
enforcer := wire.ProvideCasbinEnforcer(config, db)
|
emptyClassroomSyncService := wire.ProvideEmptyClassroomSyncService(taskManager, emptyClassroomRepository, logger)
|
||||||
casbinService := wire.ProvideCasbinService(enforcer, cache, roleRepository, config)
|
emptyClassroomHandler := handler.NewEmptyClassroomHandler(emptyClassroomSyncService)
|
||||||
roleService := wire.ProvideRoleService(roleRepository, casbinService)
|
roleService := wire.ProvideRoleService(roleRepository, casbinService)
|
||||||
roleHandler := handler.NewRoleHandler(roleService)
|
roleHandler := handler.NewRoleHandler(roleService)
|
||||||
adminUserService := wire.ProvideAdminUserService(userRepository)
|
adminUserService := wire.ProvideAdminUserService(userRepository, sessionService, cache)
|
||||||
adminUserHandler := handler.NewAdminUserHandler(adminUserService)
|
adminUserHandler := handler.NewAdminUserHandler(adminUserService, pushService)
|
||||||
adminPostService := wire.ProvideAdminPostService(postRepository, cache, logService)
|
adminPostService := wire.ProvideAdminPostService(postRepository, cache, logService)
|
||||||
adminPostHandler := handler.NewAdminPostHandler(adminPostService)
|
adminPostHandler := handler.NewAdminPostHandler(adminPostService)
|
||||||
adminCommentService := wire.ProvideAdminCommentService(commentRepository, postRepository)
|
adminCommentService := wire.ProvideAdminCommentService(commentRepository, postRepository, cache)
|
||||||
adminCommentHandler := handler.NewAdminCommentHandler(adminCommentService)
|
adminCommentHandler := handler.NewAdminCommentHandler(adminCommentService)
|
||||||
adminGroupService := wire.ProvideAdminGroupService(groupRepository, userRepository)
|
adminGroupService := wire.ProvideAdminGroupService(groupRepository, userRepository)
|
||||||
adminGroupHandler := handler.NewAdminGroupHandler(adminGroupService)
|
adminGroupHandler := handler.NewAdminGroupHandler(adminGroupService)
|
||||||
@@ -134,7 +146,7 @@ func InitializeApp() (*App, error) {
|
|||||||
materialService := wire.ProvideMaterialService(materialSubjectRepository, materialFileRepository)
|
materialService := wire.ProvideMaterialService(materialSubjectRepository, materialFileRepository)
|
||||||
materialHandler := handler.NewMaterialHandler(materialService)
|
materialHandler := handler.NewMaterialHandler(materialService)
|
||||||
callRepository := repository.NewCallRepository(db)
|
callRepository := repository.NewCallRepository(db)
|
||||||
callService := wire.ProvideCallService(callRepository, messagePublisher, config, db, client)
|
callService := wire.ProvideCallService(callRepository, messagePublisher, config, db, client, groupService)
|
||||||
callHandler := handler.NewCallHandler(callService)
|
callHandler := handler.NewCallHandler(callService)
|
||||||
reportRepository := repository.NewReportRepository(db)
|
reportRepository := repository.NewReportRepository(db)
|
||||||
reportService := wire.ProvideReportService(reportRepository, postRepository, commentRepository, messageRepository, userRepository, systemNotificationRepository, pushService, transactionManager, operationLogService, config)
|
reportService := wire.ProvideReportService(reportRepository, postRepository, commentRepository, messageRepository, userRepository, systemNotificationRepository, pushService, transactionManager, operationLogService, config)
|
||||||
@@ -145,20 +157,24 @@ func InitializeApp() (*App, error) {
|
|||||||
verificationService := wire.ProvideVerificationService(verificationRepository, userRepository)
|
verificationService := wire.ProvideVerificationService(verificationRepository, userRepository)
|
||||||
verificationHandler := handler.NewVerificationHandler(verificationService)
|
verificationHandler := handler.NewVerificationHandler(verificationService)
|
||||||
adminVerificationService := wire.ProvideAdminVerificationService(verificationRepository, userRepository)
|
adminVerificationService := wire.ProvideAdminVerificationService(verificationRepository, userRepository)
|
||||||
adminVerificationHandler := wire.ProvideAdminVerificationHandler(adminVerificationService, userRepository)
|
adminVerificationHandler := wire.ProvideAdminVerificationHandler(adminVerificationService, userService)
|
||||||
adminProfileAuditHandler := handler.NewAdminProfileAuditHandler(userProfileAuditService)
|
adminProfileAuditHandler := handler.NewAdminProfileAuditHandler(userProfileAuditService)
|
||||||
setupService := wire.ProvideSetupService(userRepository, roleRepository, casbinService, config)
|
setupService := wire.ProvideSetupService(userRepository, roleRepository, casbinService, config)
|
||||||
setupHandler := wire.ProvideSetupHandler(setupService)
|
setupHandler := wire.ProvideSetupHandler(setupService)
|
||||||
tradeRepository := repository.NewTradeRepository(db)
|
tradeRepository := repository.NewTradeRepository(db)
|
||||||
tradeService := wire.ProvideTradeService(tradeRepository)
|
tradeService := wire.ProvideTradeService(tradeRepository)
|
||||||
tradeHandler := handler.NewTradeHandler(tradeService)
|
tradeHandler := handler.NewTradeHandler(tradeService)
|
||||||
wsHandler := wire.ProvideWSHandler(messagePublisher, chatService, groupService, jwtService, callService, userRepository)
|
wsHandler := wire.ProvideWSHandler(messagePublisher, chatService, groupService, jwtService, callService, userService)
|
||||||
router := ProvideRouter(userRepository, userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, pushHandler, systemMessageHandler, groupHandler, stickerHandler, voteHandler, channelHandler, scheduleHandler, gradeHandler, examHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, qrCodeHandler, materialHandler, callHandler, reportHandler, adminReportHandler, verificationHandler, adminVerificationHandler, adminProfileAuditHandler, setupHandler, tradeHandler, logService, userActivityService, casbinService, wsHandler)
|
liveKitService := wire.ProvideLiveKitService(config, logger)
|
||||||
|
liveKitHandler := wire.ProvideLiveKitHandler(liveKitService, callService, config, logger)
|
||||||
|
router := ProvideRouter(userRepository, userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, sessionService, identityProvider, cache, pushHandler, systemMessageHandler, groupHandler, stickerHandler, voteHandler, channelHandler, scheduleHandler, gradeHandler, examHandler, emptyClassroomHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, qrCodeHandler, materialHandler, callHandler, reportHandler, adminReportHandler, verificationHandler, adminVerificationHandler, adminProfileAuditHandler, setupHandler, tradeHandler, logService, userActivityService, casbinService, userService, wsHandler, liveKitHandler)
|
||||||
hotRankWorker := wire.ProvideHotRankWorker(config, postRepository, channelRepository, cache, client)
|
hotRankWorker := wire.ProvideHotRankWorker(config, postRepository, channelRepository, cache, client)
|
||||||
|
fileCleanupWorker := wire.ProvideFileCleanupWorker(config, uploadedFileRepository, s3Client, client)
|
||||||
|
deviceCleanupWorker := wire.ProvideDeviceCleanupWorker(config, deviceTokenRepository, client)
|
||||||
server := wire.ProvideGRPCServer(config, logger, runnerHub)
|
server := wire.ProvideGRPCServer(config, logger, runnerHub)
|
||||||
runnerRegistry := wire.ProvideRunnerRegistry(config, client)
|
runnerRegistry := wire.ProvideRunnerRegistry(config, client)
|
||||||
taskDispatcher := wire.ProvideTaskDispatcher(runnerHub, runnerRegistry, config, client, logger)
|
taskDispatcher := wire.ProvideTaskDispatcher(runnerHub, runnerRegistry, config, client, logger)
|
||||||
app := NewApp(config, db, router, pushService, hotRankWorker, server, messagePublisher, taskDispatcher, logger)
|
app := NewApp(config, db, router, pushService, hotRankWorker, fileCleanupWorker, deviceCleanupWorker, server, messagePublisher, taskDispatcher, logger, pushWorker)
|
||||||
return app, nil
|
return app, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +189,10 @@ func ProvideRouter(
|
|||||||
messageHandler *handler.MessageHandler,
|
messageHandler *handler.MessageHandler,
|
||||||
notificationHandler *handler.NotificationHandler,
|
notificationHandler *handler.NotificationHandler,
|
||||||
uploadHandler *handler.UploadHandler,
|
uploadHandler *handler.UploadHandler,
|
||||||
jwtService *service.JWTService,
|
jwtService service.JWTService,
|
||||||
|
sessionService service.SessionService,
|
||||||
|
identityProvider middleware.IdentityProvider, cache2 cache.Cache,
|
||||||
|
|
||||||
pushHandler *handler.PushHandler,
|
pushHandler *handler.PushHandler,
|
||||||
systemMessageHandler *handler.SystemMessageHandler,
|
systemMessageHandler *handler.SystemMessageHandler,
|
||||||
groupHandler *handler.GroupHandler,
|
groupHandler *handler.GroupHandler,
|
||||||
@@ -183,6 +202,7 @@ func ProvideRouter(
|
|||||||
scheduleHandler *handler.ScheduleHandler,
|
scheduleHandler *handler.ScheduleHandler,
|
||||||
gradeHandler *handler.GradeHandler,
|
gradeHandler *handler.GradeHandler,
|
||||||
examHandler *handler.ExamHandler,
|
examHandler *handler.ExamHandler,
|
||||||
|
emptyClassroomHandler *handler.EmptyClassroomHandler,
|
||||||
roleHandler *handler.RoleHandler,
|
roleHandler *handler.RoleHandler,
|
||||||
adminUserHandler *handler.AdminUserHandler,
|
adminUserHandler *handler.AdminUserHandler,
|
||||||
adminPostHandler *handler.AdminPostHandler,
|
adminPostHandler *handler.AdminPostHandler,
|
||||||
@@ -203,7 +223,9 @@ func ProvideRouter(
|
|||||||
logService *service.LogService,
|
logService *service.LogService,
|
||||||
activityService service.UserActivityService,
|
activityService service.UserActivityService,
|
||||||
casbinService service.CasbinService,
|
casbinService service.CasbinService,
|
||||||
|
userService service.UserService,
|
||||||
wsHandler *handler.WSHandler,
|
wsHandler *handler.WSHandler,
|
||||||
|
liveKitHandler *handler.LiveKitHandler,
|
||||||
) *router.Router {
|
) *router.Router {
|
||||||
return router.New(router.RouterDeps{
|
return router.New(router.RouterDeps{
|
||||||
UserRepo: userRepo,
|
UserRepo: userRepo,
|
||||||
@@ -214,6 +236,9 @@ func ProvideRouter(
|
|||||||
NotificationHandler: notificationHandler,
|
NotificationHandler: notificationHandler,
|
||||||
UploadHandler: uploadHandler,
|
UploadHandler: uploadHandler,
|
||||||
JWTService: jwtService,
|
JWTService: jwtService,
|
||||||
|
SessionService: sessionService,
|
||||||
|
IdentityProvider: identityProvider,
|
||||||
|
Cache: cache2,
|
||||||
PushHandler: pushHandler,
|
PushHandler: pushHandler,
|
||||||
SystemMessageHandler: systemMessageHandler,
|
SystemMessageHandler: systemMessageHandler,
|
||||||
GroupHandler: groupHandler,
|
GroupHandler: groupHandler,
|
||||||
@@ -223,6 +248,7 @@ func ProvideRouter(
|
|||||||
ScheduleHandler: scheduleHandler,
|
ScheduleHandler: scheduleHandler,
|
||||||
GradeHandler: gradeHandler,
|
GradeHandler: gradeHandler,
|
||||||
ExamHandler: examHandler,
|
ExamHandler: examHandler,
|
||||||
|
EmptyClassroomHandler: emptyClassroomHandler,
|
||||||
RoleHandler: roleHandler,
|
RoleHandler: roleHandler,
|
||||||
AdminUserHandler: adminUserHandler,
|
AdminUserHandler: adminUserHandler,
|
||||||
AdminPostHandler: adminPostHandler,
|
AdminPostHandler: adminPostHandler,
|
||||||
@@ -233,6 +259,7 @@ func ProvideRouter(
|
|||||||
QRCodeHandler: qrcodeHandler,
|
QRCodeHandler: qrcodeHandler,
|
||||||
MaterialHandler: materialHandler,
|
MaterialHandler: materialHandler,
|
||||||
CallHandler: callHandler,
|
CallHandler: callHandler,
|
||||||
|
LiveKitHandler: liveKitHandler,
|
||||||
ReportHandler: reportHandler,
|
ReportHandler: reportHandler,
|
||||||
AdminReportHandler: adminReportHandler,
|
AdminReportHandler: adminReportHandler,
|
||||||
VerificationHandler: verificationHandler,
|
VerificationHandler: verificationHandler,
|
||||||
@@ -243,6 +270,7 @@ func ProvideRouter(
|
|||||||
LogService: logService,
|
LogService: logService,
|
||||||
ActivityService: activityService,
|
ActivityService: activityService,
|
||||||
CasbinService: casbinService,
|
CasbinService: casbinService,
|
||||||
|
UserService: userService,
|
||||||
WSHandler: wsHandler,
|
WSHandler: wsHandler,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,5 +10,14 @@ g = _, _
|
|||||||
[policy_effect]
|
[policy_effect]
|
||||||
e = some(where (p.eft == allow))
|
e = some(where (p.eft == allow))
|
||||||
|
|
||||||
|
# 真源策略:user_roles 表为用户-角色唯一真源;Casbin 仅存 p, role, resource, action。
|
||||||
|
# EnforceForUser 由 CasbinService 先查 DB 角色再对每个角色调 Enforce(role, obj, act),
|
||||||
|
# 因此 matcher 直接匹配 r.sub == p.sub(角色直接匹配),不再依赖 g 分组。
|
||||||
|
#
|
||||||
|
# matcher 使用 globMatch(doublestar 语义):
|
||||||
|
# - '* 匹配单层资源段(不跨 '/');
|
||||||
|
# - '**' 跨多层(如 admin/** 命中 admin/users/roles/write)。
|
||||||
|
# 资源命名采用路径式 admin/<domain>[/<sub>],避免 keyMatch2 把 '*' 当作正则通配
|
||||||
|
# 跨 '.' 而误匹配;也避免 globMatch 在点号分隔下 '*' 仍跨 '.' 的过宽问题。
|
||||||
[matchers]
|
[matchers]
|
||||||
m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && (r.act == p.act || p.act == "*")
|
m = r.sub == p.sub && globMatch(r.obj, p.obj) && (r.act == p.act || p.act == "*")
|
||||||
|
|||||||
@@ -21,6 +21,30 @@ database:
|
|||||||
password: postgres
|
password: postgres
|
||||||
dbname: with_you
|
dbname: with_you
|
||||||
sslmode: disable
|
sslmode: disable
|
||||||
|
# 读写分离配置(仅 postgres 模式生效,不配置则所有请求走主库)
|
||||||
|
# 注意:replica 字段已在 config.go 中显式 BindEnv,环境变量优先于 YAML。
|
||||||
|
# 单副本配置(推荐用环境变量覆盖,无需取消下方注释):
|
||||||
|
# APP_DATABASE_REPLICA_HOST, APP_DATABASE_REPLICA_PORT, APP_DATABASE_REPLICA_USER,
|
||||||
|
# APP_DATABASE_REPLICA_PASSWORD, APP_DATABASE_REPLICA_DBNAME, APP_DATABASE_REPLICA_SSLMODE
|
||||||
|
# APP_DATABASE_REPLICA_MAX_IDLE_CONNS, APP_DATABASE_REPLICA_MAX_OPEN_CONNS(可选,缺省回退主库连接池)
|
||||||
|
# replica:
|
||||||
|
# host: replica1.internal
|
||||||
|
# port: 5432
|
||||||
|
# user: readonly
|
||||||
|
# password: readonly_pass
|
||||||
|
# dbname: with_you
|
||||||
|
# sslmode: disable
|
||||||
|
# 多副本配置(仅 YAML,环境变量不支持切片结构体):
|
||||||
|
# replicas:
|
||||||
|
# - host: replica1.internal
|
||||||
|
# port: 5432
|
||||||
|
# user: readonly
|
||||||
|
# password: readonly_pass
|
||||||
|
# dbname: with_you
|
||||||
|
# sslmode: disable
|
||||||
|
# replica_policy: random # random
|
||||||
|
# replica_max_idle_conns: 10
|
||||||
|
# replica_max_open_conns: 100
|
||||||
max_idle_conns: 10
|
max_idle_conns: 10
|
||||||
max_open_conns: 100
|
max_open_conns: 100
|
||||||
log_level: warn
|
log_level: warn
|
||||||
@@ -135,6 +159,14 @@ upload:
|
|||||||
- image/gif
|
- image/gif
|
||||||
- image/webp
|
- image/webp
|
||||||
|
|
||||||
|
# 设备 registration_id 清理配置
|
||||||
|
# 周期性清理超过保留期未使用的设备 token,避免无效 registration_id 累积
|
||||||
|
# 环境变量: APP_DEVICE_CLEANUP_ENABLED, APP_DEVICE_CLEANUP_RETENTION_DAYS, APP_DEVICE_CLEANUP_INTERVAL_MINUTES
|
||||||
|
device_cleanup:
|
||||||
|
enabled: true # 是否启用清理 worker
|
||||||
|
retention_days: 3 # 保留天数,3 天不活跃即清理(按 last_used_at 判断)
|
||||||
|
interval_minutes: 360 # 扫描间隔(分钟),默认 6 小时
|
||||||
|
|
||||||
# 敏感词过滤配置
|
# 敏感词过滤配置
|
||||||
sensitive:
|
sensitive:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -266,11 +298,66 @@ report:
|
|||||||
# 极光推送配置
|
# 极光推送配置
|
||||||
# 环境变量:
|
# 环境变量:
|
||||||
# APP_JPUSH_ENABLED, APP_JPUSH_APP_KEY, APP_JPUSH_MASTER_SECRET, APP_JPUSH_PRODUCTION
|
# APP_JPUSH_ENABLED, APP_JPUSH_APP_KEY, APP_JPUSH_MASTER_SECRET, APP_JPUSH_PRODUCTION
|
||||||
|
# 厂商通道 channel_id(区分系统消息与私聊消息,通过 options.third_party_channel 下发):
|
||||||
|
# APP_JPUSH_CHANNEL_SYSTEM, APP_JPUSH_CHANNEL_CHAT
|
||||||
|
# APP_JPUSH_CHANNEL_XIAOMI_SYSTEM, APP_JPUSH_CHANNEL_XIAOMI_CHAT
|
||||||
|
# APP_JPUSH_CHANNEL_HUAWEI_SYSTEM, APP_JPUSH_CHANNEL_HUAWEI_CHAT
|
||||||
|
# APP_JPUSH_CHANNEL_OPPO_SYSTEM, APP_JPUSH_CHANNEL_OPPO_CHAT
|
||||||
|
# APP_JPUSH_CHANNEL_VIVO_SYSTEM, APP_JPUSH_CHANNEL_VIVO_CHAT
|
||||||
|
# APP_JPUSH_CHANNEL_MEIZU_SYSTEM, APP_JPUSH_CHANNEL_MEIZU_CHAT
|
||||||
|
# APP_JPUSH_CHANNEL_HONOR_SYSTEM, APP_JPUSH_CHANNEL_HONOR_CHAT
|
||||||
|
# APP_JPUSH_CHANNEL_FCM_SYSTEM, APP_JPUSH_CHANNEL_FCM_CHAT
|
||||||
|
# 小米消息模板(可选,配置后私信消息携带 channel_id 与 mi_template_id):
|
||||||
|
# APP_JPUSH_CHANNEL_XIAOMI_MI_SYSTEM_TEMPLATE_ID, APP_JPUSH_CHANNEL_XIAOMI_MI_CHAT_TEMPLATE_ID
|
||||||
|
# OPPO 私信模板(可选,仅 OPPO,配置后下发私信时携带):
|
||||||
|
# APP_JPUSH_CHANNEL_OPPO_SYSTEM_PRIVATE_TEMPLATE_ID, APP_JPUSH_CHANNEL_OPPO_CHAT_PRIVATE_TEMPLATE_ID
|
||||||
|
# OPPO 消息分类(2024.11.20 新规,category 必传时 notify_level 才生效):
|
||||||
|
# APP_JPUSH_CHANNEL_OPPO_SYSTEM_CATEGORY, APP_JPUSH_CHANNEL_OPPO_CHAT_CATEGORY
|
||||||
|
# APP_JPUSH_CHANNEL_OPPO_SYSTEM_NOTIFY_LEVEL, APP_JPUSH_CHANNEL_OPPO_CHAT_NOTIFY_LEVEL
|
||||||
|
# 荣耀通知栏消息智能分类(importance,NORMAL=服务通讯/LOW=资讯营销):
|
||||||
|
# APP_JPUSH_CHANNEL_HONOR_SYSTEM_IMPORTANCE, APP_JPUSH_CHANNEL_HONOR_CHAT_IMPORTANCE
|
||||||
|
# vivo 厂商消息场景标识(category,classification=1 时必须为系统消息类):
|
||||||
|
# APP_JPUSH_CHANNEL_VIVO_SYSTEM_CATEGORY, APP_JPUSH_CHANNEL_VIVO_CHAT_CATEGORY
|
||||||
|
# iOS 通知分组 thread-id:
|
||||||
|
# APP_JPUSH_CHANNEL_IOS_CHAT_THREAD_ID, APP_JPUSH_CHANNEL_IOS_SYSTEM_THREAD_ID
|
||||||
jpush:
|
jpush:
|
||||||
enabled: false
|
enabled: false
|
||||||
app_key: ""
|
app_key: ""
|
||||||
master_secret: ""
|
master_secret: ""
|
||||||
production: false # true: 生产环境, false: 开发环境
|
production: false # true: 生产环境, false: 开发环境
|
||||||
|
# 厂商通道 channel_id 配置(全部可选,留空则不下发对应厂商字段)
|
||||||
|
# channel.system / channel.chat 为各厂商默认 channel_id
|
||||||
|
# channel.vendor.{vendor}.{system|chat} 可单独覆盖某厂商,留空则回退到默认值
|
||||||
|
# 小米/OPPO 额外支持消息模板 id(按场景区分),配置后私信消息下发时携带
|
||||||
|
# 其余厂商(华为/VIVO/魅族/荣耀/FCM)仅 channel_id
|
||||||
|
channel:
|
||||||
|
system: "" # 系统消息/通知默认 channel_id
|
||||||
|
chat: "" # 私聊/群聊消息默认 channel_id
|
||||||
|
vendor:
|
||||||
|
# xiaomi: 默认值已在代码内置(system=153609, chat=153608, mi_system_template_id=P10761, mi_chat_template_id=M10289)
|
||||||
|
# 不在此声明,由 config.go 的 viper.SetDefault 提供,环境变量可覆盖
|
||||||
|
xiaomi: {}
|
||||||
|
huawei: { system: "", chat: "" }
|
||||||
|
# OPPO: category/notify_level 默认值已在代码内置(chat=IM/16, system=ACCOUNT/2),不在此声明
|
||||||
|
oppo:
|
||||||
|
system: ""
|
||||||
|
chat: ""
|
||||||
|
oppo_system_private_template_id: "" # OPPO 系统消息私信模板 id(需厂商后台注册)
|
||||||
|
oppo_chat_private_template_id: "" # OPPO 私聊消息私信模板 id(需厂商后台注册)
|
||||||
|
# vivo: category 默认值已在代码内置(chat=IM, system=ACCOUNT),不在此声明
|
||||||
|
vivo:
|
||||||
|
system: ""
|
||||||
|
chat: ""
|
||||||
|
meizu: { system: "", chat: "" }
|
||||||
|
# 荣耀: importance 默认值已在代码内置(chat=NORMAL, system=LOW),不在此声明
|
||||||
|
honor:
|
||||||
|
system: ""
|
||||||
|
chat: ""
|
||||||
|
fcm: { system: "", chat: "" }
|
||||||
|
# iOS APNs 通知分组(按 thread-id 区分系统/私聊通知,不配置则不分组)
|
||||||
|
ios:
|
||||||
|
chat_thread_id: "" # 私聊/群聊通知分组 thread-id
|
||||||
|
system_thread_id: "" # 系统消息/通知分组 thread-id
|
||||||
|
|
||||||
# 初始化超级管理员密钥
|
# 初始化超级管理员密钥
|
||||||
# 环境变量: APP_SETUP_SECRET
|
# 环境变量: APP_SETUP_SECRET
|
||||||
@@ -290,3 +377,36 @@ websocket:
|
|||||||
msg_channel: "ws:msg" # Redis Pub/Sub 频道
|
msg_channel: "ws:msg" # Redis Pub/Sub 频道
|
||||||
online_ttl: 60 # 在线状态 TTL(秒)
|
online_ttl: 60 # 在线状态 TTL(秒)
|
||||||
heartbeat_interval: 20 # 心跳间隔(秒)
|
heartbeat_interval: 20 # 心跳间隔(秒)
|
||||||
|
|
||||||
|
# Seq 预分配配置
|
||||||
|
# 环境变量:
|
||||||
|
# APP_SEQ_BUFFER_ENABLED, APP_SEQ_BUFFER_PRIVATE_BUFFER_SIZE
|
||||||
|
# APP_SEQ_BUFFER_GROUP_BUFFER_SIZE, APP_SEQ_BUFFER_LOCK_TIMEOUT_MS
|
||||||
|
# APP_SEQ_BUFFER_MAX_RETRIES
|
||||||
|
seq_buffer:
|
||||||
|
enabled: false # 启用后使用 Lua 预分配代替 INCR,关闭则回退旧逻辑
|
||||||
|
private_buffer_size: 50 # 私聊每次向 Redis 预分配步长
|
||||||
|
group_buffer_size: 200 # 群聊每次向 Redis 预分配步长(群聊消息更频繁)
|
||||||
|
lock_timeout_ms: 5000 # 分布式锁超时(毫秒)
|
||||||
|
max_retries: 3 # 冷启动获取锁失败最大重试次数
|
||||||
|
|
||||||
|
# 版本日志增量同步配置
|
||||||
|
# 环境变量:
|
||||||
|
# APP_VERSION_LOG_ENABLED, APP_VERSION_LOG_SYNC_LIMIT
|
||||||
|
# APP_VERSION_LOG_MAX_SYNC_GAP
|
||||||
|
version_log:
|
||||||
|
enabled: false # 启用后客户端可使用 /conversations/sync 增量同步
|
||||||
|
sync_limit: 100 # 单次同步最大返回变更条数
|
||||||
|
max_sync_gap: 1000 # 版本差距超过此值建议全量同步
|
||||||
|
|
||||||
|
# LiveKit SFU 配置(语音/视频通话)
|
||||||
|
# 环境变量:
|
||||||
|
# APP_LIVEKIT_ENABLED, APP_LIVEKIT_URL, APP_LIVEKIT_API_KEY, APP_LIVEKIT_API_SECRET
|
||||||
|
# APP_LIVEKIT_TOKEN_TTL, APP_LIVEKIT_WEBHOOK_SECRET
|
||||||
|
livekit:
|
||||||
|
enabled: false
|
||||||
|
url: "http://localhost:7880"
|
||||||
|
api_key: "devkey"
|
||||||
|
api_secret: "u1sLYiHWe4HIr3KrJrvknIleoHdgHdDqSjfkBvRs2AlA"
|
||||||
|
token_ttl: 600 # token 有效期(秒)
|
||||||
|
webhook_secret: "" # webhook 签名密钥(与 LiveKit 服务端配置一致)
|
||||||
|
|||||||
32
configs/livekit.yaml
Normal file
32
configs/livekit.yaml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# LiveKit Server Configuration
|
||||||
|
# See https://docs.livekit.io/realtime/server/config/ for all options
|
||||||
|
|
||||||
|
port: 7880
|
||||||
|
rtc:
|
||||||
|
tcp_port: 7881
|
||||||
|
udp_port: 7882
|
||||||
|
use_external_ip: false
|
||||||
|
# Enable TURN/TLS relay for restrictive networks
|
||||||
|
port_range_start: 7881
|
||||||
|
port_range_end: 7882
|
||||||
|
node_ip: 127.0.0.1
|
||||||
|
|
||||||
|
keys:
|
||||||
|
devkey: u1sLYiHWe4HIr3KrJrvknIleoHdgHdDqSjfkBvRs2AlA
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: info
|
||||||
|
|
||||||
|
# Redis for distributed state (required)
|
||||||
|
redis:
|
||||||
|
address: livekit-redis:6379
|
||||||
|
|
||||||
|
# PostgreSQL for persistent storage (rooms, participants, egress)
|
||||||
|
db:
|
||||||
|
address: postgres://postgres:postgres@livekit-postgres:5432/livekit?sslmode=disable
|
||||||
|
|
||||||
|
# Webhook for call history persistence
|
||||||
|
# webhook:
|
||||||
|
# urls:
|
||||||
|
# - http://app:8080/api/v1/calls/webhook
|
||||||
|
# api_key: devkey
|
||||||
@@ -55,6 +55,58 @@ services:
|
|||||||
timeout: 20s
|
timeout: 20s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
|
# LiveKit SFU Server (voice/video calling)
|
||||||
|
livekit:
|
||||||
|
image: livekit/livekit-server:v1.9.5
|
||||||
|
container_name: livekit
|
||||||
|
ports:
|
||||||
|
- "7880:7880" # HTTP (token, webhooks, dashboard)
|
||||||
|
- "7881:7881" # TCP/TURN fallback
|
||||||
|
- "7882:7882" # UDP media
|
||||||
|
volumes:
|
||||||
|
- ./configs/livekit.yaml:/etc/livekit.yaml
|
||||||
|
command: --config /etc/livekit.yaml --node-ip=127.0.0.1
|
||||||
|
depends_on:
|
||||||
|
livekit-redis:
|
||||||
|
condition: service_started
|
||||||
|
livekit-postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# LiveKit Redis (required for distributed coordination)
|
||||||
|
livekit-redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: livekit_redis
|
||||||
|
ports:
|
||||||
|
- "6380:6379"
|
||||||
|
volumes:
|
||||||
|
- livekit_redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# LiveKit PostgreSQL (required for room/participant state)
|
||||||
|
livekit-postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: livekit_postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: livekit
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
volumes:
|
||||||
|
- livekit_pg_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d livekit"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -76,3 +128,5 @@ volumes:
|
|||||||
# postgres_data:
|
# postgres_data:
|
||||||
# redis_data:
|
# redis_data:
|
||||||
minio_data:
|
minio_data:
|
||||||
|
livekit_redis_data:
|
||||||
|
livekit_pg_data:
|
||||||
|
|||||||
48
go.mod
48
go.mod
@@ -13,6 +13,7 @@ require (
|
|||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/google/wire v0.7.0
|
github.com/google/wire v0.7.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/livekit/protocol v1.39.4-0.20250721114233-52633eee694f
|
||||||
github.com/minio/minio-go/v7 v7.0.99
|
github.com/minio/minio-go/v7 v7.0.99
|
||||||
github.com/redis/go-redis/v9 v9.18.0
|
github.com/redis/go-redis/v9 v9.18.0
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
@@ -25,24 +26,36 @@ require (
|
|||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/driver/sqlite v1.6.0
|
gorm.io/driver/sqlite v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
|
gorm.io/plugin/dbresolver v1.6.2
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250717185734-6c6e0d3c608e.1 // indirect
|
||||||
|
buf.build/go/protovalidate v0.14.0 // indirect
|
||||||
|
buf.build/go/protoyaml v0.6.0 // indirect
|
||||||
|
cel.dev/expr v0.25.1 // indirect
|
||||||
filippo.io/edwards25519 v1.2.0 // indirect
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
|
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
||||||
|
github.com/benbjohnson/clock v1.3.5 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||||
github.com/casbin/govaluate v1.10.0 // indirect
|
github.com/casbin/govaluate v1.10.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/dennwc/iters v1.1.0 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/frostbyte73/core v0.1.1 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||||
|
github.com/gammazero/deque v1.1.0 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.1 // indirect
|
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||||
github.com/glebarez/sqlite v1.11.0 // indirect
|
github.com/glebarez/sqlite v1.11.0 // indirect
|
||||||
github.com/go-ini/ini v1.67.0 // indirect
|
github.com/go-ini/ini v1.67.0 // indirect
|
||||||
|
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
@@ -52,6 +65,7 @@ require (
|
|||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||||
|
github.com/google/cel-go v0.26.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.9.1 // indirect
|
github.com/jackc/pgx/v5 v5.9.1 // indirect
|
||||||
@@ -59,10 +73,14 @@ require (
|
|||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/jxskiss/base62 v1.1.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.5 // indirect
|
github.com/klauspost/compress v1.18.5 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/lithammer/shortuuid/v4 v4.2.0 // indirect
|
||||||
|
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // indirect
|
||||||
|
github.com/livekit/psrpc v0.6.1-0.20250511053145-465289d72c3c // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.37 // indirect
|
github.com/mattn/go-sqlite3 v1.14.37 // indirect
|
||||||
github.com/microsoft/go-mssqldb v1.9.8 // indirect
|
github.com/microsoft/go-mssqldb v1.9.8 // indirect
|
||||||
@@ -70,10 +88,31 @@ require (
|
|||||||
github.com/minio/md5-simd v1.1.2 // indirect
|
github.com/minio/md5-simd v1.1.2 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/nats-io/nats.go v1.43.0 // indirect
|
||||||
|
github.com/nats-io/nkeys v0.4.11 // indirect
|
||||||
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/opencontainers/runc v1.1.14 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
|
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
|
||||||
github.com/philhofer/fwd v1.2.0 // indirect
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
|
github.com/pion/datachannel v1.5.10 // indirect
|
||||||
|
github.com/pion/dtls/v3 v3.0.6 // indirect
|
||||||
|
github.com/pion/ice/v4 v4.0.10 // indirect
|
||||||
|
github.com/pion/interceptor v0.1.40 // indirect
|
||||||
|
github.com/pion/logging v0.2.4 // indirect
|
||||||
|
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||||
|
github.com/pion/randutil v0.1.0 // indirect
|
||||||
|
github.com/pion/rtcp v1.2.15 // indirect
|
||||||
|
github.com/pion/rtp v1.8.21 // indirect
|
||||||
|
github.com/pion/sctp v1.8.39 // indirect
|
||||||
|
github.com/pion/sdp/v3 v3.0.15 // indirect
|
||||||
|
github.com/pion/srtp/v3 v3.0.6 // indirect
|
||||||
|
github.com/pion/stun/v3 v3.0.0 // indirect
|
||||||
|
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||||
|
github.com/pion/turn/v4 v4.0.2 // indirect
|
||||||
|
github.com/pion/webrtc/v4 v4.1.3 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
|
||||||
github.com/quic-go/qpack v0.6.0 // indirect
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
@@ -84,28 +123,35 @@ require (
|
|||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
|
github.com/stoewer/go-strcase v1.3.1 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/tinylib/msgp v1.6.3 // indirect
|
github.com/tinylib/msgp v1.6.3 // indirect
|
||||||
|
github.com/twitchtv/twirp v8.1.3+incompatible // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
|
github.com/wlynxg/anet v0.0.5 // indirect
|
||||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||||
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.42.0 // indirect
|
go.opentelemetry.io/otel v1.42.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect
|
go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
go.uber.org/zap/exp v0.3.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/arch v0.25.0 // indirect
|
golang.org/x/arch v0.25.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
|
||||||
golang.org/x/net v0.52.0 // indirect
|
golang.org/x/net v0.52.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/text v0.35.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
golang.org/x/tools v0.43.0 // indirect
|
golang.org/x/tools v0.43.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
||||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
gorm.io/driver/mysql v1.6.0 // indirect
|
gorm.io/driver/mysql v1.6.0 // indirect
|
||||||
gorm.io/driver/sqlserver v1.6.3 // indirect
|
gorm.io/driver/sqlserver v1.6.3 // indirect
|
||||||
gorm.io/plugin/dbresolver v1.6.2 // indirect
|
|
||||||
modernc.org/libc v1.70.0 // indirect
|
modernc.org/libc v1.70.0 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
|||||||
136
go.sum
136
go.sum
@@ -1,3 +1,13 @@
|
|||||||
|
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250717185734-6c6e0d3c608e.1 h1:Lg6klmCi3v7VvpqeeLEER9/m5S8y9e9DjhqQnSCNy4k=
|
||||||
|
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250717185734-6c6e0d3c608e.1/go.mod h1:avRlCjnFzl98VPaeCtJ24RrV/wwHFzB8sWXhj26+n/U=
|
||||||
|
buf.build/go/protovalidate v0.14.0 h1:kr/rC/no+DtRyYX+8KXLDxNnI1rINz0imk5K44ZpZ3A=
|
||||||
|
buf.build/go/protovalidate v0.14.0/go.mod h1:+F/oISho9MO7gJQNYC2VWLzcO1fTPmaTA08SDYJZncA=
|
||||||
|
buf.build/go/protoyaml v0.6.0 h1:Nzz1lvcXF8YgNZXk+voPPwdU8FjDPTUV4ndNTXN0n2w=
|
||||||
|
buf.build/go/protoyaml v0.6.0/go.mod h1:RgUOsBu/GYKLDSIRgQXniXbNgFlGEZnQpRAUdLAFV2Q=
|
||||||
|
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||||
|
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||||
|
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||||
|
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
||||||
@@ -20,12 +30,22 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:
|
|||||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI=
|
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI=
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4=
|
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4=
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA=
|
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
||||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
|
||||||
|
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
|
||||||
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
|
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
|
||||||
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||||
|
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
||||||
|
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
|
||||||
|
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
|
||||||
|
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
@@ -46,28 +66,46 @@ github.com/casbin/gorm-adapter/v3 v3.41.0/go.mod h1:BQZRJhwUnwMpI+pT2m7/cUJwXxrH
|
|||||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/containerd/continuity v0.4.3 h1:6HVkalIp+2u1ZLH1J/pYX2oBVXlJZvh1X1A7bEZ9Su8=
|
||||||
|
github.com/containerd/continuity v0.4.3/go.mod h1:F6PTNCKepoxEaXLQp3wDAjygEnImnZ/7o4JzpodfroQ=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dennwc/iters v1.1.0 h1:PsS3DbOU7GxSUQO0e7SGmzHkPhtwOlwbqggJ++Bgnr8=
|
||||||
|
github.com/dennwc/iters v1.1.0/go.mod h1:M9KuuMBeyEXYTmB7EnI9SCyALFCmPWOIxn5W1L0CjGg=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
|
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
|
||||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||||
|
github.com/docker/cli v26.1.4+incompatible h1:I8PHdc0MtxEADqYJZvhBrW9bo8gawKwwenxRM7/rLu8=
|
||||||
|
github.com/docker/cli v26.1.4+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||||
|
github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY=
|
||||||
|
github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
|
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||||
|
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||||
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/frostbyte73/core v0.1.1 h1:ChhJOR7bAKOCPbA+lqDLE2cGKlCG5JXsDvvQr4YaJIA=
|
||||||
|
github.com/frostbyte73/core v0.1.1/go.mod h1:mhfOtR+xWAvwXiwor7jnqPMnu4fxbv1F2MwZ0BEpzZo=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/gammazero/deque v1.1.0 h1:OyiyReBbnEG2PP0Bnv1AASLIYvyKqIFN5xfl1t8oGLo=
|
||||||
|
github.com/gammazero/deque v1.1.0/go.mod h1:JVrR+Bj1NMQbPnYclvDlvSX0nVGReLrQZ0aUMuWLctg=
|
||||||
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||||
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
@@ -78,6 +116,8 @@ github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GM
|
|||||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||||
|
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
|
||||||
|
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
@@ -98,6 +138,8 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
|||||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
@@ -109,12 +151,17 @@ github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei
|
|||||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI=
|
||||||
|
github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
|
||||||
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
||||||
|
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
@@ -150,6 +197,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
|||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/jxskiss/base62 v1.1.0 h1:A5zbF8v8WXx2xixnAKD2w+abC+sIzYJX+nxmhA6HWFw=
|
||||||
|
github.com/jxskiss/base62 v1.1.0/go.mod h1:HhWAlUXvxKThfOlZbcuFzsqwtF5TcqS9ru3y5GfjWAc=
|
||||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
@@ -170,6 +219,14 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
|||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
|
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
|
||||||
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=
|
||||||
|
github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
|
||||||
|
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5ATTo469PQPkqzdoU7be46ryiCDO3boc=
|
||||||
|
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ=
|
||||||
|
github.com/livekit/protocol v1.39.4-0.20250721114233-52633eee694f h1:Cwe38+/ld3r5dnNmIZSALSoZPWNEMeYPZIi/qjpplLo=
|
||||||
|
github.com/livekit/protocol v1.39.4-0.20250721114233-52633eee694f/go.mod h1:YlgUxAegtU8jZ0tVXoIV/4fHeHqqLvS+6JnPKDbpFPU=
|
||||||
|
github.com/livekit/psrpc v0.6.1-0.20250511053145-465289d72c3c h1:WwEr0YBejYbKzk8LSaO9h8h0G9MnE7shyDu8yXQWmEc=
|
||||||
|
github.com/livekit/psrpc v0.6.1-0.20250511053145-465289d72c3c/go.mod h1:kmD+AZPkWu0MaXIMv57jhNlbiSZZ/Jx4bzlxBDVmJes=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||||
@@ -183,6 +240,12 @@ github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
|||||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||||
github.com/minio/minio-go/v7 v7.0.99 h1:2vH/byrwUkIpFQFOilvTfaUpvAX3fEFhEzO+DR3DlCE=
|
github.com/minio/minio-go/v7 v7.0.99 h1:2vH/byrwUkIpFQFOilvTfaUpvAX3fEFhEzO+DR3DlCE=
|
||||||
github.com/minio/minio-go/v7 v7.0.99/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
|
github.com/minio/minio-go/v7 v7.0.99/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
|
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||||
|
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -190,12 +253,58 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
|||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
|
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
|
||||||
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||||
|
github.com/nats-io/nats.go v1.43.0 h1:uRFZ2FEoRvP64+UUhaTokyS18XBCR/xM2vQZKO4i8ug=
|
||||||
|
github.com/nats-io/nats.go v1.43.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||||
|
github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0=
|
||||||
|
github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE=
|
||||||
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
|
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||||
|
github.com/opencontainers/runc v1.1.14 h1:rgSuzbmgz5DUJjeSnw337TxDbRuqjs6iqQck/2weR6w=
|
||||||
|
github.com/opencontainers/runc v1.1.14/go.mod h1:E4C2z+7BxR7GHXp0hAY53mek+x49X1LjPNeMTfRGvOA=
|
||||||
|
github.com/ory/dockertest/v3 v3.11.0 h1:OiHcxKAvSDUwsEVh2BjxQQc/5EHz9n0va9awCtNGuyA=
|
||||||
|
github.com/ory/dockertest/v3 v3.11.0/go.mod h1:VIPxS1gwT9NpPOrfD3rACs8Y9Z7yhzO4SB194iUDnUI=
|
||||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
|
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
|
||||||
|
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
|
||||||
|
github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
|
||||||
|
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
|
||||||
|
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
|
||||||
|
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
|
||||||
|
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
|
||||||
|
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
|
||||||
|
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||||
|
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||||
|
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
|
||||||
|
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
|
||||||
|
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||||
|
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||||
|
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
|
||||||
|
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
|
||||||
|
github.com/pion/rtp v1.8.21 h1:3yrOwmZFyUpcIosNcWRpQaU+UXIJ6yxLuJ8Bx0mw37Y=
|
||||||
|
github.com/pion/rtp v1.8.21/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
|
||||||
|
github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
|
||||||
|
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
|
||||||
|
github.com/pion/sdp/v3 v3.0.15 h1:F0I1zds+K/+37ZrzdADmx2Q44OFDOPRLhPnNTaUX9hk=
|
||||||
|
github.com/pion/sdp/v3 v3.0.15/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
|
||||||
|
github.com/pion/srtp/v3 v3.0.6 h1:E2gyj1f5X10sB/qILUGIkL4C2CqK269Xq167PbGCc/4=
|
||||||
|
github.com/pion/srtp/v3 v3.0.6/go.mod h1:BxvziG3v/armJHAaJ87euvkhHqWe9I7iiOy50K2QkhY=
|
||||||
|
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
|
||||||
|
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
|
||||||
|
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
||||||
|
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
||||||
|
github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps=
|
||||||
|
github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs=
|
||||||
|
github.com/pion/webrtc/v4 v4.1.3 h1:YZ67Boj9X/hk190jJZ8+HFGQ6DqSZ/fYP3sLAZv7c3c=
|
||||||
|
github.com/pion/webrtc/v4 v4.1.3/go.mod h1:rsq+zQ82ryfR9vbb0L1umPJ6Ogq7zm8mcn9fcGnxomM=
|
||||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||||
@@ -204,6 +313,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
|||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
|
||||||
|
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
|
||||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||||
@@ -220,8 +331,12 @@ github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
|||||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||||
|
github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
|
||||||
|
github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
|
||||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
@@ -230,6 +345,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
|||||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||||
|
github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs=
|
||||||
|
github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -249,13 +366,25 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
|
|||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
|
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
|
||||||
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||||
|
github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU=
|
||||||
|
github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||||
|
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||||
|
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
|
||||||
|
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||||
|
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
|
||||||
|
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||||
|
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
|
||||||
|
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||||
|
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
@@ -282,6 +411,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
|||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
|
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
|
||||||
|
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
|
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
|
||||||
@@ -300,6 +431,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
|
|||||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
|
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
|
||||||
|
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
|
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
|
||||||
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||||
@@ -401,6 +534,8 @@ golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||||
@@ -417,6 +552,7 @@ gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkp
|
|||||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
137
internal/cache/conversation_cache.go
vendored
137
internal/cache/conversation_cache.go
vendored
@@ -9,10 +9,37 @@ import (
|
|||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
|
redisPkg "with_you/internal/pkg/redis"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// nextSeqLua 原子递增 seq:key 存在时 INCR 返回新值,不存在时返回 0 触发冷启动
|
||||||
|
var nextSeqLua = redis.NewScript(`
|
||||||
|
local seqKey = KEYS[1]
|
||||||
|
if redis.call('EXISTS', seqKey) == 1 then
|
||||||
|
return redis.call('INCR', seqKey)
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
`)
|
||||||
|
|
||||||
|
// initSeqLua 冷启动原子初始化:SETNX 防止覆盖 + INCR 递增,返回第一个可用 seq
|
||||||
|
var initSeqLua = redis.NewScript(`
|
||||||
|
local seqKey = KEYS[1]
|
||||||
|
local initVal = tonumber(ARGV[1])
|
||||||
|
redis.call('SETNX', seqKey, initVal)
|
||||||
|
return redis.call('INCR', seqKey)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// syncSeqLua 写透缓存:确保 Redis seq >= DB seq,防止冷启动后读到过期值
|
||||||
|
var syncSeqLua = redis.NewScript(`
|
||||||
|
local current = tonumber(redis.call('GET', KEYS[1]))
|
||||||
|
if current == nil or current < tonumber(ARGV[1]) then
|
||||||
|
redis.call('SET', KEYS[1], ARGV[1])
|
||||||
|
end
|
||||||
|
`)
|
||||||
|
|
||||||
// CachedConversation 带缓存元数据的会话
|
// CachedConversation 带缓存元数据的会话
|
||||||
type CachedConversation struct {
|
type CachedConversation struct {
|
||||||
Data *model.Conversation // 实际数据
|
Data *model.Conversation // 实际数据
|
||||||
@@ -221,6 +248,7 @@ type MessageRepository interface {
|
|||||||
GetMessages(convID string, page, pageSize int) ([]*model.Message, int64, error)
|
GetMessages(convID string, page, pageSize int) ([]*model.Message, int64, error)
|
||||||
GetMessagesAfterSeq(convID string, afterSeq int64, limit int) ([]*model.Message, error)
|
GetMessagesAfterSeq(convID string, afterSeq int64, limit int) ([]*model.Message, error)
|
||||||
GetMessagesBeforeSeq(convID string, beforeSeq int64, limit int) ([]*model.Message, error)
|
GetMessagesBeforeSeq(convID string, beforeSeq int64, limit int) ([]*model.Message, error)
|
||||||
|
GetNextSeq(convID string) (int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -229,22 +257,24 @@ type MessageRepository interface {
|
|||||||
|
|
||||||
// ConversationCache 会话缓存管理器
|
// ConversationCache 会话缓存管理器
|
||||||
type ConversationCache struct {
|
type ConversationCache struct {
|
||||||
cache Cache // 底层缓存
|
cache Cache // 底层缓存
|
||||||
settings *ConversationCacheSettings // 配置
|
settings *ConversationCacheSettings // 配置
|
||||||
repo ConversationRepository // 数据仓库接口(用于 cache-aside 回源)
|
repo ConversationRepository // 数据仓库接口(用于 cache-aside 回源)
|
||||||
msgRepo MessageRepository // 消息数据仓库接口(用于消息缓存回源)
|
msgRepo MessageRepository // 消息数据仓库接口(用于消息缓存回源)
|
||||||
|
seqBufferMgr *SeqBufferManager // seq 预分配管理器(nil 则使用旧 INCR 逻辑)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConversationCache 创建会话缓存管理器
|
// NewConversationCache 创建会话缓存管理器
|
||||||
func NewConversationCache(cache Cache, repo ConversationRepository, msgRepo MessageRepository, settings *ConversationCacheSettings) *ConversationCache {
|
func NewConversationCache(cache Cache, repo ConversationRepository, msgRepo MessageRepository, settings *ConversationCacheSettings, seqBufferMgr *SeqBufferManager) *ConversationCache {
|
||||||
if settings == nil {
|
if settings == nil {
|
||||||
settings = DefaultConversationCacheSettings()
|
settings = DefaultConversationCacheSettings()
|
||||||
}
|
}
|
||||||
return &ConversationCache{
|
return &ConversationCache{
|
||||||
cache: cache,
|
cache: cache,
|
||||||
settings: settings,
|
settings: settings,
|
||||||
repo: repo,
|
repo: repo,
|
||||||
msgRepo: msgRepo,
|
msgRepo: msgRepo,
|
||||||
|
seqBufferMgr: seqBufferMgr,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,36 +722,79 @@ func (c *ConversationCache) ComputeAllUnreadCount(ctx context.Context, userID st
|
|||||||
return total, nil
|
return total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetNextSeq 获取会话的下一个 seq 值(原子递增)
|
// GetNextSeq 获取会话的下一个 seq 值(单次 Redis INCR,原子无竞态)
|
||||||
// 使用 Redis INCR 实现,首次调用时从 DB 初始化并补齐差值
|
|
||||||
func (c *ConversationCache) GetNextSeq(ctx context.Context, convID string) (int64, error) {
|
func (c *ConversationCache) GetNextSeq(ctx context.Context, convID string) (int64, error) {
|
||||||
|
// 优先使用 SeqBufferManager(已简化为单次 INCR)
|
||||||
|
if c.seqBufferMgr != nil && c.seqBufferMgr.IsEnabled() {
|
||||||
|
return c.seqBufferMgr.GetNextSeq(ctx, convID, false)
|
||||||
|
}
|
||||||
|
|
||||||
seqKey := MessageSeqKey(convID)
|
seqKey := MessageSeqKey(convID)
|
||||||
|
|
||||||
newSeq, err := c.cache.Incr(ctx, seqKey)
|
// 尝试 Redis INCR(key 存在时递增,不存在时返回 0 触发冷启动)
|
||||||
if err != nil {
|
if lc, ok := c.cache.(*LayeredCache); ok && lc.IsEnabled() {
|
||||||
return 0, fmt.Errorf("redis incr seq failed: %w", err)
|
rdb := lc.GetRedisClient()
|
||||||
}
|
if rdb != nil {
|
||||||
|
result, err := nextSeqLua.Run(ctx, rdb.GetClient(), []string{seqKey}).Int64()
|
||||||
// 首次调用时 Redis key 不存在,INCR 从 0 返回 1
|
if err == nil {
|
||||||
if newSeq == 1 && c.repo != nil {
|
if result > 0 {
|
||||||
conv, err := c.repo.GetConversationByID(convID)
|
_ = c.cache.Expire(ctx, seqKey, c.settings.SeqTTL)
|
||||||
if err != nil {
|
return result, nil
|
||||||
return 0, fmt.Errorf("failed to load conversation for seq init: %w", err)
|
}
|
||||||
}
|
// 冷启动:从 DB 加载 last_seq,SETNX+INCR 原子初始化
|
||||||
|
return c.initSeqFromDB(ctx, convID, seqKey, rdb)
|
||||||
if conv.LastSeq > 0 {
|
|
||||||
// INCR 已返回 1,需要补齐 delta 使总值为 last_seq + 1
|
|
||||||
alignedSeq, err := c.cache.IncrBySeq(ctx, seqKey, conv.LastSeq)
|
|
||||||
if err != nil {
|
|
||||||
return conv.LastSeq + 1, nil // 降级返回 DB 值
|
|
||||||
}
|
}
|
||||||
newSeq = alignedSeq
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = c.cache.Expire(ctx, seqKey, c.settings.SeqTTL)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return newSeq, nil
|
// Redis 不可用,降级到 DB(SELECT FOR UPDATE 保证原子性)
|
||||||
|
if c.msgRepo != nil {
|
||||||
|
if dbSeq, dbErr := c.msgRepo.GetNextSeq(convID); dbErr == nil {
|
||||||
|
return dbSeq, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("seq allocation failed for %s: no redis or db available", convID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// initSeqFromDB 冷启动:从 DB 加载 last_seq,用 SETNX+INCR 原子初始化
|
||||||
|
func (c *ConversationCache) initSeqFromDB(ctx context.Context, convID, seqKey string, rdb *redisPkg.Client) (int64, error) {
|
||||||
|
if c.repo == nil {
|
||||||
|
return 0, fmt.Errorf("conversation repository not configured for seq init")
|
||||||
|
}
|
||||||
|
conv, dbErr := c.repo.GetConversationByID(convID)
|
||||||
|
if dbErr != nil {
|
||||||
|
return 0, fmt.Errorf("load conversation for seq init: %w", dbErr)
|
||||||
|
}
|
||||||
|
result, initErr := initSeqLua.Run(ctx, rdb.GetClient(), []string{seqKey}, conv.LastSeq).Int64()
|
||||||
|
if initErr != nil {
|
||||||
|
return 0, fmt.Errorf("initSeqLua failed: %w", initErr)
|
||||||
|
}
|
||||||
|
_ = c.cache.Expire(ctx, seqKey, c.settings.SeqTTL)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNextSeqWithGroup 获取会话的下一个 seq 值(isGroup 参数保留兼容,简化后不区分步长)
|
||||||
|
func (c *ConversationCache) GetNextSeqWithGroup(ctx context.Context, convID string, isGroup bool) (int64, error) {
|
||||||
|
if c.seqBufferMgr != nil && c.seqBufferMgr.IsEnabled() {
|
||||||
|
return c.seqBufferMgr.GetNextSeq(ctx, convID, isGroup)
|
||||||
|
}
|
||||||
|
return c.GetNextSeq(ctx, convID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncConvSeq 写透缓存:将 DB 刚分配的 seq 同步到 Redis,确保 Redis seq >= DB seq
|
||||||
|
func (c *ConversationCache) SyncConvSeq(ctx context.Context, convID string, seq int64) {
|
||||||
|
seqKey := MessageSeqKey(convID)
|
||||||
|
|
||||||
|
if lc, ok := c.cache.(*LayeredCache); ok && lc.IsEnabled() {
|
||||||
|
rdb := lc.GetRedisClient()
|
||||||
|
if rdb != nil {
|
||||||
|
_ = syncSeqLua.Run(ctx, rdb.GetClient(), []string{seqKey}, seq).Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.seqBufferMgr != nil && c.seqBufferMgr.rdb != nil {
|
||||||
|
_ = syncSeqLua.Run(ctx, c.seqBufferMgr.rdb, []string{seqKey}, seq).Err()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
122
internal/cache/idempotency.go
vendored
Normal file
122
internal/cache/idempotency.go
vendored
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
redislib "github.com/redis/go-redis/v9"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IdempotencyResult 描述幂等检查的结果。
|
||||||
|
type IdempotencyResult struct {
|
||||||
|
// Acquired 为 true 表示当前请求成功抢占到幂等键,调用方应执行业务逻辑。
|
||||||
|
// 为 false 表示检测到重复请求:ExistingValue 为先前写入的值(可能为空,
|
||||||
|
// 表示前一个请求仍在进行中——即"进行中"占位)。
|
||||||
|
Acquired bool
|
||||||
|
// ExistingValue 命中已有幂等记录时的值(如已创建的 postID)。
|
||||||
|
// Acquired=true 时为空字符串。
|
||||||
|
ExistingValue string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TryAcquireIdempotency 原子抢占幂等键。
|
||||||
|
//
|
||||||
|
// 流程:
|
||||||
|
// 1. 读取幂等键。若已有值,视为重复请求,返回 (Acquired=false, ExistingValue=...)。
|
||||||
|
// 2. 若无值,用 SetNX 写入空占位(标记"进行中")。成功则获得执行权 (Acquired=true)。
|
||||||
|
//
|
||||||
|
// 降级:当 Redis 不可用(cache 非 *LayeredCache/*RedisCache,或底层 client 为 nil)时,
|
||||||
|
// 返回 (Acquired=true),即不阻断业务(与现有 call_service 的降级策略一致),
|
||||||
|
// 此时幂等性退化为依赖前端防抖。
|
||||||
|
//
|
||||||
|
// 调用方在业务成功后必须调用 CompleteIdempotency 写入真实结果,以便后续重试可返回同一资源。
|
||||||
|
func TryAcquireIdempotency(ctx context.Context, c Cache, key string, ttl time.Duration) IdempotencyResult {
|
||||||
|
if c == nil {
|
||||||
|
return IdempotencyResult{Acquired: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) 先查命中:若已有"完成值",直接复用,避免重复创建。
|
||||||
|
if existing, ok := GetTyped[string](c, key); ok && existing != "" {
|
||||||
|
return IdempotencyResult{Acquired: false, ExistingValue: existing}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 用 SetNX 写入空占位,原子抢占执行权。
|
||||||
|
// 占位为空字符串:表示"进行中"。后续命中时若值为空,视为并发请求进行中,
|
||||||
|
// 直接拒绝(Acquired=false 且 ExistingValue="")。
|
||||||
|
rdb := resolveRedisClient(c)
|
||||||
|
if rdb == nil {
|
||||||
|
// Redis 不可用:降级放行,依赖前端防抖。
|
||||||
|
return IdempotencyResult{Acquired: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := rdb.SetNX(ctx, normalizeKey(key), "", ttl).Result()
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Warn("idempotency SetNX failed, falling back to non-idempotent create",
|
||||||
|
zap.String("key", key),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return IdempotencyResult{Acquired: true}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
// 已被其他请求抢占(进行中或已完成但值尚未回填)。
|
||||||
|
// 读取实际值以区分"进行中"与"已完成"。
|
||||||
|
if existing, ok := GetTyped[string](c, key); ok && existing != "" {
|
||||||
|
return IdempotencyResult{Acquired: false, ExistingValue: existing}
|
||||||
|
}
|
||||||
|
return IdempotencyResult{Acquired: false}
|
||||||
|
}
|
||||||
|
return IdempotencyResult{Acquired: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteIdempotency 在业务成功后回填幂等键的真实结果值(如 postID),
|
||||||
|
// 使后续重复请求能返回同一资源。失败时仅记录日志,不影响业务结果。
|
||||||
|
func CompleteIdempotency(ctx context.Context, c Cache, key, value string, ttl time.Duration) {
|
||||||
|
if c == nil || value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 直接覆盖占位为真实值,保留原 TTL。
|
||||||
|
rdb := resolveRedisClient(c)
|
||||||
|
if rdb == nil {
|
||||||
|
// 降级:用通用 Set 写入(LayeredCache/RedisCache 都支持)。
|
||||||
|
c.Set(key, value, ttl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := rdb.Set(ctx, normalizeKey(key), value, ttl).Err(); err != nil {
|
||||||
|
zap.L().Warn("idempotency complete Set failed",
|
||||||
|
zap.String("key", key),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseIdempotency 在业务失败时清除占位,允许重试。
|
||||||
|
func ReleaseIdempotency(ctx context.Context, c Cache, key string) {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 仅当当前值仍为空占位时才删除,避免误删已回填的真实值。
|
||||||
|
if existing, ok := GetTyped[string](c, key); ok && existing != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveRedisClient 从 Cache 实现中解析出底层 *redis.Client,用于 SetNX 原子操作。
|
||||||
|
// 返回 nil 表示 Redis 不可用(如纯内存 LRU 模式)。
|
||||||
|
func resolveRedisClient(c Cache) *redislib.Client {
|
||||||
|
switch v := c.(type) {
|
||||||
|
case *LayeredCache:
|
||||||
|
client := v.GetRedisClient()
|
||||||
|
if client == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return client.GetClient()
|
||||||
|
case *RedisCache:
|
||||||
|
client := v.GetRedisClient()
|
||||||
|
if client == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return client.GetClient()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
60
internal/cache/keys.go
vendored
60
internal/cache/keys.go
vendored
@@ -38,12 +38,15 @@ const (
|
|||||||
PrefixChannelsAllList = "channels:all_list"
|
PrefixChannelsAllList = "channels:all_list"
|
||||||
|
|
||||||
// 消息缓存相关
|
// 消息缓存相关
|
||||||
keyPrefixMsgHash = "msg_hash" // 消息详情 Hash
|
keyPrefixMsgHash = "msg_hash" // 消息详情 Hash
|
||||||
keyPrefixMsgIndex = "msg_index" // 消息索引 Sorted Set
|
keyPrefixMsgIndex = "msg_index" // 消息索引 Sorted Set
|
||||||
keyPrefixMsgCount = "msg_count" // 消息计数
|
keyPrefixMsgCount = "msg_count" // 消息计数
|
||||||
keyPrefixMsgSeq = "msg_seq" // Seq 计数器
|
keyPrefixMsgSeq = "msg_seq" // Seq 计数器
|
||||||
keyPrefixMsgPage = "msg_page" // 分页缓存
|
keyPrefixMsgPage = "msg_page" // 分页缓存
|
||||||
keyPrefixMsgIdempotent = "msg_idem" // 消息幂等键
|
keyPrefixMsgIdempotent = "msg_idem" // 消息幂等键
|
||||||
|
|
||||||
|
// 帖子创建幂等键前缀(clientRequestID -> 已创建的 postID)
|
||||||
|
keyPrefixPostIdempotent = "post_idem"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PostListKey 生成帖子列表缓存键
|
// PostListKey 生成帖子列表缓存键
|
||||||
@@ -86,25 +89,13 @@ func ConversationListKey(userID string, page, pageSize int) string {
|
|||||||
return fmt.Sprintf("%s:%s:%d:%d", PrefixConversationList, userID, page, pageSize)
|
return fmt.Sprintf("%s:%s:%d:%d", PrefixConversationList, userID, page, pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConversationDetailKey 生成会话详情缓存键
|
|
||||||
func ConversationDetailKey(conversationID, userID string) string {
|
|
||||||
return fmt.Sprintf("%s:%s:%s", PrefixConversationDetail, conversationID, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GroupMembersKey 生成群组成员缓存键
|
// GroupMembersKey 生成群组成员缓存键
|
||||||
func GroupMembersKey(groupID string, page, pageSize int) string {
|
func GroupMembersKey(groupID string, page, pageSize int) string {
|
||||||
return fmt.Sprintf("%s:%s:page:%d:size:%d", PrefixGroupMembers, groupID, page, pageSize)
|
return fmt.Sprintf("%s:%s:page:%d:size:%d", PrefixGroupMembers, groupID, page, pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupMembersAllKey 生成群组全量成员ID列表缓存键
|
|
||||||
func GroupMembersAllKey(groupID string) string {
|
|
||||||
return fmt.Sprintf("%s:all:%s", PrefixGroupMembers, groupID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GroupInfoKey 生成群组信息缓存键
|
|
||||||
func GroupInfoKey(groupID string) string {
|
|
||||||
return fmt.Sprintf("%s:%s", PrefixGroupInfo, groupID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnreadSystemKey 生成系统消息未读数缓存键
|
// UnreadSystemKey 生成系统消息未读数缓存键
|
||||||
func UnreadSystemKey(userID string) string {
|
func UnreadSystemKey(userID string) string {
|
||||||
@@ -121,15 +112,6 @@ func UnreadDetailKey(userID, conversationID string) string {
|
|||||||
return fmt.Sprintf("%s:%s:%s", PrefixUnreadDetail, userID, conversationID)
|
return fmt.Sprintf("%s:%s:%s", PrefixUnreadDetail, userID, conversationID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserInfoKey 生成用户信息缓存键
|
|
||||||
func UserInfoKey(userID string) string {
|
|
||||||
return fmt.Sprintf("%s:%s", PrefixUserInfo, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserMeKey 生成当前用户信息缓存键
|
|
||||||
func UserMeKey(userID string) string {
|
|
||||||
return fmt.Sprintf("%s:%s", PrefixUserMe, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChannelsAllListKey 频道全量列表缓存键(含未启用,供 MapByIDs 解析历史帖子 channel)
|
// ChannelsAllListKey 频道全量列表缓存键(含未启用,供 MapByIDs 解析历史帖子 channel)
|
||||||
func ChannelsAllListKey() string {
|
func ChannelsAllListKey() string {
|
||||||
@@ -156,20 +138,12 @@ func InvalidateConversationList(cache Cache, userID string) {
|
|||||||
cache.DeleteByPrefix(PrefixConversationList + ":" + userID + ":")
|
cache.DeleteByPrefix(PrefixConversationList + ":" + userID + ":")
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvalidateConversationDetail 失效会话详情缓存
|
|
||||||
func InvalidateConversationDetail(cache Cache, conversationID, userID string) {
|
|
||||||
cache.Delete(ConversationDetailKey(conversationID, userID))
|
|
||||||
}
|
|
||||||
|
|
||||||
// InvalidateGroupMembers 失效群组成员缓存
|
// InvalidateGroupMembers 失效群组成员缓存
|
||||||
func InvalidateGroupMembers(cache Cache, groupID string) {
|
func InvalidateGroupMembers(cache Cache, groupID string) {
|
||||||
cache.DeleteByPrefix(PrefixGroupMembers + ":" + groupID)
|
cache.DeleteByPrefix(PrefixGroupMembers + ":" + groupID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvalidateGroupInfo 失效群组信息缓存
|
|
||||||
func InvalidateGroupInfo(cache Cache, groupID string) {
|
|
||||||
cache.Delete(GroupInfoKey(groupID))
|
|
||||||
}
|
|
||||||
|
|
||||||
// InvalidateUnreadSystem 失效系统消息未读数缓存
|
// InvalidateUnreadSystem 失效系统消息未读数缓存
|
||||||
func InvalidateUnreadSystem(cache Cache, userID string) {
|
func InvalidateUnreadSystem(cache Cache, userID string) {
|
||||||
@@ -186,11 +160,6 @@ func InvalidateUnreadDetail(cache Cache, userID, conversationID string) {
|
|||||||
cache.Delete(UnreadDetailKey(userID, conversationID))
|
cache.Delete(UnreadDetailKey(userID, conversationID))
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvalidateUserInfo 失效用户信息缓存
|
|
||||||
func InvalidateUserInfo(cache Cache, userID string) {
|
|
||||||
cache.Delete(UserInfoKey(userID))
|
|
||||||
cache.Delete(UserMeKey(userID))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 消息缓存 Key 生成函数
|
// 消息缓存 Key 生成函数
|
||||||
@@ -231,9 +200,10 @@ func MessageIdempotentKey(senderID, clientMsgID string) string {
|
|||||||
return fmt.Sprintf("%s:%s:%s", keyPrefixMsgIdempotent, senderID, clientMsgID)
|
return fmt.Sprintf("%s:%s:%s", keyPrefixMsgIdempotent, senderID, clientMsgID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PushDedupKey 推送去重键,防止同一消息重复推送给同一用户
|
// PostCreateIdempotentKey 帖子创建幂等键 (userID + clientRequestID -> postID)
|
||||||
func PushDedupKey(userID, messageID string) string {
|
// 用于在 SetNX 原子抢占期间识别同一客户端请求的重试,避免重复发帖。
|
||||||
return fmt.Sprintf("push_dedup:%s:%s", userID, messageID)
|
func PostCreateIdempotentKey(userID, clientRequestID string) string {
|
||||||
|
return fmt.Sprintf("%s:%s:%s", keyPrefixPostIdempotent, userID, clientRequestID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserReadSeqKey 用户已读位置缓存键(OpenIM 风格 SEQ_USER_READ:{convID}:{userID})
|
// UserReadSeqKey 用户已读位置缓存键(OpenIM 风格 SEQ_USER_READ:{convID}:{userID})
|
||||||
@@ -241,7 +211,3 @@ func UserReadSeqKey(convID, userID string) string {
|
|||||||
return fmt.Sprintf("%s:%s:%s", PrefixUserReadSeq, convID, userID)
|
return fmt.Sprintf("%s:%s:%s", PrefixUserReadSeq, convID, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvMaxSeqKey 会话最大 seq 缓存键
|
|
||||||
func ConvMaxSeqKey(convID string) string {
|
|
||||||
return fmt.Sprintf("%s:%s", keyPrefixMsgSeq, convID)
|
|
||||||
}
|
|
||||||
|
|||||||
74
internal/cache/layered_cache.go
vendored
74
internal/cache/layered_cache.go
vendored
@@ -2,13 +2,11 @@
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
redislib "github.com/redis/go-redis/v9"
|
redislib "github.com/redis/go-redis/v9"
|
||||||
"go.uber.org/zap"
|
|
||||||
|
|
||||||
redisPkg "with_you/internal/pkg/redis"
|
redisPkg "with_you/internal/pkg/redis"
|
||||||
)
|
)
|
||||||
@@ -354,8 +352,13 @@ func (c *LayeredCache) ZCard(ctx context.Context, key string) (int64, error) {
|
|||||||
return c.redis.ZCard(ctx, key)
|
return c.redis.ZCard(ctx, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsEnabled 返回 Redis 是否可用
|
||||||
|
func (c *LayeredCache) IsEnabled() bool {
|
||||||
|
return c.enabled && c.redis != nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *LayeredCache) Incr(ctx context.Context, key string) (int64, error) {
|
func (c *LayeredCache) Incr(ctx context.Context, key string) (int64, error) {
|
||||||
if !c.enabled || c.redis == nil {
|
if !c.IsEnabled() {
|
||||||
return 0, ErrKeyNotFound
|
return 0, ErrKeyNotFound
|
||||||
}
|
}
|
||||||
c.local.Delete(key)
|
c.local.Delete(key)
|
||||||
@@ -412,59 +415,6 @@ func (c *LayeredCache) GetRedisClient() *redisPkg.Client {
|
|||||||
return c.redis.GetRedisClient()
|
return c.redis.GetRedisClient()
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetTypedLayered[T any](c *LayeredCache, key string) (T, bool) {
|
|
||||||
var zero T
|
|
||||||
if !c.enabled {
|
|
||||||
return zero, false
|
|
||||||
}
|
|
||||||
|
|
||||||
raw, ok := c.Get(key)
|
|
||||||
if !ok {
|
|
||||||
return zero, false
|
|
||||||
}
|
|
||||||
|
|
||||||
if typed, ok := raw.(T); ok {
|
|
||||||
return typed, true
|
|
||||||
}
|
|
||||||
|
|
||||||
if str, ok := raw.(string); ok {
|
|
||||||
var out T
|
|
||||||
if err := json.Unmarshal([]byte(str), &out); err != nil {
|
|
||||||
return zero, false
|
|
||||||
}
|
|
||||||
return out, true
|
|
||||||
}
|
|
||||||
|
|
||||||
if data, err := json.Marshal(raw); err == nil {
|
|
||||||
var out T
|
|
||||||
if err := json.Unmarshal(data, &out); err != nil {
|
|
||||||
return zero, false
|
|
||||||
}
|
|
||||||
return out, true
|
|
||||||
}
|
|
||||||
|
|
||||||
return zero, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetOrLoadLayered[T any](
|
|
||||||
c *LayeredCache,
|
|
||||||
key string,
|
|
||||||
ttl time.Duration,
|
|
||||||
loader func() (T, error),
|
|
||||||
) (T, error) {
|
|
||||||
if cached, ok := GetTypedLayered[T](c, key); ok {
|
|
||||||
return cached, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
loaded, err := loader()
|
|
||||||
if err != nil {
|
|
||||||
var zero T
|
|
||||||
return zero, err
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Set(key, loaded, ttl)
|
|
||||||
return loaded, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *LayeredCache) InvalidateUserCache(userID uint) {
|
func (c *LayeredCache) InvalidateUserCache(userID uint) {
|
||||||
c.DeleteByPrefix(cacheKeyPrefixUser + ":")
|
c.DeleteByPrefix(cacheKeyPrefixUser + ":")
|
||||||
@@ -509,15 +459,3 @@ func buildGroupMembersKey(groupID uint) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var _ Cache = (*LayeredCache)(nil)
|
var _ Cache = (*LayeredCache)(nil)
|
||||||
|
|
||||||
func (c *LayeredCache) logStats() {
|
|
||||||
stats := c.GetStats()
|
|
||||||
zap.L().Debug("LayeredCache stats",
|
|
||||||
zap.Int64("local_hits", stats.LocalHits),
|
|
||||||
zap.Int64("redis_hits", stats.RedisHits),
|
|
||||||
zap.Int64("misses", stats.Misses),
|
|
||||||
zap.Int64("sets", stats.Sets),
|
|
||||||
zap.Int64("invalidates", stats.Invalidates),
|
|
||||||
zap.Float64("hit_rate", c.GetHitRate()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
19
internal/cache/metrics.go
vendored
19
internal/cache/metrics.go
vendored
@@ -14,25 +14,6 @@ type cacheMetrics struct {
|
|||||||
// metrics 全局缓存指标实例
|
// metrics 全局缓存指标实例
|
||||||
var metrics cacheMetrics
|
var metrics cacheMetrics
|
||||||
|
|
||||||
// MetricsSnapshot 指标快照
|
|
||||||
type MetricsSnapshot struct {
|
|
||||||
Hit int64
|
|
||||||
Miss int64
|
|
||||||
DecodeError int64
|
|
||||||
SetError int64
|
|
||||||
Invalidate int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMetricsSnapshot 获取当前指标快照
|
|
||||||
func GetMetricsSnapshot() MetricsSnapshot {
|
|
||||||
return MetricsSnapshot{
|
|
||||||
Hit: metrics.hit.Load(),
|
|
||||||
Miss: metrics.miss.Load(),
|
|
||||||
DecodeError: metrics.decodeError.Load(),
|
|
||||||
SetError: metrics.setError.Load(),
|
|
||||||
Invalidate: metrics.invalidate.Load(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// recordHit 记录缓存命中
|
// recordHit 记录缓存命中
|
||||||
func recordHit() {
|
func recordHit() {
|
||||||
|
|||||||
5
internal/cache/repository_adapter.go
vendored
5
internal/cache/repository_adapter.go
vendored
@@ -74,3 +74,8 @@ func (a *MessageRepositoryAdapter) CreateMessage(msg *model.Message) error {
|
|||||||
func (a *MessageRepositoryAdapter) UpdateConversationLastSeq(convID string, seq int64) error {
|
func (a *MessageRepositoryAdapter) UpdateConversationLastSeq(convID string, seq int64) error {
|
||||||
return a.repo.UpdateConversationLastSeq(convID, seq)
|
return a.repo.UpdateConversationLastSeq(convID, seq)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetNextSeq 实现 MessageRepository 接口
|
||||||
|
func (a *MessageRepositoryAdapter) GetNextSeq(convID string) (int64, error) {
|
||||||
|
return a.repo.GetNextSeq(convID)
|
||||||
|
}
|
||||||
|
|||||||
105
internal/cache/seq_buffer.go
vendored
Normal file
105
internal/cache/seq_buffer.go
vendored
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
redisPkg "with_you/internal/pkg/redis"
|
||||||
|
)
|
||||||
|
|
||||||
|
const seqKeyTTL = 24 * time.Hour
|
||||||
|
|
||||||
|
// SeqBufferConfig seq 预分配配置(保留结构以兼容配置文件,实际不再使用缓冲)
|
||||||
|
type SeqBufferConfig struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
PrivateBufferSize int `mapstructure:"private_buffer_size"`
|
||||||
|
GroupBufferSize int `mapstructure:"group_buffer_size"`
|
||||||
|
LockTimeoutMs int `mapstructure:"lock_timeout_ms"`
|
||||||
|
MaxRetries int `mapstructure:"max_retries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeqBufferManager seq 分配管理器(简化版:每条消息一个 Redis INCR,无本地缓冲)
|
||||||
|
type SeqBufferManager struct {
|
||||||
|
rdb *redis.Client
|
||||||
|
config SeqBufferConfig
|
||||||
|
repo ConversationRepository
|
||||||
|
msgRepo MessageRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSeqBufferManager 创建 seq 分配管理器
|
||||||
|
func NewSeqBufferManager(rdb *redis.Client, cfg SeqBufferConfig, repo ConversationRepository, msgRepo MessageRepository) *SeqBufferManager {
|
||||||
|
return &SeqBufferManager{
|
||||||
|
rdb: rdb,
|
||||||
|
config: cfg,
|
||||||
|
repo: repo,
|
||||||
|
msgRepo: msgRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNextSeq 获取下一个 seq(单次 Redis INCR,原子且无竞态)
|
||||||
|
func (m *SeqBufferManager) GetNextSeq(ctx context.Context, convID string, _ bool) (int64, error) {
|
||||||
|
seqKey := MessageSeqKey(convID)
|
||||||
|
|
||||||
|
// 1. 尝试 INCR(key 存在时直接递增,原子操作)
|
||||||
|
result, err := nextSeqLua.Run(ctx, m.rdb, []string{seqKey}).Int64()
|
||||||
|
if err == nil {
|
||||||
|
if result > 0 {
|
||||||
|
m.rdb.Expire(ctx, seqKey, seqKeyTTL)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
// result == 0: 冷启动,需从 DB 初始化
|
||||||
|
return m.initAndIncr(ctx, convID, seqKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redis 不可用,降级到 DB
|
||||||
|
zap.L().Warn("seq INCR failed, falling back to DB", zap.String("convID", convID), zap.Error(err))
|
||||||
|
if m.msgRepo != nil {
|
||||||
|
if dbSeq, dbErr := m.msgRepo.GetNextSeq(convID); dbErr == nil {
|
||||||
|
return dbSeq, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("seq allocation failed for %s: %w", convID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// initAndIncr 冷启动:从 DB 加载 last_seq,然后用 SETNX+INCR 原子初始化
|
||||||
|
func (m *SeqBufferManager) initAndIncr(ctx context.Context, convID, seqKey string) (int64, error) {
|
||||||
|
if m.repo == nil {
|
||||||
|
return 0, fmt.Errorf("conversation repository not configured for seq init")
|
||||||
|
}
|
||||||
|
|
||||||
|
conv, err := m.repo.GetConversationByID(convID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("load conversation for seq init: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
initVal := conv.LastSeq
|
||||||
|
result, err := initSeqLua.Run(ctx, m.rdb, []string{seqKey}, initVal).Int64()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("initSeqLua failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.rdb.Expire(ctx, seqKey, seqKeyTTL)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateBuffer no-op(保留接口兼容)
|
||||||
|
func (m *SeqBufferManager) InvalidateBuffer(convID string) {}
|
||||||
|
|
||||||
|
// IsEnabled 是否启用
|
||||||
|
func (m *SeqBufferManager) IsEnabled() bool {
|
||||||
|
return m.config.Enabled && m.rdb != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSeqBufferManagerFromRedisPkg 从 pkg/redis.Client 创建 SeqBufferManager
|
||||||
|
func NewSeqBufferManagerFromRedisPkg(redisPkgClient *redisPkg.Client, cfg SeqBufferConfig, repo ConversationRepository, msgRepo MessageRepository) *SeqBufferManager {
|
||||||
|
if redisPkgClient == nil {
|
||||||
|
zap.L().Warn("SeqBuffer: redis client is nil, seq pre-allocation disabled")
|
||||||
|
return &SeqBufferManager{config: cfg, repo: repo, msgRepo: msgRepo}
|
||||||
|
}
|
||||||
|
rdb := redisPkgClient.GetClient()
|
||||||
|
return NewSeqBufferManager(rdb, cfg, repo, msgRepo)
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ type Config struct {
|
|||||||
Log LogConfig `mapstructure:"log"`
|
Log LogConfig `mapstructure:"log"`
|
||||||
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
||||||
Upload UploadConfig `mapstructure:"upload"`
|
Upload UploadConfig `mapstructure:"upload"`
|
||||||
|
FileCleanup FileCleanupConfig `mapstructure:"file_cleanup"`
|
||||||
|
DeviceCleanup DeviceCleanupConfig `mapstructure:"device_cleanup"`
|
||||||
OpenAI OpenAIConfig `mapstructure:"openai"`
|
OpenAI OpenAIConfig `mapstructure:"openai"`
|
||||||
TencentTMS TencentTMSConfig `mapstructure:"tencent_tms"`
|
TencentTMS TencentTMSConfig `mapstructure:"tencent_tms"`
|
||||||
Email EmailConfig `mapstructure:"email"`
|
Email EmailConfig `mapstructure:"email"`
|
||||||
@@ -27,13 +29,16 @@ type Config struct {
|
|||||||
Casbin CasbinConfig `mapstructure:"casbin"`
|
Casbin CasbinConfig `mapstructure:"casbin"`
|
||||||
Encryption EncryptionConfig `mapstructure:"encryption"`
|
Encryption EncryptionConfig `mapstructure:"encryption"`
|
||||||
HotRank HotRankConfig `mapstructure:"hot_rank"`
|
HotRank HotRankConfig `mapstructure:"hot_rank"`
|
||||||
WebRTC WebRTCConfig `mapstructure:"webrtc"`
|
LiveKit LiveKitConfig `mapstructure:"livekit"`
|
||||||
Report ReportConfig `mapstructure:"report"`
|
Report ReportConfig `mapstructure:"report"`
|
||||||
Sensitive SensitiveConfig `mapstructure:"sensitive"`
|
Sensitive SensitiveConfig `mapstructure:"sensitive"`
|
||||||
JPush JPushConfig `mapstructure:"jpush"`
|
JPush JPushConfig `mapstructure:"jpush"`
|
||||||
SetupSecret string `mapstructure:"setup_secret"`
|
SetupSecret string `mapstructure:"setup_secret"`
|
||||||
WebSocket WSConfig `mapstructure:"websocket"`
|
WebSocket WSConfig `mapstructure:"websocket"`
|
||||||
Runner RunnerConfig `mapstructure:"runner"`
|
Runner RunnerConfig `mapstructure:"runner"`
|
||||||
|
SeqBuffer SeqBufferConfig `mapstructure:"seq_buffer"`
|
||||||
|
VersionLog VersionLogConfig `mapstructure:"version_log"`
|
||||||
|
PushWorker PushWorkerConfig `mapstructure:"push_worker"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load 加载配置文件
|
// Load 加载配置文件
|
||||||
@@ -59,6 +64,21 @@ func Load(configPath string) (*Config, error) {
|
|||||||
viper.SetDefault("database.slow_threshold_ms", 200)
|
viper.SetDefault("database.slow_threshold_ms", 200)
|
||||||
viper.SetDefault("database.ignore_record_not_found", true)
|
viper.SetDefault("database.ignore_record_not_found", true)
|
||||||
viper.SetDefault("database.parameterized_queries", true)
|
viper.SetDefault("database.parameterized_queries", true)
|
||||||
|
viper.SetDefault("database.replica_policy", "random")
|
||||||
|
|
||||||
|
// 读写分离:单副本字段显式 BindEnv。
|
||||||
|
// AutomaticEnv 对 YAML 中未出现的嵌套 key(replica 段在 config.yaml 中默认被注释)
|
||||||
|
// 不会反向推导,必须逐字段 BindEnv 才能让 APP_DATABASE_REPLICA_HOST 等环境变量生效。
|
||||||
|
// 与 jpush.channel.* 处理方式一致(见下方 BindEnv 块)。
|
||||||
|
viper.BindEnv("database.replica.host", "APP_DATABASE_REPLICA_HOST")
|
||||||
|
viper.BindEnv("database.replica.port", "APP_DATABASE_REPLICA_PORT")
|
||||||
|
viper.BindEnv("database.replica.user", "APP_DATABASE_REPLICA_USER")
|
||||||
|
viper.BindEnv("database.replica.password", "APP_DATABASE_REPLICA_PASSWORD")
|
||||||
|
viper.BindEnv("database.replica.dbname", "APP_DATABASE_REPLICA_DBNAME")
|
||||||
|
viper.BindEnv("database.replica.sslmode", "APP_DATABASE_REPLICA_SSLMODE")
|
||||||
|
// 副本连接池参数(可选,留空回退到主库连接池配置,见 database.go)
|
||||||
|
viper.BindEnv("database.replica_max_idle_conns", "APP_DATABASE_REPLICA_MAX_IDLE_CONNS")
|
||||||
|
viper.BindEnv("database.replica_max_open_conns", "APP_DATABASE_REPLICA_MAX_OPEN_CONNS")
|
||||||
viper.SetDefault("redis.type", "miniredis")
|
viper.SetDefault("redis.type", "miniredis")
|
||||||
viper.SetDefault("redis.redis.host", "localhost")
|
viper.SetDefault("redis.redis.host", "localhost")
|
||||||
viper.SetDefault("redis.redis.port", 6379)
|
viper.SetDefault("redis.redis.port", 6379)
|
||||||
@@ -91,6 +111,15 @@ func Load(configPath string) (*Config, error) {
|
|||||||
viper.SetDefault("rate_limit.requests_per_minute", 60)
|
viper.SetDefault("rate_limit.requests_per_minute", 60)
|
||||||
viper.SetDefault("upload.max_file_size", 10485760)
|
viper.SetDefault("upload.max_file_size", 10485760)
|
||||||
viper.SetDefault("upload.allowed_types", []string{"image/jpeg", "image/png", "image/gif", "image/webp"})
|
viper.SetDefault("upload.allowed_types", []string{"image/jpeg", "image/png", "image/gif", "image/webp"})
|
||||||
|
// 文件过期清理默认值
|
||||||
|
viper.SetDefault("file_cleanup.enabled", true)
|
||||||
|
viper.SetDefault("file_cleanup.retention_days", 7)
|
||||||
|
viper.SetDefault("file_cleanup.interval_minutes", 360)
|
||||||
|
viper.SetDefault("file_cleanup.batch_size", 100)
|
||||||
|
// 设备 registration_id 清理默认值(3 天不活跃即清理)
|
||||||
|
viper.SetDefault("device_cleanup.enabled", true)
|
||||||
|
viper.SetDefault("device_cleanup.retention_days", 3)
|
||||||
|
viper.SetDefault("device_cleanup.interval_minutes", 360)
|
||||||
viper.SetDefault("s3.endpoint", "")
|
viper.SetDefault("s3.endpoint", "")
|
||||||
viper.SetDefault("s3.access_key", "")
|
viper.SetDefault("s3.access_key", "")
|
||||||
viper.SetDefault("s3.secret_key", "")
|
viper.SetDefault("s3.secret_key", "")
|
||||||
@@ -164,16 +193,94 @@ func Load(configPath string) (*Config, error) {
|
|||||||
viper.SetDefault("hot_rank.recent_window_hours", 168)
|
viper.SetDefault("hot_rank.recent_window_hours", 168)
|
||||||
viper.SetDefault("hot_rank.recent_fetch_limit", 500)
|
viper.SetDefault("hot_rank.recent_fetch_limit", 500)
|
||||||
viper.SetDefault("hot_rank.candidate_cap", 800)
|
viper.SetDefault("hot_rank.candidate_cap", 800)
|
||||||
// WebRTC 默认值(Google 公共 STUN 服务器)
|
|
||||||
viper.SetDefault("webrtc.ice_servers", []map[string]any{
|
|
||||||
{"urls": []string{"stun:stun.l.google.com:19302"}},
|
|
||||||
})
|
|
||||||
// Report 默认值
|
// Report 默认值
|
||||||
viper.SetDefault("report.auto_hide_threshold", 3)
|
viper.SetDefault("report.auto_hide_threshold", 3)
|
||||||
viper.SetDefault("jpush.enabled", false)
|
viper.SetDefault("jpush.enabled", false)
|
||||||
viper.SetDefault("jpush.app_key", "")
|
viper.SetDefault("jpush.app_key", "")
|
||||||
viper.SetDefault("jpush.master_secret", "")
|
viper.SetDefault("jpush.master_secret", "")
|
||||||
viper.SetDefault("jpush.production", false)
|
viper.SetDefault("jpush.production", false)
|
||||||
|
// JPush 厂商通道 channel_id 默认值
|
||||||
|
// 各厂商 channel_id 通过环境变量配置(见下方 BindEnv)
|
||||||
|
viper.SetDefault("jpush.channel.system", "")
|
||||||
|
viper.SetDefault("jpush.channel.chat", "")
|
||||||
|
viper.SetDefault("jpush.channel.vendor.xiaomi.system", "153609")
|
||||||
|
viper.SetDefault("jpush.channel.vendor.xiaomi.chat", "153608")
|
||||||
|
viper.SetDefault("jpush.channel.vendor.huawei.system", "")
|
||||||
|
viper.SetDefault("jpush.channel.vendor.huawei.chat", "")
|
||||||
|
viper.SetDefault("jpush.channel.vendor.oppo.system", "")
|
||||||
|
viper.SetDefault("jpush.channel.vendor.oppo.chat", "")
|
||||||
|
viper.SetDefault("jpush.channel.vendor.vivo.system", "")
|
||||||
|
viper.SetDefault("jpush.channel.vendor.vivo.chat", "")
|
||||||
|
viper.SetDefault("jpush.channel.vendor.meizu.system", "")
|
||||||
|
viper.SetDefault("jpush.channel.vendor.meizu.chat", "")
|
||||||
|
viper.SetDefault("jpush.channel.vendor.honor.system", "")
|
||||||
|
viper.SetDefault("jpush.channel.vendor.honor.chat", "")
|
||||||
|
viper.SetDefault("jpush.channel.vendor.fcm.system", "")
|
||||||
|
viper.SetDefault("jpush.channel.vendor.fcm.chat", "")
|
||||||
|
// 小米/OPPO 模板 id 默认值
|
||||||
|
viper.SetDefault("jpush.channel.vendor.xiaomi.mi_system_template_id", "P10761")
|
||||||
|
viper.SetDefault("jpush.channel.vendor.xiaomi.mi_chat_template_id", "M10289")
|
||||||
|
viper.SetDefault("jpush.channel.vendor.oppo.oppo_system_private_template_id", "")
|
||||||
|
viper.SetDefault("jpush.channel.vendor.oppo.oppo_chat_private_template_id", "")
|
||||||
|
// OPPO 消息分类默认值(category / notify_level)
|
||||||
|
// OPPO 消息分类默认值(2024.11.20 新规)
|
||||||
|
// chat: category=IM(即时通讯), notify_level=16(通知栏+锁屏+横幅+震动+铃声,强提醒)
|
||||||
|
// system: category=ACCOUNT(账号动态/互动通知), notify_level=2(通知栏+锁屏,避免打扰)
|
||||||
|
// 注:使用 notify_level 时 category 必传,此处两者同时配置
|
||||||
|
viper.SetDefault("jpush.channel.vendor.oppo.oppo_system_category", "ACCOUNT")
|
||||||
|
viper.SetDefault("jpush.channel.vendor.oppo.oppo_chat_category", "IM")
|
||||||
|
viper.SetDefault("jpush.channel.vendor.oppo.oppo_system_notify_level", 2)
|
||||||
|
viper.SetDefault("jpush.channel.vendor.oppo.oppo_chat_notify_level", 16)
|
||||||
|
// 荣耀 importance 默认值
|
||||||
|
// chat: NORMAL(服务与通讯,及时送达), system: LOW(资讯营销,避免打扰)
|
||||||
|
// 注:classification 优先级更高,会覆盖 importance
|
||||||
|
viper.SetDefault("jpush.channel.vendor.honor.honor_system_importance", "LOW")
|
||||||
|
viper.SetDefault("jpush.channel.vendor.honor.honor_chat_importance", "NORMAL")
|
||||||
|
// vivo category 默认值(classification=1 时必须为系统消息类)
|
||||||
|
// chat: IM(即时通讯), system: ACCOUNT(账号动态/互动通知)
|
||||||
|
viper.SetDefault("jpush.channel.vendor.vivo.vivo_system_category", "ACCOUNT")
|
||||||
|
viper.SetDefault("jpush.channel.vendor.vivo.vivo_chat_category", "IM")
|
||||||
|
// iOS 通知分组 thread-id 默认值(留空,不分组)
|
||||||
|
viper.SetDefault("jpush.channel.ios.chat_thread_id", "")
|
||||||
|
viper.SetDefault("jpush.channel.ios.system_thread_id", "")
|
||||||
|
// JPush 厂商 channel_id 环境变量显式绑定
|
||||||
|
// AutomaticEnv 对深层嵌套 key 不可靠,故逐个 BindEnv
|
||||||
|
// 命名规则:APP_JPUSH_CHANNEL_{VENDOR}_{SYSTEM|CHAT}
|
||||||
|
viper.BindEnv("jpush.channel.system", "APP_JPUSH_CHANNEL_SYSTEM")
|
||||||
|
viper.BindEnv("jpush.channel.chat", "APP_JPUSH_CHANNEL_CHAT")
|
||||||
|
viper.BindEnv("jpush.channel.vendor.xiaomi.system", "APP_JPUSH_CHANNEL_XIAOMI_SYSTEM")
|
||||||
|
viper.BindEnv("jpush.channel.vendor.xiaomi.chat", "APP_JPUSH_CHANNEL_XIAOMI_CHAT")
|
||||||
|
viper.BindEnv("jpush.channel.vendor.huawei.system", "APP_JPUSH_CHANNEL_HUAWEI_SYSTEM")
|
||||||
|
viper.BindEnv("jpush.channel.vendor.huawei.chat", "APP_JPUSH_CHANNEL_HUAWEI_CHAT")
|
||||||
|
viper.BindEnv("jpush.channel.vendor.oppo.system", "APP_JPUSH_CHANNEL_OPPO_SYSTEM")
|
||||||
|
viper.BindEnv("jpush.channel.vendor.oppo.chat", "APP_JPUSH_CHANNEL_OPPO_CHAT")
|
||||||
|
viper.BindEnv("jpush.channel.vendor.vivo.system", "APP_JPUSH_CHANNEL_VIVO_SYSTEM")
|
||||||
|
viper.BindEnv("jpush.channel.vendor.vivo.chat", "APP_JPUSH_CHANNEL_VIVO_CHAT")
|
||||||
|
viper.BindEnv("jpush.channel.vendor.meizu.system", "APP_JPUSH_CHANNEL_MEIZU_SYSTEM")
|
||||||
|
viper.BindEnv("jpush.channel.vendor.meizu.chat", "APP_JPUSH_CHANNEL_MEIZU_CHAT")
|
||||||
|
viper.BindEnv("jpush.channel.vendor.honor.system", "APP_JPUSH_CHANNEL_HONOR_SYSTEM")
|
||||||
|
viper.BindEnv("jpush.channel.vendor.honor.chat", "APP_JPUSH_CHANNEL_HONOR_CHAT")
|
||||||
|
viper.BindEnv("jpush.channel.vendor.fcm.system", "APP_JPUSH_CHANNEL_FCM_SYSTEM")
|
||||||
|
viper.BindEnv("jpush.channel.vendor.fcm.chat", "APP_JPUSH_CHANNEL_FCM_CHAT")
|
||||||
|
// 小米/OPPO 模板 id 环境变量绑定
|
||||||
|
viper.BindEnv("jpush.channel.vendor.xiaomi.mi_system_template_id", "APP_JPUSH_CHANNEL_XIAOMI_MI_SYSTEM_TEMPLATE_ID")
|
||||||
|
viper.BindEnv("jpush.channel.vendor.xiaomi.mi_chat_template_id", "APP_JPUSH_CHANNEL_XIAOMI_MI_CHAT_TEMPLATE_ID")
|
||||||
|
viper.BindEnv("jpush.channel.vendor.oppo.oppo_system_private_template_id", "APP_JPUSH_CHANNEL_OPPO_SYSTEM_PRIVATE_TEMPLATE_ID")
|
||||||
|
viper.BindEnv("jpush.channel.vendor.oppo.oppo_chat_private_template_id", "APP_JPUSH_CHANNEL_OPPO_CHAT_PRIVATE_TEMPLATE_ID")
|
||||||
|
// OPPO category / notify_level 环境变量绑定
|
||||||
|
viper.BindEnv("jpush.channel.vendor.oppo.oppo_system_category", "APP_JPUSH_CHANNEL_OPPO_SYSTEM_CATEGORY")
|
||||||
|
viper.BindEnv("jpush.channel.vendor.oppo.oppo_chat_category", "APP_JPUSH_CHANNEL_OPPO_CHAT_CATEGORY")
|
||||||
|
viper.BindEnv("jpush.channel.vendor.oppo.oppo_system_notify_level", "APP_JPUSH_CHANNEL_OPPO_SYSTEM_NOTIFY_LEVEL")
|
||||||
|
viper.BindEnv("jpush.channel.vendor.oppo.oppo_chat_notify_level", "APP_JPUSH_CHANNEL_OPPO_CHAT_NOTIFY_LEVEL")
|
||||||
|
// 荣耀 importance 环境变量绑定
|
||||||
|
viper.BindEnv("jpush.channel.vendor.honor.honor_system_importance", "APP_JPUSH_CHANNEL_HONOR_SYSTEM_IMPORTANCE")
|
||||||
|
viper.BindEnv("jpush.channel.vendor.honor.honor_chat_importance", "APP_JPUSH_CHANNEL_HONOR_CHAT_IMPORTANCE")
|
||||||
|
// vivo category 环境变量绑定
|
||||||
|
viper.BindEnv("jpush.channel.vendor.vivo.vivo_system_category", "APP_JPUSH_CHANNEL_VIVO_SYSTEM_CATEGORY")
|
||||||
|
viper.BindEnv("jpush.channel.vendor.vivo.vivo_chat_category", "APP_JPUSH_CHANNEL_VIVO_CHAT_CATEGORY")
|
||||||
|
// iOS thread-id 环境变量绑定
|
||||||
|
viper.BindEnv("jpush.channel.ios.chat_thread_id", "APP_JPUSH_CHANNEL_IOS_CHAT_THREAD_ID")
|
||||||
|
viper.BindEnv("jpush.channel.ios.system_thread_id", "APP_JPUSH_CHANNEL_IOS_SYSTEM_THREAD_ID")
|
||||||
viper.SetDefault("setup_secret", "")
|
viper.SetDefault("setup_secret", "")
|
||||||
// WebSocket 默认值
|
// WebSocket 默认值
|
||||||
viper.SetDefault("websocket.mode", "standalone")
|
viper.SetDefault("websocket.mode", "standalone")
|
||||||
@@ -190,6 +297,32 @@ func Load(configPath string) (*Config, error) {
|
|||||||
viper.SetDefault("runner.cluster.cap_prefix", "runner:cap:")
|
viper.SetDefault("runner.cluster.cap_prefix", "runner:cap:")
|
||||||
viper.SetDefault("runner.cluster.registry_ttl", 90)
|
viper.SetDefault("runner.cluster.registry_ttl", 90)
|
||||||
viper.SetDefault("runner.cluster.heartbeat_interval", 30)
|
viper.SetDefault("runner.cluster.heartbeat_interval", 30)
|
||||||
|
// SeqBuffer 默认值
|
||||||
|
viper.SetDefault("seq_buffer.enabled", false)
|
||||||
|
viper.SetDefault("seq_buffer.private_buffer_size", 50)
|
||||||
|
viper.SetDefault("seq_buffer.group_buffer_size", 200)
|
||||||
|
viper.SetDefault("seq_buffer.lock_timeout_ms", 5000)
|
||||||
|
viper.SetDefault("seq_buffer.max_retries", 3)
|
||||||
|
// VersionLog 默认值
|
||||||
|
viper.SetDefault("version_log.enabled", false)
|
||||||
|
viper.SetDefault("version_log.sync_limit", 100)
|
||||||
|
viper.SetDefault("version_log.max_sync_gap", 1000)
|
||||||
|
// PushWorker 默认值
|
||||||
|
viper.SetDefault("push_worker.enabled", false)
|
||||||
|
viper.SetDefault("push_worker.stream", "msg_push")
|
||||||
|
viper.SetDefault("push_worker.group", "push_worker")
|
||||||
|
viper.SetDefault("push_worker.batch_size", 50)
|
||||||
|
viper.SetDefault("push_worker.poll_timeout_ms", 2000)
|
||||||
|
viper.SetDefault("push_worker.max_retries", 3)
|
||||||
|
viper.SetDefault("push_worker.max_stream_len", 100000)
|
||||||
|
viper.SetDefault("push_worker.idle_timeout_ms", 30000)
|
||||||
|
// LiveKit 默认值
|
||||||
|
viper.SetDefault("livekit.enabled", false)
|
||||||
|
viper.SetDefault("livekit.url", "http://localhost:7880")
|
||||||
|
viper.SetDefault("livekit.api_key", "devkey")
|
||||||
|
viper.SetDefault("livekit.api_secret", "")
|
||||||
|
viper.SetDefault("livekit.token_ttl", 600)
|
||||||
|
viper.SetDefault("livekit.webhook_secret", "")
|
||||||
|
|
||||||
if err := viper.ReadInConfig(); err != nil {
|
if err := viper.ReadInConfig(); err != nil {
|
||||||
return nil, fmt.Errorf("failed to read config: %w", err)
|
return nil, fmt.Errorf("failed to read config: %w", err)
|
||||||
|
|||||||
@@ -4,15 +4,22 @@ import "fmt"
|
|||||||
|
|
||||||
// DatabaseConfig 数据库配置
|
// DatabaseConfig 数据库配置
|
||||||
type DatabaseConfig struct {
|
type DatabaseConfig struct {
|
||||||
Type string `mapstructure:"type"`
|
Type string `mapstructure:"type"`
|
||||||
SQLite SQLiteConfig `mapstructure:"sqlite"`
|
SQLite SQLiteConfig `mapstructure:"sqlite"`
|
||||||
Postgres PostgresConfig `mapstructure:"postgres"`
|
Postgres PostgresConfig `mapstructure:"postgres"`
|
||||||
MaxIdleConns int `mapstructure:"max_idle_conns"`
|
MaxIdleConns int `mapstructure:"max_idle_conns"`
|
||||||
MaxOpenConns int `mapstructure:"max_open_conns"`
|
MaxOpenConns int `mapstructure:"max_open_conns"`
|
||||||
LogLevel string `mapstructure:"log_level"`
|
LogLevel string `mapstructure:"log_level"`
|
||||||
SlowThresholdMs int `mapstructure:"slow_threshold_ms"`
|
SlowThresholdMs int `mapstructure:"slow_threshold_ms"`
|
||||||
IgnoreRecordNotFound bool `mapstructure:"ignore_record_not_found"`
|
IgnoreRecordNotFound bool `mapstructure:"ignore_record_not_found"`
|
||||||
ParameterizedQueries bool `mapstructure:"parameterized_queries"`
|
ParameterizedQueries bool `mapstructure:"parameterized_queries"`
|
||||||
|
// 读写分离:单副本配置(支持环境变量,如 APP_DATABASE_REPLICA_HOST)
|
||||||
|
Replica PostgresConfig `mapstructure:"replica"`
|
||||||
|
// 读写分离:多副本配置(仅 YAML 支持,环境变量无法覆盖切片结构体)
|
||||||
|
Replicas []PostgresConfig `mapstructure:"replicas"`
|
||||||
|
ReplicaPolicy string `mapstructure:"replica_policy"`
|
||||||
|
ReplicaMaxIdle int `mapstructure:"replica_max_idle_conns"`
|
||||||
|
ReplicaMaxOpen int `mapstructure:"replica_max_open_conns"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SQLiteConfig SQLite 配置
|
// SQLiteConfig SQLite 配置
|
||||||
@@ -20,7 +27,7 @@ type SQLiteConfig struct {
|
|||||||
Path string `mapstructure:"path"`
|
Path string `mapstructure:"path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostgresConfig PostgreSQL 配置
|
// PostgresConfig PostgreSQL 配置(主库和副本共用)
|
||||||
type PostgresConfig struct {
|
type PostgresConfig struct {
|
||||||
Host string `mapstructure:"host"`
|
Host string `mapstructure:"host"`
|
||||||
Port int `mapstructure:"port"`
|
Port int `mapstructure:"port"`
|
||||||
@@ -37,3 +44,18 @@ func (d PostgresConfig) DSN() string {
|
|||||||
d.Host, d.Port, d.User, d.Password, d.DBName, d.SSLMode,
|
d.Host, d.Port, d.User, d.Password, d.DBName, d.SSLMode,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasReplica 返回是否配置了读副本(单副本或多副本)
|
||||||
|
func (d DatabaseConfig) HasReplica() bool {
|
||||||
|
return d.Replica.Host != "" || len(d.Replicas) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllReplicas 返回所有读副本配置(合并单副本 + 多副本)
|
||||||
|
func (d DatabaseConfig) AllReplicas() []PostgresConfig {
|
||||||
|
var result []PostgresConfig
|
||||||
|
if d.Replica.Host != "" {
|
||||||
|
result = append(result, d.Replica)
|
||||||
|
}
|
||||||
|
result = append(result, d.Replicas...)
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -1,8 +1,83 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
type JPushConfig struct {
|
type JPushConfig struct {
|
||||||
Enabled bool `mapstructure:"enabled"`
|
Enabled bool `mapstructure:"enabled"`
|
||||||
AppKey string `mapstructure:"app_key"`
|
AppKey string `mapstructure:"app_key"`
|
||||||
MasterSecret string `mapstructure:"master_secret"`
|
MasterSecret string `mapstructure:"master_secret"`
|
||||||
Production bool `mapstructure:"production"`
|
Production bool `mapstructure:"production"`
|
||||||
}
|
// Channel 厂商通道 channel_id 配置
|
||||||
|
// 通过 options.third_party_channel 下发到各厂商(小米/华为/OPPO 等)
|
||||||
|
Channel ChannelConfig `mapstructure:"channel"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelConfig 厂商通道 channel_id 配置
|
||||||
|
// 区分「系统消息」与「私聊/群聊消息」两类 channel
|
||||||
|
type ChannelConfig struct {
|
||||||
|
// System 系统消息/通知默认 channel_id,未单独配置 Vendor 时各厂商共用
|
||||||
|
System string `mapstructure:"system"`
|
||||||
|
// Chat 私聊/群聊消息默认 channel_id,未单独配置 Vendor 时各厂商共用
|
||||||
|
Chat string `mapstructure:"chat"`
|
||||||
|
// Vendor 各厂商 channel_id 覆盖配置(留空则回退到 System/Chat)
|
||||||
|
Vendor VendorChannel `mapstructure:"vendor"`
|
||||||
|
// iOS APNs 通知场景区分配置(按 thread-id 分组)
|
||||||
|
IOS IOSChannelConfig `mapstructure:"ios"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IOSChannelConfig iOS APNs 通知场景区分配置
|
||||||
|
// iOS 走 APNs 无 channel_id 概念,通过 thread-id 实现通知分组
|
||||||
|
type IOSChannelConfig struct {
|
||||||
|
// ChatThreadID 私聊/群聊消息通知分组 thread-id
|
||||||
|
ChatThreadID string `mapstructure:"chat_thread_id"`
|
||||||
|
// SystemThreadID 系统消息/通知分组 thread-id
|
||||||
|
SystemThreadID string `mapstructure:"system_thread_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VendorChannel 各厂商通道 channel_id 覆盖
|
||||||
|
// 每个厂商区分 System(系统消息)与 Chat(私聊消息)两个 channel_id
|
||||||
|
type VendorChannel struct {
|
||||||
|
Xiaomi VendorChannelID `mapstructure:"xiaomi"`
|
||||||
|
Huawei VendorChannelID `mapstructure:"huawei"`
|
||||||
|
OPPO VendorChannelID `mapstructure:"oppo"`
|
||||||
|
VIVO VendorChannelID `mapstructure:"vivo"`
|
||||||
|
Meizu VendorChannelID `mapstructure:"meizu"`
|
||||||
|
Honor VendorChannelID `mapstructure:"honor"`
|
||||||
|
FCM VendorChannelID `mapstructure:"fcm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VendorChannelID 单个厂商的两类 channel 配置
|
||||||
|
// 每个 channel 可携带厂商私有模板配置(如小米 mi_template_id、OPPO private_msg_template_id)
|
||||||
|
type VendorChannelID struct {
|
||||||
|
// channel_id
|
||||||
|
System string `mapstructure:"system"`
|
||||||
|
Chat string `mapstructure:"chat"`
|
||||||
|
|
||||||
|
// 小米消息模板(可选,配置后私信消息下发时携带 channel_id 及 mi_template_id)
|
||||||
|
// 见:小米关于消息模板推送新规的更新通知
|
||||||
|
MiSystemTemplateID string `mapstructure:"mi_system_template_id"` // 系统消息模板 id
|
||||||
|
MiChatTemplateID string `mapstructure:"mi_chat_template_id"` // 私聊消息模板 id
|
||||||
|
|
||||||
|
// OPPO 私信模板(可选,仅 OPPO 厂商,配置后下发私信时携带)
|
||||||
|
// 见:OPUSH 私信模版校验能力接入说明
|
||||||
|
OppoSystemPrivateTemplateID string `mapstructure:"oppo_system_private_template_id"` // 系统消息私信模板 id
|
||||||
|
OppoChatPrivateTemplateID string `mapstructure:"oppo_chat_private_template_id"` // 私聊消息私信模板 id
|
||||||
|
|
||||||
|
// OPPO 消息分类(可选,2024.11.20 新规)
|
||||||
|
// category 消息场景标识,使用 notify_level 时必传;notify_level 提醒等级
|
||||||
|
// 取值:category 如 IM/ACCOUNT;notify_level 1=通知栏, 2=通知栏+锁屏, 16=通知栏+锁屏+横幅+震动+铃声
|
||||||
|
OppoSystemCategory string `mapstructure:"oppo_system_category"` // 系统消息 category(如 ACCOUNT)
|
||||||
|
OppoChatCategory string `mapstructure:"oppo_chat_category"` // 私聊消息 category(如 IM)
|
||||||
|
OppoSystemNotifyLevel int `mapstructure:"oppo_system_notify_level"` // 系统消息提醒等级(如 2)
|
||||||
|
OppoChatNotifyLevel int `mapstructure:"oppo_chat_notify_level"` // 私聊消息提醒等级(如 16)
|
||||||
|
|
||||||
|
// 荣耀通知栏消息智能分类(可选,importance 字段)
|
||||||
|
// 取值:"NORMAL"=服务与通讯,"LOW"=资讯营销
|
||||||
|
// 注:classification 优先级更高,会覆盖 importance 设置的值
|
||||||
|
HonorSystemImportance string `mapstructure:"honor_system_importance"` // 系统消息 importance(如 LOW)
|
||||||
|
HonorChatImportance string `mapstructure:"honor_chat_importance"` // 私聊消息 importance(如 NORMAL)
|
||||||
|
|
||||||
|
// vivo 厂商消息场景标识(可选,category 字段)
|
||||||
|
// 用于标识消息类型,确定提醒方式;classification=1 时 category 必须为系统消息类
|
||||||
|
// 不携带 category 会默认按运营消息下发,受频控限制
|
||||||
|
VivoSystemCategory string `mapstructure:"vivo_system_category"` // 系统消息 category(如 ACCOUNT 账号动态)
|
||||||
|
VivoChatCategory string `mapstructure:"vivo_chat_category"` // 私聊消息 category(如 IM 即时通讯)
|
||||||
|
}
|
||||||
|
|||||||
82
internal/config/jpush_default_test.go
Normal file
82
internal/config/jpush_default_test.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestXiaomiDefaultValuesFromViper 验证:config.yaml 中小米字段留空时,
|
||||||
|
// viper.SetDefault 设置的默认值(153608/153609 + M10289/P10761)能正确填充到 Config 结构体。
|
||||||
|
// 这证明:不配置环境变量/yaml 值时,代码内置默认值生效。
|
||||||
|
func TestXiaomiDefaultValuesFromViper(t *testing.T) {
|
||||||
|
// 加载仓库内的 config.yaml(小米字段已留空,依赖代码默认值)
|
||||||
|
yamlPath := filepath.Join("..", "..", "configs", "config.yaml")
|
||||||
|
cfg, err := Load(yamlPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load config failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
x := cfg.JPush.Channel.Vendor.Xiaomi
|
||||||
|
if x.System != "153609" {
|
||||||
|
t.Errorf("xiaomi.system default = %q, want 153609", x.System)
|
||||||
|
}
|
||||||
|
if x.Chat != "153608" {
|
||||||
|
t.Errorf("xiaomi.chat default = %q, want 153608", x.Chat)
|
||||||
|
}
|
||||||
|
if x.MiSystemTemplateID != "P10761" {
|
||||||
|
t.Errorf("xiaomi.mi_system_template_id default = %q, want P10761", x.MiSystemTemplateID)
|
||||||
|
}
|
||||||
|
if x.MiChatTemplateID != "M10289" {
|
||||||
|
t.Errorf("xiaomi.mi_chat_template_id default = %q, want M10289", x.MiChatTemplateID)
|
||||||
|
}
|
||||||
|
t.Logf("小米默认值生效: system=%s chat=%s mi_sys=%s mi_chat=%s",
|
||||||
|
x.System, x.Chat, x.MiSystemTemplateID, x.MiChatTemplateID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVendorDefaultValuesFromViper 验证:OPPO/vivo/荣耀的分类字段在 yaml 留空时,
|
||||||
|
// viper.SetDefault 设置的默认值能正确填充到 Config 结构体。
|
||||||
|
// channel_id 类字段需厂商后台注册,不在默认值范围(留空)。
|
||||||
|
func TestVendorDefaultValuesFromViper(t *testing.T) {
|
||||||
|
yamlPath := filepath.Join("..", "..", "configs", "config.yaml")
|
||||||
|
cfg, err := Load(yamlPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load config failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OPPO: chat=IM/16, system=ACCOUNT/2
|
||||||
|
oppo := cfg.JPush.Channel.Vendor.OPPO
|
||||||
|
if oppo.OppoChatCategory != "IM" {
|
||||||
|
t.Errorf("oppo.oppo_chat_category default = %q, want IM", oppo.OppoChatCategory)
|
||||||
|
}
|
||||||
|
if oppo.OppoSystemCategory != "ACCOUNT" {
|
||||||
|
t.Errorf("oppo.oppo_system_category default = %q, want ACCOUNT", oppo.OppoSystemCategory)
|
||||||
|
}
|
||||||
|
if oppo.OppoChatNotifyLevel != 16 {
|
||||||
|
t.Errorf("oppo.oppo_chat_notify_level default = %d, want 16", oppo.OppoChatNotifyLevel)
|
||||||
|
}
|
||||||
|
if oppo.OppoSystemNotifyLevel != 2 {
|
||||||
|
t.Errorf("oppo.oppo_system_notify_level default = %d, want 2", oppo.OppoSystemNotifyLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// vivo: chat=IM, system=ACCOUNT
|
||||||
|
vivo := cfg.JPush.Channel.Vendor.VIVO
|
||||||
|
if vivo.VivoChatCategory != "IM" {
|
||||||
|
t.Errorf("vivo.vivo_chat_category default = %q, want IM", vivo.VivoChatCategory)
|
||||||
|
}
|
||||||
|
if vivo.VivoSystemCategory != "ACCOUNT" {
|
||||||
|
t.Errorf("vivo.vivo_system_category default = %q, want ACCOUNT", vivo.VivoSystemCategory)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 荣耀: chat=NORMAL, system=LOW
|
||||||
|
honor := cfg.JPush.Channel.Vendor.Honor
|
||||||
|
if honor.HonorChatImportance != "NORMAL" {
|
||||||
|
t.Errorf("honor.honor_chat_importance default = %q, want NORMAL", honor.HonorChatImportance)
|
||||||
|
}
|
||||||
|
if honor.HonorSystemImportance != "LOW" {
|
||||||
|
t.Errorf("honor.honor_system_importance default = %q, want LOW", honor.HonorSystemImportance)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("OPPO: chat=%s/%d system=%s/%d", oppo.OppoChatCategory, oppo.OppoChatNotifyLevel, oppo.OppoSystemCategory, oppo.OppoSystemNotifyLevel)
|
||||||
|
t.Logf("vivo: chat=%s system=%s", vivo.VivoChatCategory, vivo.VivoSystemCategory)
|
||||||
|
t.Logf("荣耀: chat=%s system=%s", honor.HonorChatImportance, honor.HonorSystemImportance)
|
||||||
|
}
|
||||||
11
internal/config/livekit.go
Normal file
11
internal/config/livekit.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
// LiveKitConfig LiveKit SFU 配置
|
||||||
|
type LiveKitConfig struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
URL string `mapstructure:"url"`
|
||||||
|
APIKey string `mapstructure:"api_key"`
|
||||||
|
APISecret string `mapstructure:"api_secret"`
|
||||||
|
TokenTTL int `mapstructure:"token_ttl"`
|
||||||
|
WebhookSecret string `mapstructure:"webhook_secret"`
|
||||||
|
}
|
||||||
13
internal/config/push_worker.go
Normal file
13
internal/config/push_worker.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
// PushWorkerConfig 推送 Worker 配置(Redis Stream 异步推送)
|
||||||
|
type PushWorkerConfig struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
Stream string `mapstructure:"stream"` // Redis Stream 名称,默认 "msg_push"
|
||||||
|
Group string `mapstructure:"group"` // Consumer Group 名称,默认 "push_worker"
|
||||||
|
BatchSize int `mapstructure:"batch_size"` // XREADGROUP 每次读取条数,默认 50
|
||||||
|
PollTimeoutMs int `mapstructure:"poll_timeout_ms"` // XREADGROUP BLOCK 超时 ms,默认 2000
|
||||||
|
MaxRetries int `mapstructure:"max_retries"` // 消息最大重试次数,默认 3
|
||||||
|
MaxStreamLen int64 `mapstructure:"max_stream_len"` // Stream MAXLEN,默认 100000
|
||||||
|
IdleTimeoutMs int64 `mapstructure:"idle_timeout_ms"` // XPENDING 空闲超时 ms,默认 30000
|
||||||
|
}
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// RedisConfig Redis 配置
|
// RedisConfig Redis 配置
|
||||||
@@ -62,19 +59,3 @@ type MiniredisConfig struct {
|
|||||||
Port int `mapstructure:"port"`
|
Port int `mapstructure:"port"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRedis 创建 Redis 客户端(真实 Redis)
|
|
||||||
func NewRedis(cfg *RedisConfig) (*redis.Client, error) {
|
|
||||||
client := redis.NewClient(&redis.Options{
|
|
||||||
Addr: cfg.Redis.Addr(),
|
|
||||||
Password: cfg.Redis.Password,
|
|
||||||
DB: cfg.Redis.DB,
|
|
||||||
PoolSize: cfg.PoolSize,
|
|
||||||
})
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
if err := client.Ping(ctx).Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to connect to redis: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return client, nil
|
|
||||||
}
|
|
||||||
|
|||||||
10
internal/config/seq_buffer.go
Normal file
10
internal/config/seq_buffer.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
// SeqBufferConfig seq 预分配配置
|
||||||
|
type SeqBufferConfig struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
PrivateBufferSize int `mapstructure:"private_buffer_size"` // 私聊预分配步长(默认 50)
|
||||||
|
GroupBufferSize int `mapstructure:"group_buffer_size"` // 群聊预分配步长(默认 200)
|
||||||
|
LockTimeoutMs int `mapstructure:"lock_timeout_ms"` // 分布式锁超时毫秒(默认 5000)
|
||||||
|
MaxRetries int `mapstructure:"max_retries"` // 冷启动重试次数(默认 3)
|
||||||
|
}
|
||||||
@@ -61,3 +61,19 @@ type UploadConfig struct {
|
|||||||
MaxFileSize int64 `mapstructure:"max_file_size"`
|
MaxFileSize int64 `mapstructure:"max_file_size"`
|
||||||
AllowedTypes []string `mapstructure:"allowed_types"`
|
AllowedTypes []string `mapstructure:"allowed_types"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FileCleanupConfig 聊天文件 TTL 过期清理配置
|
||||||
|
type FileCleanupConfig struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"` // 是否启用清理 worker
|
||||||
|
RetentionDays int `mapstructure:"retention_days"` // 保留天数,默认 7
|
||||||
|
IntervalMinutes int `mapstructure:"interval_minutes"` // 扫描间隔(分钟),默认 360(6 小时)
|
||||||
|
BatchSize int `mapstructure:"batch_size"` // 单次扫描处理上限,默认 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceCleanupConfig 设备 registration_id 清理配置
|
||||||
|
// 周期性清理超过保留期未使用的设备 token,避免无效 registration_id 累积
|
||||||
|
type DeviceCleanupConfig struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"` // 是否启用清理 worker,默认 true
|
||||||
|
RetentionDays int `mapstructure:"retention_days"` // 保留天数,默认 3(3 天不活跃即清理)
|
||||||
|
IntervalMinutes int `mapstructure:"interval_minutes"` // 扫描间隔(分钟),默认 360(6 小时)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/minio/minio-go/v7"
|
|
||||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
|
||||||
)
|
|
||||||
|
|
||||||
// S3Config S3 存储配置
|
// S3Config S3 存储配置
|
||||||
type S3Config struct {
|
type S3Config struct {
|
||||||
Endpoint string `mapstructure:"endpoint"`
|
Endpoint string `mapstructure:"endpoint"`
|
||||||
@@ -19,30 +10,3 @@ type S3Config struct {
|
|||||||
Region string `mapstructure:"region"`
|
Region string `mapstructure:"region"`
|
||||||
Domain string `mapstructure:"domain"` // 自定义域名,如 s3.carrot.skin
|
Domain string `mapstructure:"domain"` // 自定义域名,如 s3.carrot.skin
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewS3 创建 S3 客户端
|
|
||||||
func NewS3(cfg *S3Config) (*minio.Client, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
client, err := minio.New(cfg.Endpoint, &minio.Options{
|
|
||||||
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
|
|
||||||
Secure: cfg.UseSSL,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create S3 client: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
exists, err := client.BucketExists(ctx, cfg.Bucket)
|
|
||||||
if err != nil {
|
|
||||||
// Access Denied 或网络不通时,跳过 bucket 检查,假定 bucket 已存在
|
|
||||||
} else if !exists {
|
|
||||||
if err := client.MakeBucket(ctx, cfg.Bucket, minio.MakeBucketOptions{
|
|
||||||
Region: cfg.Region,
|
|
||||||
}); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create bucket: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return client, nil
|
|
||||||
}
|
|
||||||
|
|||||||
8
internal/config/version_log.go
Normal file
8
internal/config/version_log.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
// VersionLogConfig 版本日志增量同步配置
|
||||||
|
type VersionLogConfig struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
SyncLimit int `mapstructure:"sync_limit"` // 单次同步最大返回条数
|
||||||
|
MaxSyncGap int64 `mapstructure:"max_sync_gap"` // 超过此版本差距建议全量同步
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
// WebRTCConfig WebRTC ICE 服务器配置
|
|
||||||
type WebRTCConfig struct {
|
|
||||||
ICEServers []ICEServerConfig `mapstructure:"ice_servers"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ICEServerConfig ICE 服务器配置
|
|
||||||
type ICEServerConfig struct {
|
|
||||||
URLs []string `mapstructure:"urls"`
|
|
||||||
Username string `mapstructure:"username"`
|
|
||||||
Credential string `mapstructure:"credential"`
|
|
||||||
}
|
|
||||||
535
internal/database/database.go
Normal file
535
internal/database/database.go
Normal file
@@ -0,0 +1,535 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
222
internal/database/database_test.go
Normal file
222
internal/database/database_test.go
Normal 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_created(data_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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超级管理员 admin/** 通配(globMatch 中 ** 跨 '/')
|
||||||
|
var rule model.CasbinRule
|
||||||
|
if err := db.Where("ptype=? AND v0=? AND v1=?", "p", model.RoleSuperAdmin, "admin/**").First(&rule).Error; err != nil {
|
||||||
|
t.Errorf("expected superadmin admin/** 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 引用可用(供未来临时文件场景扩展)
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
package dto
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"with_you/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ==================== Call Request DTOs ====================
|
|
||||||
|
|
||||||
// StartCallRequest 发起通话请求
|
|
||||||
type StartCallRequest struct {
|
|
||||||
ConversationID string `json:"conversation_id" binding:"required"` // 会话ID
|
|
||||||
CallType model.CallType `json:"call_type" binding:"required"` // 通话类型: private, group
|
|
||||||
}
|
|
||||||
|
|
||||||
// AnswerCallRequest 接听通话请求
|
|
||||||
type AnswerCallRequest struct {
|
|
||||||
SDP string `json:"sdp" binding:"required"` // SDP Answer
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendSDPRequest 发送SDP请求
|
|
||||||
type SendSDPRequest struct {
|
|
||||||
SDP string `json:"sdp" binding:"required"` // SDP Offer/Answer
|
|
||||||
Type string `json:"type" binding:"required"` // offer 或 answer
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendICECandidateRequest 发送ICE候选请求
|
|
||||||
type SendICECandidateRequest struct {
|
|
||||||
Candidate string `json:"candidate" binding:"required"` // candidate 字符串
|
|
||||||
SDPMid string `json:"sdp_mid"` // SDP mid
|
|
||||||
SDPMLineIndex int16 `json:"sdp_mline_index"` // SDP m-line index
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Call Response DTOs ====================
|
|
||||||
|
|
||||||
// CallSessionResponse 通话会话响应
|
|
||||||
type CallSessionResponse struct {
|
|
||||||
ID string `json:"id"` // 通话ID
|
|
||||||
ConversationID string `json:"conversation_id"` // 会话ID
|
|
||||||
GroupID string `json:"group_id,omitempty"`
|
|
||||||
CallerID string `json:"caller_id"` // 发起者ID
|
|
||||||
CallType model.CallType `json:"call_type"` // 通话类型
|
|
||||||
Status model.CallStatus `json:"status"` // 通话状态
|
|
||||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
|
||||||
EndedAt *time.Time `json:"ended_at,omitempty"`
|
|
||||||
Duration int64 `json:"duration"` // 通话时长(秒)
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
Participants []CallParticipantResponse `json:"participants"` // 参与者列表
|
|
||||||
Caller *UserResponse `json:"caller,omitempty"` // 发起者信息
|
|
||||||
}
|
|
||||||
|
|
||||||
// CallParticipantResponse 通话参与者响应
|
|
||||||
type CallParticipantResponse struct {
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
Status model.ParticipantStatus `json:"status"`
|
|
||||||
JoinedAt *time.Time `json:"joined_at,omitempty"`
|
|
||||||
LeftAt *time.Time `json:"left_at,omitempty"`
|
|
||||||
User *UserResponse `json:"user,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Call SSE Event DTOs ====================
|
|
||||||
|
|
||||||
// CallInviteEvent 来电邀请事件
|
|
||||||
type CallInviteEvent struct {
|
|
||||||
CallID string `json:"call_id"`
|
|
||||||
ConversationID string `json:"conversation_id"`
|
|
||||||
GroupID string `json:"group_id,omitempty"`
|
|
||||||
CallerID string `json:"caller_id"`
|
|
||||||
CallType model.CallType `json:"call_type"`
|
|
||||||
Caller *UserResponse `json:"caller,omitempty"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CallAnswerEvent 接听响应事件
|
|
||||||
type CallAnswerEvent struct {
|
|
||||||
CallID string `json:"call_id"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
SDP string `json:"sdp"` // SDP Answer
|
|
||||||
}
|
|
||||||
|
|
||||||
// CallRejectEvent 拒绝通话事件
|
|
||||||
type CallRejectEvent struct {
|
|
||||||
CallID string `json:"call_id"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CallEndEvent 结束通话事件
|
|
||||||
type CallEndEvent struct {
|
|
||||||
CallID string `json:"call_id"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
Duration int64 `json:"duration"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CallICECandidateEvent ICE候选事件
|
|
||||||
type CallICECandidateEvent struct {
|
|
||||||
CallID string `json:"call_id"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
Candidate string `json:"candidate"`
|
|
||||||
SDPMid string `json:"sdp_mid"`
|
|
||||||
SDPMLineIndex int16 `json:"sdp_mline_index"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CallSDPOfferEvent SDP Offer事件
|
|
||||||
type CallSDPOfferEvent struct {
|
|
||||||
CallID string `json:"call_id"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
SDP string `json:"sdp"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CallSDPAnswerEvent SDP Answer事件
|
|
||||||
type CallSDPAnswerEvent struct {
|
|
||||||
CallID string `json:"call_id"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
SDP string `json:"sdp"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CallUserJoinedEvent 用户加入通话事件(群聊)
|
|
||||||
type CallUserJoinedEvent struct {
|
|
||||||
CallID string `json:"call_id"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
User *UserResponse `json:"user,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CallUserLeftEvent 用户离开通话事件(群聊)
|
|
||||||
type CallUserLeftEvent struct {
|
|
||||||
CallID string `json:"call_id"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== ICE Server Config ====================
|
|
||||||
|
|
||||||
// ICEServerConfig ICE服务器配置
|
|
||||||
type ICEServerConfig struct {
|
|
||||||
URLs []string `json:"urls"`
|
|
||||||
Username string `json:"username,omitempty"`
|
|
||||||
Credential string `json:"credential,omitempty"`
|
|
||||||
CredentialType string `json:"credential_type,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CallConfigResponse 通话配置响应
|
|
||||||
type CallConfigResponse struct {
|
|
||||||
ICEServers []ICEServerConfig `json:"ice_servers"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Converter Functions ====================
|
|
||||||
|
|
||||||
// ConvertCallSessionToResponse 转换通话会话为响应格式
|
|
||||||
func ConvertCallSessionToResponse(call *model.CallSession, caller *model.User, participants []*model.User) *CallSessionResponse {
|
|
||||||
resp := &CallSessionResponse{
|
|
||||||
ID: call.ID,
|
|
||||||
ConversationID: call.ConversationID,
|
|
||||||
CallerID: call.CallerID,
|
|
||||||
CallType: call.CallType,
|
|
||||||
Status: call.Status,
|
|
||||||
StartedAt: call.StartedAt,
|
|
||||||
EndedAt: call.EndedAt,
|
|
||||||
Duration: call.Duration,
|
|
||||||
CreatedAt: call.CreatedAt,
|
|
||||||
}
|
|
||||||
|
|
||||||
if call.GroupID != nil {
|
|
||||||
resp.GroupID = *call.GroupID
|
|
||||||
}
|
|
||||||
|
|
||||||
if caller != nil {
|
|
||||||
resp.Caller = ConvertUserToResponse(caller)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转换参与者
|
|
||||||
resp.Participants = make([]CallParticipantResponse, 0, len(call.Participants))
|
|
||||||
userMap := make(map[string]*model.User)
|
|
||||||
for _, u := range participants {
|
|
||||||
userMap[u.ID] = u
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, p := range call.Participants {
|
|
||||||
pr := CallParticipantResponse{
|
|
||||||
UserID: p.UserID,
|
|
||||||
Status: p.Status,
|
|
||||||
JoinedAt: p.JoinedAt,
|
|
||||||
LeftAt: p.LeftAt,
|
|
||||||
}
|
|
||||||
if u, ok := userMap[p.UserID]; ok {
|
|
||||||
pr.User = ConvertUserToResponse(u)
|
|
||||||
}
|
|
||||||
resp.Participants = append(resp.Participants, pr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
@@ -131,52 +131,6 @@ type AdminUpdateUserStatusRequest struct {
|
|||||||
Status string `json:"status" binding:"required,oneof=active banned"`
|
Status string `json:"status" binding:"required,oneof=active banned"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Post DTOs ====================
|
|
||||||
|
|
||||||
// PostChannelBrief 帖子所属频道(列表/详情卡片展示)
|
|
||||||
type PostChannelBrief struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PostImageResponse 帖子图片响应
|
|
||||||
type PostImageResponse struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
ThumbnailURL string `json:"thumbnail_url"`
|
|
||||||
PreviewURL string `json:"preview_url"` // 列表/网格预览图
|
|
||||||
PreviewURLLarge string `json:"preview_url_large"` // 详情页预览图
|
|
||||||
Width int `json:"width"`
|
|
||||||
Height int `json:"height"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PostResponse 帖子响应(列表用)
|
|
||||||
type PostResponse struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
ChannelID *string `json:"channel_id,omitempty"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
Segments model.MessageSegments `json:"segments,omitempty"`
|
|
||||||
Images []PostImageResponse `json:"images"`
|
|
||||||
Status string `json:"status,omitempty"`
|
|
||||||
LikesCount int `json:"likes_count"`
|
|
||||||
CommentsCount int `json:"comments_count"`
|
|
||||||
FavoritesCount int `json:"favorites_count"`
|
|
||||||
SharesCount int `json:"shares_count"`
|
|
||||||
ViewsCount int `json:"views_count"`
|
|
||||||
IsPinned bool `json:"is_pinned"`
|
|
||||||
IsLocked bool `json:"is_locked"`
|
|
||||||
IsVote bool `json:"is_vote"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
UpdatedAt string `json:"updated_at"`
|
|
||||||
ContentEditedAt string `json:"content_edited_at,omitempty"`
|
|
||||||
Author *UserResponse `json:"author"`
|
|
||||||
IsLiked bool `json:"is_liked"`
|
|
||||||
IsFavorited bool `json:"is_favorited"`
|
|
||||||
Channel *PostChannelBrief `json:"channel,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Comment DTOs ====================
|
// ==================== Comment DTOs ====================
|
||||||
|
|
||||||
// CommentImageResponse 评论图片响应
|
// CommentImageResponse 评论图片响应
|
||||||
@@ -224,16 +178,16 @@ type NotificationResponse struct {
|
|||||||
type SegmentType string
|
type SegmentType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
SegmentTypeText SegmentType = "text"
|
SegmentTypeText SegmentType = "text"
|
||||||
SegmentTypeImage SegmentType = "image"
|
SegmentTypeImage SegmentType = "image"
|
||||||
SegmentTypeVoice SegmentType = "voice"
|
SegmentTypeVoice SegmentType = "voice"
|
||||||
SegmentTypeVideo SegmentType = "video"
|
SegmentTypeVideo SegmentType = "video"
|
||||||
SegmentTypeFile SegmentType = "file"
|
SegmentTypeFile SegmentType = "file"
|
||||||
SegmentTypeAt SegmentType = "at"
|
SegmentTypeAt SegmentType = "at"
|
||||||
SegmentTypeReply SegmentType = "reply"
|
SegmentTypeReply SegmentType = "reply"
|
||||||
SegmentTypeFace SegmentType = "face"
|
SegmentTypeFace SegmentType = "face"
|
||||||
SegmentTypeLink SegmentType = "link"
|
SegmentTypeLink SegmentType = "link"
|
||||||
SegmentTypeVote SegmentType = "vote"
|
SegmentTypeVote SegmentType = "vote"
|
||||||
SegmentTypePostRef SegmentType = "post_ref"
|
SegmentTypePostRef SegmentType = "post_ref"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -428,8 +382,8 @@ type CreateConversationRequest struct {
|
|||||||
// SendMessageRequest 发送消息请求
|
// SendMessageRequest 发送消息请求
|
||||||
type SendMessageRequest struct {
|
type SendMessageRequest struct {
|
||||||
Segments model.MessageSegments `json:"segments" binding:"required"` // 消息链(必须)
|
Segments model.MessageSegments `json:"segments" binding:"required"` // 消息链(必须)
|
||||||
ReplyToID *string `json:"reply_to_id,omitempty"` // 回复的消息ID (string类型)
|
ReplyToID *string `json:"reply_to_id,omitempty"` // 回复的消息ID (string类型)
|
||||||
ClientMsgID string `json:"client_msg_id,omitempty"` // 客户端消息ID(幂等性去重)
|
ClientMsgID string `json:"client_msg_id,omitempty"` // 客户端消息ID(幂等性去重)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkReadRequest 标记已读请求
|
// MarkReadRequest 标记已读请求
|
||||||
@@ -545,6 +499,22 @@ type DeviceTokenResponse struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminDeviceTokenResponse 管理端设备Token响应
|
||||||
|
// 相比 DeviceTokenResponse,额外包含 user_id 与 push_token(即 JPush RegistrationID),
|
||||||
|
// 供后台查询用户设备 registration_id 使用。
|
||||||
|
type AdminDeviceTokenResponse struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
DeviceType string `json:"device_type"`
|
||||||
|
PushToken string `json:"push_token,omitempty"` // JPush RegistrationID
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
DeviceName string `json:"device_name,omitempty"`
|
||||||
|
LastUsedAt time.Time `json:"last_used_at,omitzero"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 推送记录 DTOs ====================
|
// ==================== 推送记录 DTOs ====================
|
||||||
|
|
||||||
// PushRecordResponse 推送记录响应
|
// PushRecordResponse 推送记录响应
|
||||||
@@ -619,122 +589,6 @@ func FormatTimePointer(t *time.Time) string {
|
|||||||
return FormatTime(*t)
|
return FormatTime(*t)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Group DTOs ====================
|
|
||||||
|
|
||||||
// CreateGroupRequest 创建群组请求
|
|
||||||
type CreateGroupRequest struct {
|
|
||||||
Name string `json:"name" binding:"required,max=50"`
|
|
||||||
Description string `json:"description" binding:"max=500"`
|
|
||||||
MemberIDs []string `json:"member_ids"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateGroupRequest 更新群组请求
|
|
||||||
type UpdateGroupRequest struct {
|
|
||||||
Name string `json:"name" binding:"omitempty,max=50"`
|
|
||||||
Description string `json:"description" binding:"omitempty,max=500"`
|
|
||||||
Avatar string `json:"avatar" binding:"omitempty,url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// InviteMembersRequest 邀请成员请求
|
|
||||||
type InviteMembersRequest struct {
|
|
||||||
MemberIDs []string `json:"member_ids" binding:"required,min=1"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TransferOwnerRequest 转让群主请求
|
|
||||||
type TransferOwnerRequest struct {
|
|
||||||
NewOwnerID string `json:"new_owner_id" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetRoleRequest 设置角色请求
|
|
||||||
type SetRoleRequest struct {
|
|
||||||
Role string `json:"role" binding:"required,oneof=admin member"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetNicknameRequest 设置昵称请求
|
|
||||||
type SetNicknameRequest struct {
|
|
||||||
Nickname string `json:"nickname" binding:"max=50"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// MuteMemberRequest 禁言成员请求
|
|
||||||
type MuteMemberRequest struct {
|
|
||||||
Muted bool `json:"muted"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetMuteAllRequest 设置全员禁言请求
|
|
||||||
type SetMuteAllRequest struct {
|
|
||||||
MuteAll bool `json:"mute_all"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetJoinTypeRequest 设置加群方式请求
|
|
||||||
type SetJoinTypeRequest struct {
|
|
||||||
JoinType int `json:"join_type" binding:"min=0,max=2"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateAnnouncementRequest 创建群公告请求
|
|
||||||
type CreateAnnouncementRequest struct {
|
|
||||||
Content string `json:"content" binding:"required,max=2000"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GroupResponse 群组响应
|
|
||||||
type GroupResponse struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Avatar string `json:"avatar"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
OwnerID string `json:"owner_id"`
|
|
||||||
MemberCount int `json:"member_count"`
|
|
||||||
MaxMembers int `json:"max_members"`
|
|
||||||
JoinType int `json:"join_type"`
|
|
||||||
MuteAll bool `json:"mute_all"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GroupMemberResponse 群成员响应
|
|
||||||
type GroupMemberResponse struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
GroupID string `json:"group_id"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
Nickname string `json:"nickname"`
|
|
||||||
Muted bool `json:"muted"`
|
|
||||||
JoinTime string `json:"join_time"`
|
|
||||||
User *UserResponse `json:"user,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GroupAnnouncementResponse 群公告响应
|
|
||||||
type GroupAnnouncementResponse struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
GroupID string `json:"group_id"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
AuthorID string `json:"author_id"`
|
|
||||||
IsPinned bool `json:"is_pinned"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GroupListResponse 群组列表响应
|
|
||||||
type GroupListResponse struct {
|
|
||||||
List []*GroupResponse `json:"list"`
|
|
||||||
Total int64 `json:"total"`
|
|
||||||
Page int `json:"page"`
|
|
||||||
PageSize int `json:"page_size"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GroupMemberListResponse 群成员列表响应
|
|
||||||
type GroupMemberListResponse struct {
|
|
||||||
List []*GroupMemberResponse `json:"list"`
|
|
||||||
Total int64 `json:"total"`
|
|
||||||
Page int `json:"page"`
|
|
||||||
PageSize int `json:"page_size"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GroupAnnouncementListResponse 群公告列表响应
|
|
||||||
type GroupAnnouncementListResponse struct {
|
|
||||||
List []*GroupAnnouncementResponse `json:"list"`
|
|
||||||
Total int64 `json:"total"`
|
|
||||||
Page int `json:"page"`
|
|
||||||
PageSize int `json:"page_size"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== WebSocket Event DTOs ====================
|
// ==================== WebSocket Event DTOs ====================
|
||||||
|
|
||||||
// WSEventResponse WebSocket事件响应结构体
|
// WSEventResponse WebSocket事件响应结构体
|
||||||
@@ -745,7 +599,7 @@ type WSEventResponse struct {
|
|||||||
Type string `json:"type"` // 事件类型 (message, notification, system等)
|
Type string `json:"type"` // 事件类型 (message, notification, system等)
|
||||||
DetailType string `json:"detail_type"` // 详细类型 (private, group, like, comment等)
|
DetailType string `json:"detail_type"` // 详细类型 (private, group, like, comment等)
|
||||||
ConversationID string `json:"conversation_id"` // 会话ID
|
ConversationID string `json:"conversation_id"` // 会话ID
|
||||||
Seq string `json:"seq"` // 消息序列号
|
Seq int64 `json:"seq"` // 消息序列号
|
||||||
Segments model.MessageSegments `json:"segments"` // 消息段数组
|
Segments model.MessageSegments `json:"segments"` // 消息段数组
|
||||||
SenderID string `json:"sender_id"` // 发送者用户ID
|
SenderID string `json:"sender_id"` // 发送者用户ID
|
||||||
}
|
}
|
||||||
@@ -754,11 +608,11 @@ type WSEventResponse struct {
|
|||||||
|
|
||||||
// SendMessageParams 发送消息参数(用于 REST API)
|
// SendMessageParams 发送消息参数(用于 REST API)
|
||||||
type SendMessageParams struct {
|
type SendMessageParams struct {
|
||||||
DetailType string `json:"detail_type"` // 消息类型: private, group
|
DetailType string `json:"detail_type"` // 消息类型: private, group
|
||||||
ConversationID string `json:"conversation_id"` // 会话ID
|
ConversationID string `json:"conversation_id"` // 会话ID
|
||||||
Segments model.MessageSegments `json:"segments"` // 消息内容(消息段数组)
|
Segments model.MessageSegments `json:"segments"` // 消息内容(消息段数组)
|
||||||
ReplyToID *string `json:"reply_to_id,omitempty"` // 回复的消息ID
|
ReplyToID *string `json:"reply_to_id,omitempty"` // 回复的消息ID
|
||||||
ClientMsgID string `json:"client_msg_id,omitempty"` // 客户端消息ID(幂等性去重)
|
ClientMsgID string `json:"client_msg_id,omitempty"` // 客户端消息ID(幂等性去重)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteMsgParams 撤回消息参数
|
// DeleteMsgParams 撤回消息参数
|
||||||
@@ -809,7 +663,7 @@ type SetGroupAvatarParams struct {
|
|||||||
|
|
||||||
// SetGroupDescriptionParams 设置群描述参数
|
// SetGroupDescriptionParams 设置群描述参数
|
||||||
type SetGroupDescriptionParams struct {
|
type SetGroupDescriptionParams struct {
|
||||||
GroupID string `json:"group_id"` // 群组ID
|
GroupID string `json:"group_id"` // 群组ID
|
||||||
Description string `json:"description"` // 群描述
|
Description string `json:"description"` // 群描述
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -935,41 +789,6 @@ type GetMyMemberInfoParams struct {
|
|||||||
GroupID string `json:"group_id"` // 群组ID
|
GroupID string `json:"group_id"` // 群组ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Vote DTOs ====================
|
|
||||||
|
|
||||||
// CreateVotePostRequest 创建投票帖子请求
|
|
||||||
type CreateVotePostRequest struct {
|
|
||||||
Title string `json:"title" binding:"required,max=200"`
|
|
||||||
Content string `json:"content" binding:"max=2000"`
|
|
||||||
ChannelID *string `json:"channel_id,omitempty"`
|
|
||||||
Images []string `json:"images"`
|
|
||||||
VoteOptions []string `json:"vote_options" binding:"required,min=2,max=10"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreatePostWithSegmentsRequest 创建帖子请求(含 segments)
|
|
||||||
type CreatePostWithSegmentsRequest struct {
|
|
||||||
Title string `json:"title" binding:"required,max=200"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
Segments model.MessageSegments `json:"segments"`
|
|
||||||
Images []string `json:"images"`
|
|
||||||
ChannelID *string `json:"channel_id,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// VoteOptionDTO 投票选项DTO
|
|
||||||
type VoteOptionDTO struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
VotesCount int `json:"votes_count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// VoteResultDTO 投票结果DTO
|
|
||||||
type VoteResultDTO struct {
|
|
||||||
Options []VoteOptionDTO `json:"options"`
|
|
||||||
TotalVotes int `json:"total_votes"`
|
|
||||||
HasVoted bool `json:"has_voted"`
|
|
||||||
VotedOptionID string `json:"voted_option_id,omitzero"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== WebSocket Response DTOs ====================
|
// ==================== WebSocket Response DTOs ====================
|
||||||
|
|
||||||
// WSResponse WebSocket响应结构体
|
// WSResponse WebSocket响应结构体
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
package dto
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// LogFilter 日志过滤条件
|
|
||||||
type LogFilter struct {
|
|
||||||
UserID string
|
|
||||||
Operation string
|
|
||||||
TargetType string
|
|
||||||
TargetID string
|
|
||||||
Status string
|
|
||||||
IP string
|
|
||||||
StartTime string
|
|
||||||
EndTime string
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoginFilter 登录日志过滤条件
|
|
||||||
type LoginFilter struct {
|
|
||||||
UserID string
|
|
||||||
Event string
|
|
||||||
Result string
|
|
||||||
FailReason string
|
|
||||||
LoginType string
|
|
||||||
IP string
|
|
||||||
StartTime time.Time
|
|
||||||
EndTime time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// DataChangeFilter 数据变更日志过滤条件
|
|
||||||
type DataChangeFilter struct {
|
|
||||||
UserID string
|
|
||||||
OperatorID string
|
|
||||||
ChangeType string
|
|
||||||
TargetType string
|
|
||||||
OperatorType string
|
|
||||||
IP string
|
|
||||||
StartTime string
|
|
||||||
EndTime string
|
|
||||||
}
|
|
||||||
|
|
||||||
// MaterialFileQueryParams 学习资料查询参数
|
|
||||||
type MaterialFileQueryParams struct {
|
|
||||||
SubjectID string
|
|
||||||
FileType string
|
|
||||||
Status string
|
|
||||||
Keyword string
|
|
||||||
Page int
|
|
||||||
PageSize int
|
|
||||||
SortBy string
|
|
||||||
SortOrder string
|
|
||||||
}
|
|
||||||
@@ -50,26 +50,6 @@ func GroupMemberToResponse(member *model.GroupMember) *GroupMemberResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupMemberToResponseWithUser 将GroupMember转换为GroupMemberResponse(包含用户信息)
|
|
||||||
func GroupMemberToResponseWithUser(member *model.GroupMember, user *model.User) *GroupMemberResponse {
|
|
||||||
if member == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
resp := GroupMemberToResponse(member)
|
|
||||||
if user != nil {
|
|
||||||
resp.User = ConvertUserToResponse(user)
|
|
||||||
}
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
// GroupMembersToResponse 将GroupMember列表转换为GroupMemberResponse列表
|
|
||||||
func GroupMembersToResponse(members []model.GroupMember) []*GroupMemberResponse {
|
|
||||||
result := make([]*GroupMemberResponse, 0, len(members))
|
|
||||||
for i := range members {
|
|
||||||
result = append(result, GroupMemberToResponse(&members[i]))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// GroupAnnouncementToResponse 将GroupAnnouncement转换为GroupAnnouncementResponse
|
// GroupAnnouncementToResponse 将GroupAnnouncement转换为GroupAnnouncementResponse
|
||||||
func GroupAnnouncementToResponse(announcement *model.GroupAnnouncement) *GroupAnnouncementResponse {
|
func GroupAnnouncementToResponse(announcement *model.GroupAnnouncement) *GroupAnnouncementResponse {
|
||||||
|
|||||||
120
internal/dto/group_dto.go
Normal file
120
internal/dto/group_dto.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// ==================== Group DTOs ====================
|
||||||
|
// 从 dto.go 拆分而来:群组相关的请求与响应 DTO。
|
||||||
|
// 与 group_converter.go 同属群组域。
|
||||||
|
|
||||||
|
// CreateGroupRequest 创建群组请求
|
||||||
|
type CreateGroupRequest struct {
|
||||||
|
Name string `json:"name" binding:"required,max=50"`
|
||||||
|
Description string `json:"description" binding:"max=500"`
|
||||||
|
MemberIDs []string `json:"member_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateGroupRequest 更新群组请求
|
||||||
|
type UpdateGroupRequest struct {
|
||||||
|
Name string `json:"name" binding:"omitempty,max=50"`
|
||||||
|
Description string `json:"description" binding:"omitempty,max=500"`
|
||||||
|
Avatar string `json:"avatar" binding:"omitempty,url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InviteMembersRequest 邀请成员请求
|
||||||
|
type InviteMembersRequest struct {
|
||||||
|
MemberIDs []string `json:"member_ids" binding:"required,min=1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransferOwnerRequest 转让群主请求
|
||||||
|
type TransferOwnerRequest struct {
|
||||||
|
NewOwnerID string `json:"new_owner_id" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRoleRequest 设置角色请求
|
||||||
|
type SetRoleRequest struct {
|
||||||
|
Role string `json:"role" binding:"required,oneof=admin member"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetNicknameRequest 设置昵称请求
|
||||||
|
type SetNicknameRequest struct {
|
||||||
|
Nickname string `json:"nickname" binding:"max=50"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MuteMemberRequest 禁言成员请求
|
||||||
|
type MuteMemberRequest struct {
|
||||||
|
Muted bool `json:"muted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMuteAllRequest 设置全员禁言请求
|
||||||
|
type SetMuteAllRequest struct {
|
||||||
|
MuteAll bool `json:"mute_all"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetJoinTypeRequest 设置加群方式请求
|
||||||
|
type SetJoinTypeRequest struct {
|
||||||
|
JoinType int `json:"join_type" binding:"min=0,max=2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAnnouncementRequest 创建群公告请求
|
||||||
|
type CreateAnnouncementRequest struct {
|
||||||
|
Content string `json:"content" binding:"required,max=2000"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupResponse 群组响应
|
||||||
|
type GroupResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
OwnerID string `json:"owner_id"`
|
||||||
|
MemberCount int `json:"member_count"`
|
||||||
|
MaxMembers int `json:"max_members"`
|
||||||
|
JoinType int `json:"join_type"`
|
||||||
|
MuteAll bool `json:"mute_all"`
|
||||||
|
IsMember bool `json:"is_member"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupMemberResponse 群成员响应
|
||||||
|
type GroupMemberResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
GroupID string `json:"group_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Muted bool `json:"muted"`
|
||||||
|
JoinTime string `json:"join_time"`
|
||||||
|
User *UserResponse `json:"user,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupAnnouncementResponse 群公告响应
|
||||||
|
type GroupAnnouncementResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
GroupID string `json:"group_id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
AuthorID string `json:"author_id"`
|
||||||
|
IsPinned bool `json:"is_pinned"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupListResponse 群组列表响应
|
||||||
|
type GroupListResponse struct {
|
||||||
|
List []*GroupResponse `json:"list"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupMemberListResponse 群成员列表响应
|
||||||
|
type GroupMemberListResponse struct {
|
||||||
|
List []*GroupMemberResponse `json:"list"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupAnnouncementListResponse 群公告列表响应
|
||||||
|
type GroupAnnouncementListResponse struct {
|
||||||
|
List []*GroupAnnouncementResponse `json:"list"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
}
|
||||||
@@ -8,6 +8,13 @@ import (
|
|||||||
|
|
||||||
// ConvertMessageToResponse 将Message转换为MessageResponse
|
// ConvertMessageToResponse 将Message转换为MessageResponse
|
||||||
func ConvertMessageToResponse(message *model.Message) *MessageResponse {
|
func ConvertMessageToResponse(message *model.Message) *MessageResponse {
|
||||||
|
return ConvertMessageToResponseWithExpiry(message, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertMessageToResponseWithExpiry 将Message转换为MessageResponse,并对已过期的 file segment 注入 expired 标记。
|
||||||
|
// expiredURLSet 为已从 S3 清理的文件 URL 集合(可为 nil,表示不标记)。
|
||||||
|
// 注意:注入时对 file segment 的 Data 做深拷贝,避免污染消息缓存。
|
||||||
|
func ConvertMessageToResponseWithExpiry(message *model.Message, expiredURLSet map[string]struct{}) *MessageResponse {
|
||||||
if message == nil {
|
if message == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -15,6 +22,19 @@ func ConvertMessageToResponse(message *model.Message) *MessageResponse {
|
|||||||
// 直接使用 segments,不需要解析
|
// 直接使用 segments,不需要解析
|
||||||
segments := make(model.MessageSegments, len(message.Segments))
|
segments := make(model.MessageSegments, len(message.Segments))
|
||||||
for i, seg := range message.Segments {
|
for i, seg := range message.Segments {
|
||||||
|
// 对 file 类型且 URL 已过期的 segment,深拷贝 Data 并注入 expired 标记
|
||||||
|
if seg.Type == string(model.ContentTypeFile) && len(expiredURLSet) > 0 {
|
||||||
|
url, _ := seg.Data["url"].(string)
|
||||||
|
if _, expired := expiredURLSet[url]; expired {
|
||||||
|
newData := make(map[string]any, len(seg.Data)+1)
|
||||||
|
for k, v := range seg.Data {
|
||||||
|
newData[k] = v
|
||||||
|
}
|
||||||
|
newData["expired"] = true
|
||||||
|
segments[i] = model.MessageSegment{Type: seg.Type, Data: newData}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
segments[i] = model.MessageSegment{
|
segments[i] = model.MessageSegment{
|
||||||
Type: seg.Type,
|
Type: seg.Type,
|
||||||
Data: seg.Data,
|
Data: seg.Data,
|
||||||
@@ -107,15 +127,16 @@ func ConvertMessagesToResponse(messages []*model.Message) []*MessageResponse {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertConversationsToResponse 将Conversation列表转换为响应列表
|
// ConvertMessagesToResponseWithExpiry 将Message列表转换为响应列表,并标记过期文件
|
||||||
func ConvertConversationsToResponse(convs []*model.Conversation) []*ConversationResponse {
|
func ConvertMessagesToResponseWithExpiry(messages []*model.Message, expiredURLSet map[string]struct{}) []*MessageResponse {
|
||||||
result := make([]*ConversationResponse, 0, len(convs))
|
result := make([]*MessageResponse, 0, len(messages))
|
||||||
for _, conv := range convs {
|
for _, msg := range messages {
|
||||||
result = append(result, ConvertConversationToResponse(conv, nil, 0, nil, false, false))
|
result = append(result, ConvertMessageToResponseWithExpiry(msg, expiredURLSet))
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ==================== PushRecord 转换 ====================
|
// ==================== PushRecord 转换 ====================
|
||||||
|
|
||||||
// PushRecordToResponse 将PushRecord转换为PushRecordResponse
|
// PushRecordToResponse 将PushRecord转换为PushRecordResponse
|
||||||
@@ -178,3 +199,34 @@ func DeviceTokensToResponse(tokens []*model.DeviceToken) []*DeviceTokenResponse
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeviceTokenToAdminResponse 将DeviceToken转换为管理端响应(含 registration_id)
|
||||||
|
func DeviceTokenToAdminResponse(token *model.DeviceToken) *AdminDeviceTokenResponse {
|
||||||
|
if token == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
resp := &AdminDeviceTokenResponse{
|
||||||
|
ID: token.ID,
|
||||||
|
UserID: token.UserID,
|
||||||
|
DeviceID: token.DeviceID,
|
||||||
|
DeviceType: string(token.DeviceType),
|
||||||
|
PushToken: token.PushToken,
|
||||||
|
IsActive: token.IsActive,
|
||||||
|
DeviceName: token.DeviceName,
|
||||||
|
CreatedAt: token.CreatedAt,
|
||||||
|
UpdatedAt: token.UpdatedAt,
|
||||||
|
}
|
||||||
|
if token.LastUsedAt != nil {
|
||||||
|
resp.LastUsedAt = *token.LastUsedAt
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceTokensToAdminResponse 将DeviceToken列表转换为管理端响应列表
|
||||||
|
func DeviceTokensToAdminResponse(tokens []*model.DeviceToken) []*AdminDeviceTokenResponse {
|
||||||
|
result := make([]*AdminDeviceTokenResponse, 0, len(tokens))
|
||||||
|
for _, token := range tokens {
|
||||||
|
result = append(result, DeviceTokenToAdminResponse(token))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,49 +33,6 @@ func ConvertNotificationsToResponse(notifications []*model.Notification) []*Noti
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== SystemMessage 转换 ====================
|
|
||||||
|
|
||||||
// SystemMessageToResponse 将Message转换为SystemMessageResponse
|
|
||||||
func SystemMessageToResponse(msg *model.Message) *SystemMessageResponse {
|
|
||||||
if msg == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从 segments 中提取文本内容
|
|
||||||
content := ExtractTextContentFromModel(msg.Segments)
|
|
||||||
|
|
||||||
resp := &SystemMessageResponse{
|
|
||||||
ID: msg.ID,
|
|
||||||
SenderID: msg.SenderID,
|
|
||||||
ReceiverID: "", // 系统消息的接收者需要从上下文获取
|
|
||||||
Content: content,
|
|
||||||
Category: string(msg.Category),
|
|
||||||
SystemType: string(msg.SystemType),
|
|
||||||
CreatedAt: msg.CreatedAt,
|
|
||||||
}
|
|
||||||
if msg.ExtraData != nil {
|
|
||||||
resp.ExtraData = map[string]any{
|
|
||||||
"actor_id": msg.ExtraData.ActorID,
|
|
||||||
"actor_name": msg.ExtraData.ActorName,
|
|
||||||
"avatar_url": msg.ExtraData.AvatarURL,
|
|
||||||
"target_id": msg.ExtraData.TargetID,
|
|
||||||
"target_type": msg.ExtraData.TargetType,
|
|
||||||
"action_url": msg.ExtraData.ActionURL,
|
|
||||||
"action_time": msg.ExtraData.ActionTime,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
// SystemMessagesToResponse 将Message列表转换为SystemMessageResponse列表
|
|
||||||
func SystemMessagesToResponse(messages []*model.Message) []*SystemMessageResponse {
|
|
||||||
result := make([]*SystemMessageResponse, 0, len(messages))
|
|
||||||
for _, msg := range messages {
|
|
||||||
result = append(result, SystemMessageToResponse(msg))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// SystemNotificationToResponse 将SystemNotification转换为SystemMessageResponse
|
// SystemNotificationToResponse 将SystemNotification转换为SystemMessageResponse
|
||||||
func SystemNotificationToResponse(n *model.SystemNotification) *SystemMessageResponse {
|
func SystemNotificationToResponse(n *model.SystemNotification) *SystemMessageResponse {
|
||||||
if n == nil {
|
if n == nil {
|
||||||
@@ -118,11 +75,3 @@ func SystemNotificationToResponse(n *model.SystemNotification) *SystemMessageRes
|
|||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
// SystemNotificationsToResponse 将SystemNotification列表转换为SystemMessageResponse列表
|
|
||||||
func SystemNotificationsToResponse(notifications []*model.SystemNotification) []*SystemMessageResponse {
|
|
||||||
result := make([]*SystemMessageResponse, 0, len(notifications))
|
|
||||||
for _, n := range notifications {
|
|
||||||
result = append(result, SystemNotificationToResponse(n))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -212,14 +212,6 @@ func ConvertCommentToResponse(comment *model.Comment, isLiked bool) *CommentResp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertCommentsToResponse 将Comment列表转换为响应列表
|
|
||||||
func ConvertCommentsToResponse(comments []*model.Comment, isLiked bool) []*CommentResponse {
|
|
||||||
result := make([]*CommentResponse, 0, len(comments))
|
|
||||||
for _, comment := range comments {
|
|
||||||
result = append(result, ConvertCommentToResponse(comment, isLiked))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsLikedChecker 点赞状态检查器接口
|
// IsLikedChecker 点赞状态检查器接口
|
||||||
type IsLikedChecker interface {
|
type IsLikedChecker interface {
|
||||||
|
|||||||
92
internal/dto/post_dto.go
Normal file
92
internal/dto/post_dto.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import "with_you/internal/model"
|
||||||
|
|
||||||
|
// ==================== Post DTOs ====================
|
||||||
|
// 从 dto.go 拆分而来:帖子与投票相关的请求/响应 DTO。
|
||||||
|
// 与 post_converter.go 同属帖子域。
|
||||||
|
|
||||||
|
// PostChannelBrief 帖子所属频道(列表/详情卡片展示)
|
||||||
|
type PostChannelBrief struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostImageResponse 帖子图片响应
|
||||||
|
type PostImageResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
ThumbnailURL string `json:"thumbnail_url"`
|
||||||
|
PreviewURL string `json:"preview_url"` // 列表/网格预览图
|
||||||
|
PreviewURLLarge string `json:"preview_url_large"` // 详情页预览图
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostResponse 帖子响应(列表用)
|
||||||
|
type PostResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
ChannelID *string `json:"channel_id,omitempty"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Segments model.MessageSegments `json:"segments,omitempty"`
|
||||||
|
Images []PostImageResponse `json:"images"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
LikesCount int `json:"likes_count"`
|
||||||
|
CommentsCount int `json:"comments_count"`
|
||||||
|
FavoritesCount int `json:"favorites_count"`
|
||||||
|
SharesCount int `json:"shares_count"`
|
||||||
|
ViewsCount int `json:"views_count"`
|
||||||
|
IsPinned bool `json:"is_pinned"`
|
||||||
|
IsLocked bool `json:"is_locked"`
|
||||||
|
IsVote bool `json:"is_vote"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
ContentEditedAt string `json:"content_edited_at,omitempty"`
|
||||||
|
Author *UserResponse `json:"author"`
|
||||||
|
IsLiked bool `json:"is_liked"`
|
||||||
|
IsFavorited bool `json:"is_favorited"`
|
||||||
|
Channel *PostChannelBrief `json:"channel,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Vote DTOs ====================
|
||||||
|
|
||||||
|
// CreateVotePostRequest 创建投票帖子请求
|
||||||
|
type CreateVotePostRequest struct {
|
||||||
|
Title string `json:"title" binding:"required,max=200"`
|
||||||
|
Content string `json:"content" binding:"max=2000"`
|
||||||
|
ChannelID *string `json:"channel_id,omitempty"`
|
||||||
|
Images []string `json:"images"`
|
||||||
|
VoteOptions []string `json:"vote_options" binding:"required,min=2,max=10"`
|
||||||
|
// ClientRequestID 客户端为本次发帖生成的幂等键(UUID)。
|
||||||
|
// 同一 userID + ClientRequestID 在 TTL 内重复提交时,后端直接返回首次创建的帖子,避免重复发帖。
|
||||||
|
ClientRequestID string `json:"client_request_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePostWithSegmentsRequest 创建帖子请求(含 segments)
|
||||||
|
type CreatePostWithSegmentsRequest struct {
|
||||||
|
Title string `json:"title" binding:"required,max=200"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Segments model.MessageSegments `json:"segments"`
|
||||||
|
Images []string `json:"images"`
|
||||||
|
ChannelID *string `json:"channel_id,omitempty"`
|
||||||
|
// ClientRequestID 客户端为本次发帖生成的幂等键(UUID)。
|
||||||
|
// 同一 userID + ClientRequestID 在 TTL 内重复提交时,后端直接返回首次创建的帖子,避免重复发帖。
|
||||||
|
ClientRequestID string `json:"client_request_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VoteOptionDTO 投票选项DTO
|
||||||
|
type VoteOptionDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
VotesCount int `json:"votes_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VoteResultDTO 投票结果DTO
|
||||||
|
type VoteResultDTO struct {
|
||||||
|
Options []VoteOptionDTO `json:"options"`
|
||||||
|
TotalVotes int `json:"total_votes"`
|
||||||
|
HasVoted bool `json:"has_voted"`
|
||||||
|
VotedOptionID string `json:"voted_option_id,omitzero"`
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
@@ -8,10 +8,10 @@ import (
|
|||||||
|
|
||||||
// CreateReportRequest 创建举报请求
|
// CreateReportRequest 创建举报请求
|
||||||
type CreateReportRequest struct {
|
type CreateReportRequest struct {
|
||||||
TargetType string `json:"target_type" binding:"required,oneof=post comment message"`
|
TargetType string `json:"target_type" binding:"required,oneof=post comment message"`
|
||||||
TargetID string `json:"target_id" binding:"required"`
|
TargetID string `json:"target_id" binding:"required"`
|
||||||
Reason string `json:"reason" binding:"required,oneof=spam inappropriate harassment misinformation other"`
|
Reason string `json:"reason" binding:"required,oneof=spam inappropriate harassment misinformation other"`
|
||||||
Description string `json:"description" binding:"max=500"`
|
Description string `json:"description" binding:"max=500"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReportResponse 举报响应
|
// ReportResponse 举报响应
|
||||||
@@ -26,94 +26,77 @@ type ReportResponse struct {
|
|||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminReportListQuery 管理端举报列表查询参数
|
|
||||||
type AdminReportListQuery struct {
|
|
||||||
Page int `form:"page" binding:"min=1"`
|
|
||||||
PageSize int `form:"page_size" binding:"min=1,max=100"`
|
|
||||||
TargetType string `form:"target_type" binding:"omitempty,oneof=post comment message"`
|
|
||||||
Status string `form:"status" binding:"omitempty,oneof=pending processing resolved rejected"`
|
|
||||||
StartDate string `form:"start_date" binding:"omitempty"`
|
|
||||||
EndDate string `form:"end_date" binding:"omitempty"`
|
|
||||||
Keyword string `form:"keyword" binding:"omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminReportListResponse 管理端举报列表响应
|
// AdminReportListResponse 管理端举报列表响应
|
||||||
type AdminReportListResponse struct {
|
type AdminReportListResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ReporterID string `json:"reporter_id"`
|
ReporterID string `json:"reporter_id"`
|
||||||
Reporter *UserResponse `json:"reporter,omitempty"`
|
Reporter *UserResponse `json:"reporter,omitempty"`
|
||||||
TargetType string `json:"target_type"`
|
TargetType string `json:"target_type"`
|
||||||
TargetID string `json:"target_id"`
|
TargetID string `json:"target_id"`
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
HandledBy *string `json:"handled_by,omitempty"`
|
HandledBy *string `json:"handled_by,omitempty"`
|
||||||
Handler *UserResponse `json:"handler,omitempty"`
|
Handler *UserResponse `json:"handler,omitempty"`
|
||||||
HandledAt *string `json:"handled_at,omitempty"`
|
HandledAt *string `json:"handled_at,omitempty"`
|
||||||
Result *string `json:"result,omitempty"`
|
Result *string `json:"result,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminReportDetailResponse 管理端举报详情响应
|
// AdminReportDetailResponse 管理端举报详情响应
|
||||||
type AdminReportDetailResponse struct {
|
type AdminReportDetailResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ReporterID string `json:"reporter_id"`
|
ReporterID string `json:"reporter_id"`
|
||||||
Reporter *UserResponse `json:"reporter"`
|
Reporter *UserResponse `json:"reporter"`
|
||||||
TargetType string `json:"target_type"`
|
TargetType string `json:"target_type"`
|
||||||
TargetID string `json:"target_id"`
|
TargetID string `json:"target_id"`
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
HandledBy *string `json:"handled_by,omitempty"`
|
HandledBy *string `json:"handled_by,omitempty"`
|
||||||
Handler *UserResponse `json:"handler,omitempty"`
|
Handler *UserResponse `json:"handler,omitempty"`
|
||||||
HandledAt *string `json:"handled_at,omitempty"`
|
HandledAt *string `json:"handled_at,omitempty"`
|
||||||
Result *string `json:"result,omitempty"`
|
Result *string `json:"result,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
|
|
||||||
// 被举报内容详情
|
// 被举报内容详情
|
||||||
TargetContent *ReportTargetContent `json:"target_content,omitempty"`
|
TargetContent *ReportTargetContent `json:"target_content,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReportTargetContent 被举报内容详情
|
// ReportTargetContent 被举报内容详情
|
||||||
type ReportTargetContent struct {
|
type ReportTargetContent struct {
|
||||||
Type string `json:"type"` // post, comment, message
|
Type string `json:"type"` // post, comment, message
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Author *UserResponse `json:"author,omitempty"`
|
Author *UserResponse `json:"author,omitempty"`
|
||||||
Content string `json:"content,omitempty"`
|
Content string `json:"content,omitempty"`
|
||||||
Title string `json:"title,omitempty"` // 帖子标题
|
Title string `json:"title,omitempty"` // 帖子标题
|
||||||
Images []string `json:"images,omitempty"` // 图片列表
|
Images []string `json:"images,omitempty"` // 图片列表
|
||||||
Status string `json:"status,omitempty"` // 内容状态
|
Status string `json:"status,omitempty"` // 内容状态
|
||||||
CreatedAt string `json:"created_at,omitempty"`
|
CreatedAt string `json:"created_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleReportRequest 处理举报请求
|
// HandleReportRequest 处理举报请求
|
||||||
type HandleReportRequest struct {
|
type HandleReportRequest struct {
|
||||||
Action string `json:"action" binding:"required,oneof=approve reject"` // approve: 确认违规, reject: 驳回
|
Action string `json:"action" binding:"required,oneof=approve reject"` // approve: 确认违规, reject: 驳回
|
||||||
Result string `json:"result" binding:"max=500"` // 处理结果说明
|
Result string `json:"result" binding:"max=500"` // 处理结果说明
|
||||||
}
|
}
|
||||||
|
|
||||||
// BatchHandleReportRequest 批量处理举报请求
|
// BatchHandleReportRequest 批量处理举报请求
|
||||||
type BatchHandleReportRequest struct {
|
type BatchHandleReportRequest struct {
|
||||||
IDs []string `json:"ids" binding:"required,min=1,max=100"`
|
IDs []string `json:"ids" binding:"required,min=1,max=100"`
|
||||||
Action string `json:"action" binding:"required,oneof=approve reject"`
|
Action string `json:"action" binding:"required,oneof=approve reject"`
|
||||||
Result string `json:"result" binding:"max=500"`
|
Result string `json:"result" binding:"max=500"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminBatchHandleResponse 批量处理响应
|
// AdminBatchHandleResponse 批量处理响应
|
||||||
type AdminBatchHandleResponse struct {
|
type AdminBatchHandleResponse struct {
|
||||||
SuccessCount int `json:"success_count"`
|
SuccessCount int `json:"success_count"`
|
||||||
FailedCount int `json:"failed_count"`
|
FailedCount int `json:"failed_count"`
|
||||||
FailedIDs []string `json:"failed_ids,omitempty"`
|
FailedIDs []string `json:"failed_ids,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 转换函数 ====================
|
// ==================== 转换函数 ====================
|
||||||
|
|
||||||
func baseAdminReportFields(report *model.Report) (string, string) {
|
|
||||||
reason := string(report.Reason)
|
|
||||||
status := string(report.Status)
|
|
||||||
return reason, status
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConvertReportToResponse 将 Report 模型转换为响应
|
// ConvertReportToResponse 将 Report 模型转换为响应
|
||||||
func ConvertReportToResponse(report *model.Report) *ReportResponse {
|
func ConvertReportToResponse(report *model.Report) *ReportResponse {
|
||||||
return &ReportResponse{
|
return &ReportResponse{
|
||||||
@@ -185,4 +168,4 @@ func ConvertReportToAdminDetailResponse(report *model.Report, reporter *model.Us
|
|||||||
resp.HandledAt = &handledAt
|
resp.HandledAt = &handledAt
|
||||||
}
|
}
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,6 @@ import (
|
|||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ParseSegmentData 解析Segment数据到目标结构体
|
|
||||||
func ParseSegmentData(segment model.MessageSegment, target any) error {
|
|
||||||
dataBytes, err := json.Marshal(segment.Data)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return json.Unmarshal(dataBytes, target)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTextSegment 创建文本Segment
|
// NewTextSegment 创建文本Segment
|
||||||
func NewTextSegment(content string) model.MessageSegment {
|
func NewTextSegment(content string) model.MessageSegment {
|
||||||
return model.MessageSegment{
|
return model.MessageSegment{
|
||||||
@@ -25,137 +16,6 @@ func NewTextSegment(content string) model.MessageSegment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewImageSegment 创建图片Segment
|
|
||||||
func NewImageSegment(url string, width, height int, thumbnailURL string) model.MessageSegment {
|
|
||||||
return model.MessageSegment{
|
|
||||||
Type: string(SegmentTypeImage),
|
|
||||||
Data: map[string]any{
|
|
||||||
"url": url,
|
|
||||||
"width": width,
|
|
||||||
"height": height,
|
|
||||||
"thumbnail_url": thumbnailURL,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewImageSegmentWithSize 创建带文件大小的图片Segment
|
|
||||||
func NewImageSegmentWithSize(url string, width, height int, thumbnailURL string, fileSize int64) model.MessageSegment {
|
|
||||||
return model.MessageSegment{
|
|
||||||
Type: string(SegmentTypeImage),
|
|
||||||
Data: map[string]any{
|
|
||||||
"url": url,
|
|
||||||
"width": width,
|
|
||||||
"height": height,
|
|
||||||
"thumbnail_url": thumbnailURL,
|
|
||||||
"file_size": fileSize,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewVoiceSegment 创建语音Segment
|
|
||||||
func NewVoiceSegment(url string, duration int, fileSize int64) model.MessageSegment {
|
|
||||||
return model.MessageSegment{
|
|
||||||
Type: string(SegmentTypeVoice),
|
|
||||||
Data: map[string]any{
|
|
||||||
"url": url,
|
|
||||||
"duration": duration,
|
|
||||||
"file_size": fileSize,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewVideoSegment 创建视频Segment
|
|
||||||
func NewVideoSegment(url string, width, height, duration int, thumbnailURL string, fileSize int64) model.MessageSegment {
|
|
||||||
return model.MessageSegment{
|
|
||||||
Type: string(SegmentTypeVideo),
|
|
||||||
Data: map[string]any{
|
|
||||||
"url": url,
|
|
||||||
"width": width,
|
|
||||||
"height": height,
|
|
||||||
"duration": duration,
|
|
||||||
"thumbnail_url": thumbnailURL,
|
|
||||||
"file_size": fileSize,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewFileSegment 创建文件Segment
|
|
||||||
func NewFileSegment(url, name string, size int64, mimeType string) model.MessageSegment {
|
|
||||||
return model.MessageSegment{
|
|
||||||
Type: string(SegmentTypeFile),
|
|
||||||
Data: map[string]any{
|
|
||||||
"url": url,
|
|
||||||
"name": name,
|
|
||||||
"size": size,
|
|
||||||
"mime_type": mimeType,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAtSegment 创建@Segment,只存储 user_id,昵称由前端根据群成员列表实时解析
|
|
||||||
func NewAtSegment(userID string) model.MessageSegment {
|
|
||||||
return model.MessageSegment{
|
|
||||||
Type: string(SegmentTypeAt),
|
|
||||||
Data: map[string]any{
|
|
||||||
"user_id": userID,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAtAllSegment 创建@所有人Segment
|
|
||||||
func NewAtAllSegment() model.MessageSegment {
|
|
||||||
return model.MessageSegment{
|
|
||||||
Type: string(SegmentTypeAt),
|
|
||||||
Data: map[string]any{
|
|
||||||
"user_id": "all",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewReplySegment 创建回复Segment
|
|
||||||
func NewReplySegment(messageID string) model.MessageSegment {
|
|
||||||
return model.MessageSegment{
|
|
||||||
Type: string(SegmentTypeReply),
|
|
||||||
Data: map[string]any{"id": messageID},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewFaceSegment 创建表情Segment
|
|
||||||
func NewFaceSegment(id int, name, url string) model.MessageSegment {
|
|
||||||
return model.MessageSegment{
|
|
||||||
Type: string(SegmentTypeFace),
|
|
||||||
Data: map[string]any{
|
|
||||||
"id": id,
|
|
||||||
"name": name,
|
|
||||||
"url": url,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewLinkSegment 创建链接Segment
|
|
||||||
func NewLinkSegment(url, title, description, image string) model.MessageSegment {
|
|
||||||
return model.MessageSegment{
|
|
||||||
Type: string(SegmentTypeLink),
|
|
||||||
Data: map[string]any{
|
|
||||||
"url": url,
|
|
||||||
"title": title,
|
|
||||||
"description": description,
|
|
||||||
"image": image,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPostRefSegment 创建帖子内链Segment
|
|
||||||
func NewPostRefSegment(postID, title string) model.MessageSegment {
|
|
||||||
return model.MessageSegment{
|
|
||||||
Type: string(SegmentTypePostRef),
|
|
||||||
Data: map[string]any{
|
|
||||||
"post_id": postID,
|
|
||||||
"title": title,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExtractVoteSegmentData 从消息链中提取投票Segment数据
|
// ExtractVoteSegmentData 从消息链中提取投票Segment数据
|
||||||
func ExtractVoteSegmentData(segments model.MessageSegments) *VoteSegmentData {
|
func ExtractVoteSegmentData(segments model.MessageSegments) *VoteSegmentData {
|
||||||
for _, segment := range segments {
|
for _, segment := range segments {
|
||||||
@@ -179,22 +39,6 @@ func HasVoteSegment(segments model.MessageSegments) bool {
|
|||||||
return HasSegmentType(segments, SegmentTypeVote)
|
return HasSegmentType(segments, SegmentTypeVote)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractTextContentFromJSON 从JSON格式的segments中提取纯文本内容
|
|
||||||
// 用于从数据库读取的 []byte 格式的 segments
|
|
||||||
// 已废弃:现在数据库直接存储 model.MessageSegments 类型
|
|
||||||
func ExtractTextContentFromJSON(segmentsJSON []byte) string {
|
|
||||||
if len(segmentsJSON) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var segments model.MessageSegments
|
|
||||||
if err := json.Unmarshal(segmentsJSON, &segments); err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return ExtractTextContentFromModel(segments)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExtractTextContentFromModel 从 model.MessageSegments 中提取纯文本内容
|
// ExtractTextContentFromModel 从 model.MessageSegments 中提取纯文本内容
|
||||||
func ExtractTextContentFromModel(segments model.MessageSegments) string {
|
func ExtractTextContentFromModel(segments model.MessageSegments) string {
|
||||||
var result string
|
var result string
|
||||||
@@ -290,72 +134,6 @@ func ExtractReferencedPosts(segments model.MessageSegments) []string {
|
|||||||
return postIDs
|
return postIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasPostRefSegment 检查消息链中是否包含帖子引用
|
|
||||||
func HasPostRefSegment(segments model.MessageSegments) bool {
|
|
||||||
return HasSegmentType(segments, SegmentTypePostRef)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsAtAll 检查消息是否@了所有人
|
|
||||||
func IsAtAll(segments model.MessageSegments) bool {
|
|
||||||
return slices.ContainsFunc(segments, func(segment model.MessageSegment) bool {
|
|
||||||
if segment.Type == string(SegmentTypeAt) {
|
|
||||||
if userID, ok := segment.Data["user_id"].(string); ok && userID == "all" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetReplyMessageID 从消息链中获取被回复的消息ID
|
|
||||||
// 如果没有回复segment,返回空字符串
|
|
||||||
func GetReplyMessageID(segments model.MessageSegments) string {
|
|
||||||
idx := slices.IndexFunc(segments, func(segment model.MessageSegment) bool {
|
|
||||||
return segment.Type == string(SegmentTypeReply)
|
|
||||||
})
|
|
||||||
if idx == -1 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if id, ok := segments[idx].Data["id"].(string); ok {
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildSegmentsFromContent 从旧版content构建segments
|
|
||||||
// 用于兼容旧版本消息
|
|
||||||
func BuildSegmentsFromContent(contentType, content string, mediaURL *string) model.MessageSegments {
|
|
||||||
var segments model.MessageSegments
|
|
||||||
|
|
||||||
switch contentType {
|
|
||||||
case "text":
|
|
||||||
segments = append(segments, NewTextSegment(content))
|
|
||||||
case "image":
|
|
||||||
if mediaURL != nil {
|
|
||||||
segments = append(segments, NewImageSegment(*mediaURL, 0, 0, ""))
|
|
||||||
}
|
|
||||||
case "voice":
|
|
||||||
if mediaURL != nil {
|
|
||||||
segments = append(segments, NewVoiceSegment(*mediaURL, 0, 0))
|
|
||||||
}
|
|
||||||
case "video":
|
|
||||||
if mediaURL != nil {
|
|
||||||
segments = append(segments, NewVideoSegment(*mediaURL, 0, 0, 0, "", 0))
|
|
||||||
}
|
|
||||||
case "file":
|
|
||||||
if mediaURL != nil {
|
|
||||||
segments = append(segments, NewFileSegment(*mediaURL, content, 0, ""))
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// 默认当作文本处理
|
|
||||||
if content != "" {
|
|
||||||
segments = append(segments, NewTextSegment(content))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return segments
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasSegmentType 检查消息链中是否包含指定类型的segment
|
// HasSegmentType 检查消息链中是否包含指定类型的segment
|
||||||
func HasSegmentType(segments model.MessageSegments, segmentType SegmentType) bool {
|
func HasSegmentType(segments model.MessageSegments, segmentType SegmentType) bool {
|
||||||
return slices.ContainsFunc(segments, func(segment model.MessageSegment) bool {
|
return slices.ContainsFunc(segments, func(segment model.MessageSegment) bool {
|
||||||
@@ -363,71 +141,3 @@ func HasSegmentType(segments model.MessageSegments, segmentType SegmentType) boo
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSegmentsByType 获取消息链中所有指定类型的segment
|
|
||||||
func GetSegmentsByType(segments model.MessageSegments, segmentType SegmentType) []model.MessageSegment {
|
|
||||||
var result []model.MessageSegment
|
|
||||||
for _, segment := range segments {
|
|
||||||
if segment.Type == string(segmentType) {
|
|
||||||
result = append(result, segment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetFirstImageURL 获取消息链中第一张图片的URL
|
|
||||||
// 如果没有图片,返回空字符串
|
|
||||||
func GetFirstImageURL(segments model.MessageSegments) string {
|
|
||||||
idx := slices.IndexFunc(segments, func(segment model.MessageSegment) bool {
|
|
||||||
return segment.Type == string(SegmentTypeImage)
|
|
||||||
})
|
|
||||||
if idx == -1 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if url, ok := segments[idx].Data["url"].(string); ok {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetFirstMediaURL 获取消息链中第一个媒体文件的URL(图片/视频/语音/文件)
|
|
||||||
// 用于兼容旧版本API
|
|
||||||
func GetFirstMediaURL(segments model.MessageSegments) string {
|
|
||||||
idx := slices.IndexFunc(segments, func(segment model.MessageSegment) bool {
|
|
||||||
switch segment.Type {
|
|
||||||
case string(SegmentTypeImage), string(SegmentTypeVideo), string(SegmentTypeVoice), string(SegmentTypeFile):
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
if idx == -1 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if url, ok := segments[idx].Data["url"].(string); ok {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// DetermineContentType 从消息链推断消息类型(用于兼容旧版本)
|
|
||||||
func DetermineContentType(segments model.MessageSegments) string {
|
|
||||||
if len(segments) == 0 {
|
|
||||||
return "text"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 优先检查媒体类型
|
|
||||||
for _, segment := range segments {
|
|
||||||
switch segment.Type {
|
|
||||||
case string(SegmentTypeImage):
|
|
||||||
return "image"
|
|
||||||
case string(SegmentTypeVideo):
|
|
||||||
return "video"
|
|
||||||
case string(SegmentTypeVoice):
|
|
||||||
return "voice"
|
|
||||||
case string(SegmentTypeFile):
|
|
||||||
return "file"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 默认返回text
|
|
||||||
return "text"
|
|
||||||
}
|
|
||||||
|
|||||||
23
internal/dto/sync_dto.go
Normal file
23
internal/dto/sync_dto.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// SyncVersionRequest 增量同步请求
|
||||||
|
type SyncVersionRequest struct {
|
||||||
|
Version int64 `form:"version" binding:"min=0"` // 客户端上次同步版本号,0 表示全量同步
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncVersionResponse 增量同步响应
|
||||||
|
type SyncVersionResponse struct {
|
||||||
|
CurrentVersion int64 `json:"current_version"` // 服务端当前最大版本号
|
||||||
|
Changes []SyncChangeItem `json:"changes"` // 变更列表
|
||||||
|
FullSync bool `json:"full_sync"` // 是否需要全量同步(版本差距过大时)
|
||||||
|
HasMore bool `json:"has_more"` // 是否还有更多变更
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncChangeItem 单条变更项
|
||||||
|
type SyncChangeItem struct {
|
||||||
|
ConversationID string `json:"conversation_id"`
|
||||||
|
ChangeType string `json:"change_type"` // new_msg, read, pin, unpin, mute, unmute, hide, group_update
|
||||||
|
MaxSeq int64 `json:"max_seq,omitempty"`
|
||||||
|
LastMessageAt int64 `json:"last_message_at,omitempty"`
|
||||||
|
Version int64 `json:"version"`
|
||||||
|
}
|
||||||
@@ -66,6 +66,3 @@ func ConvertTradeItemsToResponse(items []*model.TradeItem, favoriteMap map[strin
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func ConvertTradeItemToDetailResponse(item *model.TradeItem, isFavorited bool) *TradeItemResponse {
|
|
||||||
return ConvertTradeItemToResponse(item, isFavorited)
|
|
||||||
}
|
|
||||||
@@ -108,6 +108,8 @@ var (
|
|||||||
ErrInvalidCallState = &AppError{Code: "INVALID_CALL_STATE", Message: "通话状态无效"}
|
ErrInvalidCallState = &AppError{Code: "INVALID_CALL_STATE", Message: "通话状态无效"}
|
||||||
ErrCalleeOffline = &AppError{Code: "CALLEE_OFFLINE", Message: "对方不在线"}
|
ErrCalleeOffline = &AppError{Code: "CALLEE_OFFLINE", Message: "对方不在线"}
|
||||||
ErrCallAlreadyAnswered = &AppError{Code: "CALL_ALREADY_ANSWERED", Message: "通话已在其他设备应答"}
|
ErrCallAlreadyAnswered = &AppError{Code: "CALL_ALREADY_ANSWERED", Message: "通话已在其他设备应答"}
|
||||||
|
ErrGroupCallNotSupported = &AppError{Code: "GROUP_CALL_NOT_SUPPORTED", Message: "群组通话不支持"}
|
||||||
|
ErrMaxParticipantsReached = &AppError{Code: "MAX_PARTICIPANTS_REACHED", Message: "通话人数已达上限"}
|
||||||
|
|
||||||
// 消息相关错误
|
// 消息相关错误
|
||||||
ErrConversationNotFound = &AppError{Code: "CONVERSATION_NOT_FOUND", Message: "会话不存在或无权限"}
|
ErrConversationNotFound = &AppError{Code: "CONVERSATION_NOT_FOUND", Message: "会话不存在或无权限"}
|
||||||
@@ -128,6 +130,9 @@ var (
|
|||||||
// 学习资料相关错误
|
// 学习资料相关错误
|
||||||
ErrSubjectHasMaterials = &AppError{Code: "SUBJECT_HAS_MATERIALS", Message: "学科下存在资料,无法删除"}
|
ErrSubjectHasMaterials = &AppError{Code: "SUBJECT_HAS_MATERIALS", Message: "学科下存在资料,无法删除"}
|
||||||
|
|
||||||
|
// 帖子创建幂等性:同一 client_request_id 的请求仍在处理中(占位未回填真实结果)
|
||||||
|
ErrDuplicatePostRequest = &AppError{Code: "DUPLICATE_POST_REQUEST", Message: "正在发布中,请稍候"}
|
||||||
|
|
||||||
// 身份认证相关错误
|
// 身份认证相关错误
|
||||||
ErrVerificationPending = &AppError{Code: "VERIFICATION_PENDING", Message: "已有待审核的认证申请,请等待审核"}
|
ErrVerificationPending = &AppError{Code: "VERIFICATION_PENDING", Message: "已有待审核的认证申请,请等待审核"}
|
||||||
ErrVerificationNotFound = &AppError{Code: "VERIFICATION_NOT_FOUND", Message: "认证记录不存在"}
|
ErrVerificationNotFound = &AppError{Code: "VERIFICATION_NOT_FOUND", Message: "认证记录不存在"}
|
||||||
@@ -136,6 +141,13 @@ var (
|
|||||||
ErrVerificationAlreadyHandled = &AppError{Code: "VERIFICATION_ALREADY_HANDLED", Message: "该认证申请已被处理"}
|
ErrVerificationAlreadyHandled = &AppError{Code: "VERIFICATION_ALREADY_HANDLED", Message: "该认证申请已被处理"}
|
||||||
ErrVerificationRequired = &AppError{Code: "VERIFICATION_REQUIRED", Message: "需要完成身份认证"}
|
ErrVerificationRequired = &AppError{Code: "VERIFICATION_REQUIRED", Message: "需要完成身份认证"}
|
||||||
|
|
||||||
|
// 认证 / 令牌相关错误(统一由认证管道返回)
|
||||||
|
ErrInvalidToken = &AppError{Code: "INVALID_TOKEN", Message: "令牌无效"}
|
||||||
|
ErrTokenExpired = &AppError{Code: "TOKEN_EXPIRED", Message: "令牌已过期"}
|
||||||
|
ErrSessionRevoked = &AppError{Code: "SESSION_REVOKED", Message: "会话已失效,请重新登录"}
|
||||||
|
ErrAccountInactive = &AppError{Code: "ACCOUNT_INACTIVE", Message: "账号未激活"}
|
||||||
|
ErrWrongTokenType = &AppError{Code: "WRONG_TOKEN_TYPE", Message: "令牌类型错误"}
|
||||||
|
|
||||||
// 初始化相关错误
|
// 初始化相关错误
|
||||||
ErrSetupAlreadyCompleted = &AppError{Code: "SETUP_ALREADY_COMPLETED", Message: "超级管理员已初始化,无法重复设置"}
|
ErrSetupAlreadyCompleted = &AppError{Code: "SETUP_ALREADY_COMPLETED", Message: "超级管理员已初始化,无法重复设置"}
|
||||||
ErrInvalidSetupSecret = &AppError{Code: "INVALID_SETUP_SECRET", Message: "初始化密钥无效"}
|
ErrInvalidSetupSecret = &AppError{Code: "INVALID_SETUP_SECRET", Message: "初始化密钥无效"}
|
||||||
@@ -151,16 +163,6 @@ func Wrap(err error, appErr *AppError) *AppError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// New 创建新错误
|
|
||||||
func New(code, message string) *AppError {
|
|
||||||
return &AppError{Code: code, Message: message}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Is 判断是否为特定错误
|
|
||||||
func Is(err, target error) bool {
|
|
||||||
return errors.Is(err, target)
|
|
||||||
}
|
|
||||||
|
|
||||||
// As 将错误转换为 AppError
|
// As 将错误转换为 AppError
|
||||||
func As(err error) (*AppError, bool) {
|
func As(err error) (*AppError, bool) {
|
||||||
var appErr *AppError
|
var appErr *AppError
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"time"
|
"time"
|
||||||
@@ -12,7 +11,6 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials"
|
"google.golang.org/grpc/credentials"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server gRPC 服务器
|
// Server gRPC 服务器
|
||||||
@@ -23,19 +21,6 @@ type Server struct {
|
|||||||
grpcSrv *grpc.Server
|
grpcSrv *grpc.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer 创建新的 gRPC 服务器(内部构造 Hub + TaskManager)
|
|
||||||
func NewServer(cfg *config.GRPCConfig, logger *zap.Logger) *Server {
|
|
||||||
taskManager := NewTaskManager(nil, 60*time.Second, logger)
|
|
||||||
hub := NewRunnerHub(taskManager, logger)
|
|
||||||
taskManager.SetDispatcher(hub)
|
|
||||||
|
|
||||||
return &Server{
|
|
||||||
cfg: cfg,
|
|
||||||
logger: logger,
|
|
||||||
hub: hub,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewServerWithDeps 使用外部依赖创建 gRPC 服务器(Wire DI 使用)
|
// NewServerWithDeps 使用外部依赖创建 gRPC 服务器(Wire DI 使用)
|
||||||
func NewServerWithDeps(cfg *config.GRPCConfig, logger *zap.Logger, hub *RunnerHub) *Server {
|
func NewServerWithDeps(cfg *config.GRPCConfig, logger *zap.Logger, hub *RunnerHub) *Server {
|
||||||
return &Server{
|
return &Server{
|
||||||
@@ -111,43 +96,3 @@ func (s *Server) Stop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetHub 获取 RunnerHub
|
|
||||||
func (s *Server) GetHub() *RunnerHub {
|
|
||||||
return s.hub
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTaskManager 获取 TaskManager
|
|
||||||
func (s *Server) GetTaskManager() *TaskManager {
|
|
||||||
return s.hub.taskManager
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shutdown 优雅关闭
|
|
||||||
func (s *Server) Shutdown(ctx context.Context) error {
|
|
||||||
if s.grpcSrv == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
s.grpcSrv.GracefulStop()
|
|
||||||
close(done)
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
s.logger.Info("gRPC server stopped gracefully")
|
|
||||||
return nil
|
|
||||||
case <-ctx.Done():
|
|
||||||
s.logger.Warn("gRPC server shutdown timeout, forcing stop")
|
|
||||||
s.grpcSrv.Stop()
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetClientCredentials 获取客户端凭证配置
|
|
||||||
func GetClientCredentials(cfg *config.GRPCConfig) (credentials.TransportCredentials, error) {
|
|
||||||
if cfg.TLSEnabled {
|
|
||||||
return credentials.NewClientTLSFromFile(cfg.TLSCertFile, "")
|
|
||||||
}
|
|
||||||
return insecure.NewCredentials(), nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"with_you/internal/dto"
|
|
||||||
"with_you/internal/pkg/response"
|
"with_you/internal/pkg/response"
|
||||||
|
"with_you/internal/query"
|
||||||
"with_you/internal/service"
|
"with_you/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -37,7 +37,7 @@ func (h *AdminLogHandler) GetOperationLogs(c *gin.Context) {
|
|||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
filters := dto.LogFilter{
|
filters := query.LogFilter{
|
||||||
UserID: c.Query("user_id"),
|
UserID: c.Query("user_id"),
|
||||||
Operation: c.Query("operation"),
|
Operation: c.Query("operation"),
|
||||||
TargetType: c.Query("target_type"),
|
TargetType: c.Query("target_type"),
|
||||||
@@ -70,7 +70,7 @@ func (h *AdminLogHandler) GetLoginLogs(c *gin.Context) {
|
|||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
filters := dto.LoginFilter{
|
filters := query.LoginFilter{
|
||||||
UserID: c.Query("user_id"),
|
UserID: c.Query("user_id"),
|
||||||
Event: c.Query("event"),
|
Event: c.Query("event"),
|
||||||
Result: c.Query("result"),
|
Result: c.Query("result"),
|
||||||
@@ -113,7 +113,7 @@ func (h *AdminLogHandler) GetDataChangeLogs(c *gin.Context) {
|
|||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
filters := dto.DataChangeFilter{
|
filters := query.DataChangeFilter{
|
||||||
UserID: c.Query("user_id"),
|
UserID: c.Query("user_id"),
|
||||||
OperatorID: c.Query("operator_id"),
|
OperatorID: c.Query("operator_id"),
|
||||||
ChangeType: c.Query("change_type"),
|
ChangeType: c.Query("change_type"),
|
||||||
@@ -190,7 +190,7 @@ func (h *AdminLogHandler) ExportLogs(c *gin.Context) {
|
|||||||
"logs": logs,
|
"logs": logs,
|
||||||
})
|
})
|
||||||
case "login":
|
case "login":
|
||||||
logs, _, err := h.loginLogService.GetLoginLogs(c.Request.Context(), dto.LoginFilter{}, 1, 10000)
|
logs, _, err := h.loginLogService.GetLoginLogs(c.Request.Context(), query.LoginFilter{}, 1, 10000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.HandleError(c, err, "failed to export login logs")
|
response.HandleError(c, err, "failed to export login logs")
|
||||||
return
|
return
|
||||||
@@ -200,7 +200,7 @@ func (h *AdminLogHandler) ExportLogs(c *gin.Context) {
|
|||||||
"log": logs,
|
"log": logs,
|
||||||
})
|
})
|
||||||
case "data_change":
|
case "data_change":
|
||||||
logs, _, err := h.dataChangeLogService.GetDataChangeLogs(c.Request.Context(), dto.DataChangeFilter{}, 1, 10000)
|
logs, _, err := h.dataChangeLogService.GetDataChangeLogs(c.Request.Context(), query.DataChangeFilter{}, 1, 10000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.HandleError(c, err, "failed to export data change logs")
|
response.HandleError(c, err, "failed to export data change logs")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
|
||||||
"with_you/internal/dto"
|
"with_you/internal/dto"
|
||||||
"with_you/internal/pkg/response"
|
"with_you/internal/pkg/response"
|
||||||
|
"with_you/internal/query"
|
||||||
"with_you/internal/service"
|
"with_you/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -25,7 +25,7 @@ func NewAdminReportHandler(adminReportService service.AdminReportService) *Admin
|
|||||||
// List 获取举报列表
|
// List 获取举报列表
|
||||||
func (h *AdminReportHandler) List(c *gin.Context) {
|
func (h *AdminReportHandler) List(c *gin.Context) {
|
||||||
// 解析查询参数
|
// 解析查询参数
|
||||||
var query dto.AdminReportListQuery
|
var query query.AdminReportListQuery
|
||||||
if err := c.ShouldBindQuery(&query); err != nil {
|
if err := c.ShouldBindQuery(&query); err != nil {
|
||||||
response.BadRequest(c, "参数错误: "+err.Error())
|
response.BadRequest(c, "参数错误: "+err.Error())
|
||||||
return
|
return
|
||||||
@@ -137,16 +137,3 @@ func (h *AdminReportHandler) BatchHandle(c *gin.Context) {
|
|||||||
|
|
||||||
response.Success(c, result)
|
response.Success(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parsePageParams 解析分页参数
|
|
||||||
func parsePageParams(c *gin.Context) (int, int) {
|
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
if pageSize < 1 || pageSize > 100 {
|
|
||||||
pageSize = 20
|
|
||||||
}
|
|
||||||
return page, pageSize
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ import (
|
|||||||
// AdminUserHandler 管理端用户处理器
|
// AdminUserHandler 管理端用户处理器
|
||||||
type AdminUserHandler struct {
|
type AdminUserHandler struct {
|
||||||
adminUserService service.AdminUserService
|
adminUserService service.AdminUserService
|
||||||
|
pushService service.PushService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAdminUserHandler 创建管理端用户处理器
|
// NewAdminUserHandler 创建管理端用户处理器
|
||||||
func NewAdminUserHandler(adminUserService service.AdminUserService) *AdminUserHandler {
|
func NewAdminUserHandler(adminUserService service.AdminUserService, pushService service.PushService) *AdminUserHandler {
|
||||||
return &AdminUserHandler{
|
return &AdminUserHandler{
|
||||||
adminUserService: adminUserService,
|
adminUserService: adminUserService,
|
||||||
|
pushService: pushService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,3 +105,22 @@ func (h *AdminUserHandler) UpdateUserStatus(c *gin.Context) {
|
|||||||
|
|
||||||
response.Success(c, userDetail)
|
response.Success(c, userDetail)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserDevices 获取用户设备列表(含 registration_id)
|
||||||
|
// GET /api/v1/admin/users/:id/devices
|
||||||
|
// 供后台查询目标用户各设备的 JPush RegistrationID(push_token),用于排查推送问题或手动推送测试。
|
||||||
|
func (h *AdminUserHandler) GetUserDevices(c *gin.Context) {
|
||||||
|
userID := c.Param("id")
|
||||||
|
if userID == "" {
|
||||||
|
response.BadRequest(c, "user id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
devices, err := h.pushService.GetUserDevices(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
response.InternalServerError(c, "failed to get user devices")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, dto.DeviceTokensToAdminResponse(devices))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"with_you/internal/dto"
|
"with_you/internal/dto"
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
"with_you/internal/pkg/response"
|
"with_you/internal/pkg/response"
|
||||||
"with_you/internal/repository"
|
|
||||||
"with_you/internal/service"
|
"with_you/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -14,16 +14,16 @@ import (
|
|||||||
|
|
||||||
type AdminVerificationHandler struct {
|
type AdminVerificationHandler struct {
|
||||||
adminVerificationService service.AdminVerificationService
|
adminVerificationService service.AdminVerificationService
|
||||||
userRepo repository.UserRepository
|
userService service.UserService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAdminVerificationHandler(
|
func NewAdminVerificationHandler(
|
||||||
adminVerificationService service.AdminVerificationService,
|
adminVerificationService service.AdminVerificationService,
|
||||||
userRepo repository.UserRepository,
|
userService service.UserService,
|
||||||
) *AdminVerificationHandler {
|
) *AdminVerificationHandler {
|
||||||
return &AdminVerificationHandler{
|
return &AdminVerificationHandler{
|
||||||
adminVerificationService: adminVerificationService,
|
adminVerificationService: adminVerificationService,
|
||||||
userRepo: userRepo,
|
userService: userService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ func (h *AdminVerificationHandler) ListVerifications(c *gin.Context) {
|
|||||||
|
|
||||||
responses := make([]*dto.AdminVerificationListResponse, len(records))
|
responses := make([]*dto.AdminVerificationListResponse, len(records))
|
||||||
for i, record := range records {
|
for i, record := range records {
|
||||||
responses[i] = h.convertToAdminListResponse(record)
|
responses[i] = h.convertToAdminListResponse(c.Request.Context(), record)
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Paginated(c, responses, total, page, pageSize)
|
response.Paginated(c, responses, total, page, pageSize)
|
||||||
@@ -74,7 +74,7 @@ func (h *AdminVerificationHandler) GetVerificationDetail(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Success(c, h.convertToAdminDetailResponse(record))
|
response.Success(c, h.convertToAdminDetailResponse(c.Request.Context(), record))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AdminVerificationHandler) ReviewVerification(c *gin.Context) {
|
func (h *AdminVerificationHandler) ReviewVerification(c *gin.Context) {
|
||||||
@@ -113,10 +113,10 @@ func (h *AdminVerificationHandler) ReviewVerification(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Success(c, h.convertToAdminDetailResponse(record))
|
response.Success(c, h.convertToAdminDetailResponse(c.Request.Context(), record))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AdminVerificationHandler) convertToAdminListResponse(record *model.VerificationRecord) *dto.AdminVerificationListResponse {
|
func (h *AdminVerificationHandler) convertToAdminListResponse(ctx context.Context, record *model.VerificationRecord) *dto.AdminVerificationListResponse {
|
||||||
resp := &dto.AdminVerificationListResponse{
|
resp := &dto.AdminVerificationListResponse{
|
||||||
ID: record.ID,
|
ID: record.ID,
|
||||||
UserID: record.UserID,
|
UserID: record.UserID,
|
||||||
@@ -129,7 +129,7 @@ func (h *AdminVerificationHandler) convertToAdminListResponse(record *model.Veri
|
|||||||
CreatedAt: dto.FormatTime(record.CreatedAt),
|
CreatedAt: dto.FormatTime(record.CreatedAt),
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := h.userRepo.GetByID(record.UserID)
|
user, err := h.userService.GetUserByID(ctx, record.UserID)
|
||||||
if err == nil && user != nil {
|
if err == nil && user != nil {
|
||||||
resp.Username = user.Username
|
resp.Username = user.Username
|
||||||
resp.Nickname = user.Nickname
|
resp.Nickname = user.Nickname
|
||||||
@@ -139,7 +139,7 @@ func (h *AdminVerificationHandler) convertToAdminListResponse(record *model.Veri
|
|||||||
if record.ReviewedAt != nil {
|
if record.ReviewedAt != nil {
|
||||||
resp.ReviewedAt = dto.FormatTime(*record.ReviewedAt)
|
resp.ReviewedAt = dto.FormatTime(*record.ReviewedAt)
|
||||||
if record.ReviewedBy != nil {
|
if record.ReviewedBy != nil {
|
||||||
reviewer, err := h.userRepo.GetByID(*record.ReviewedBy)
|
reviewer, err := h.userService.GetUserByID(ctx, *record.ReviewedBy)
|
||||||
if err == nil && reviewer != nil {
|
if err == nil && reviewer != nil {
|
||||||
resp.ReviewerName = reviewer.Nickname
|
resp.ReviewerName = reviewer.Nickname
|
||||||
}
|
}
|
||||||
@@ -149,7 +149,7 @@ func (h *AdminVerificationHandler) convertToAdminListResponse(record *model.Veri
|
|||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AdminVerificationHandler) convertToAdminDetailResponse(record *model.VerificationRecord) *dto.AdminVerificationDetailResponse {
|
func (h *AdminVerificationHandler) convertToAdminDetailResponse(ctx context.Context, record *model.VerificationRecord) *dto.AdminVerificationDetailResponse {
|
||||||
resp := &dto.AdminVerificationDetailResponse{
|
resp := &dto.AdminVerificationDetailResponse{
|
||||||
ID: record.ID,
|
ID: record.ID,
|
||||||
UserID: record.UserID,
|
UserID: record.UserID,
|
||||||
@@ -163,7 +163,7 @@ func (h *AdminVerificationHandler) convertToAdminDetailResponse(record *model.Ve
|
|||||||
CreatedAt: dto.FormatTime(record.CreatedAt),
|
CreatedAt: dto.FormatTime(record.CreatedAt),
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := h.userRepo.GetByID(record.UserID)
|
user, err := h.userService.GetUserByID(ctx, record.UserID)
|
||||||
if err == nil && user != nil {
|
if err == nil && user != nil {
|
||||||
resp.Username = user.Username
|
resp.Username = user.Username
|
||||||
resp.Nickname = user.Nickname
|
resp.Nickname = user.Nickname
|
||||||
@@ -175,7 +175,7 @@ func (h *AdminVerificationHandler) convertToAdminDetailResponse(record *model.Ve
|
|||||||
}
|
}
|
||||||
if record.ReviewedBy != nil {
|
if record.ReviewedBy != nil {
|
||||||
resp.ReviewedBy = *record.ReviewedBy
|
resp.ReviewedBy = *record.ReviewedBy
|
||||||
if reviewer, err := h.userRepo.GetByID(*record.ReviewedBy); err == nil && reviewer != nil {
|
if reviewer, err := h.userService.GetUserByID(ctx, *record.ReviewedBy); err == nil && reviewer != nil {
|
||||||
resp.ReviewerName = reviewer.Nickname
|
resp.ReviewerName = reviewer.Nickname
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,12 +49,3 @@ func (h *CallHandler) GetCallHistory(c *gin.Context) {
|
|||||||
"page": page,
|
"page": page,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetICEServers 获取 ICE 服务器配置
|
|
||||||
// GET /api/v1/calls/ice-servers
|
|
||||||
func (h *CallHandler) GetICEServers(c *gin.Context) {
|
|
||||||
servers := h.callService.GetICEServers()
|
|
||||||
response.Success(c, gin.H{
|
|
||||||
"ice_servers": servers,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ import (
|
|||||||
|
|
||||||
// CommentHandler 评论处理器
|
// CommentHandler 评论处理器
|
||||||
type CommentHandler struct {
|
type CommentHandler struct {
|
||||||
commentService *service.CommentService
|
commentService service.CommentService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCommentHandler 创建评论处理器
|
// NewCommentHandler 创建评论处理器
|
||||||
func NewCommentHandler(commentService *service.CommentService) *CommentHandler {
|
func NewCommentHandler(commentService service.CommentService) *CommentHandler {
|
||||||
return &CommentHandler{
|
return &CommentHandler{
|
||||||
commentService: commentService,
|
commentService: commentService,
|
||||||
}
|
}
|
||||||
|
|||||||
83
internal/handler/empty_classroom_handler.go
Normal file
83
internal/handler/empty_classroom_handler.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"with_you/internal/pkg/response"
|
||||||
|
"with_you/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EmptyClassroomHandler struct {
|
||||||
|
classroomSyncService service.EmptyClassroomSyncService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEmptyClassroomHandler(
|
||||||
|
classroomSyncService service.EmptyClassroomSyncService,
|
||||||
|
) *EmptyClassroomHandler {
|
||||||
|
return &EmptyClassroomHandler{
|
||||||
|
classroomSyncService: classroomSyncService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type syncEmptyClassroomsRequest struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
Semester string `json:"semester"`
|
||||||
|
WeekStart string `json:"week_start"`
|
||||||
|
WeekEnd string `json:"week_end"`
|
||||||
|
CampusCode string `json:"campus_code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *EmptyClassroomHandler) SyncEmptyClassrooms(c *gin.Context) {
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
response.Unauthorized(c, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req syncEmptyClassroomsRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "invalid request: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.classroomSyncService.SyncUserEmptyClassrooms(c.Request.Context(), userID, &service.SyncEmptyClassroomsRequest{
|
||||||
|
Username: req.Username,
|
||||||
|
Password: req.Password,
|
||||||
|
Semester: req.Semester,
|
||||||
|
WeekStart: req.WeekStart,
|
||||||
|
WeekEnd: req.WeekEnd,
|
||||||
|
CampusCode: req.CampusCode,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.HandleError(c, err, "failed to sync empty classrooms")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.Success {
|
||||||
|
response.Success(c, result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.SuccessWithMessage(c, result.Message, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *EmptyClassroomHandler) ListEmptyClassrooms(c *gin.Context) {
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
response.Unauthorized(c, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
semester := c.Query("semester")
|
||||||
|
if semester == "" {
|
||||||
|
response.BadRequest(c, "semester is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
classrooms, err := h.classroomSyncService.ListClassrooms(userID, semester)
|
||||||
|
if err != nil {
|
||||||
|
response.HandleError(c, err, "failed to list empty classrooms")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, gin.H{"classrooms": classrooms})
|
||||||
|
}
|
||||||
@@ -4,19 +4,16 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"with_you/internal/pkg/response"
|
"with_you/internal/pkg/response"
|
||||||
"with_you/internal/repository"
|
|
||||||
"with_you/internal/service"
|
"with_you/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ExamHandler struct {
|
type ExamHandler struct {
|
||||||
examSyncService service.ExamSyncService
|
examSyncService service.ExamSyncService
|
||||||
examRepo repository.ExamRepository
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewExamHandler(examSyncService service.ExamSyncService, examRepo repository.ExamRepository) *ExamHandler {
|
func NewExamHandler(examSyncService service.ExamSyncService) *ExamHandler {
|
||||||
return &ExamHandler{
|
return &ExamHandler{
|
||||||
examSyncService: examSyncService,
|
examSyncService: examSyncService,
|
||||||
examRepo: examRepo,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +65,7 @@ func (h *ExamHandler) ListExams(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
exams, err := h.examRepo.ListByUserAndSemester(userID, semester)
|
exams, err := h.examSyncService.ListExams(userID, semester)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.HandleError(c, err, "failed to list exams")
|
response.HandleError(c, err, "failed to list exams")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -4,19 +4,16 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"with_you/internal/pkg/response"
|
"with_you/internal/pkg/response"
|
||||||
"with_you/internal/repository"
|
|
||||||
"with_you/internal/service"
|
"with_you/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
type GradeHandler struct {
|
type GradeHandler struct {
|
||||||
gradeSyncService service.GradeSyncService
|
gradeSyncService service.GradeSyncService
|
||||||
gradeRepo repository.GradeRepository
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGradeHandler(gradeSyncService service.GradeSyncService, gradeRepo repository.GradeRepository) *GradeHandler {
|
func NewGradeHandler(gradeSyncService service.GradeSyncService) *GradeHandler {
|
||||||
return &GradeHandler{
|
return &GradeHandler{
|
||||||
gradeSyncService: gradeSyncService,
|
gradeSyncService: gradeSyncService,
|
||||||
gradeRepo: gradeRepo,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,22 +59,15 @@ func (h *GradeHandler) ListGrades(c *gin.Context) {
|
|||||||
response.Unauthorized(c, "")
|
response.Unauthorized(c, "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
semester := c.Query("semester")
|
|
||||||
if semester == "" {
|
|
||||||
response.BadRequest(c, "semester is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
grades, err := h.gradeRepo.ListByUserAndSemester(userID, semester)
|
summary, err := h.gradeSyncService.ListGradesWithSummary(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.HandleError(c, err, "failed to list grades")
|
response.HandleError(c, err, "failed to list grades")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
gpaSummary, _ := h.gradeRepo.GetGpaSummary(userID, semester)
|
|
||||||
|
|
||||||
response.Success(c, gin.H{
|
response.Success(c, gin.H{
|
||||||
"grades": grades,
|
"grades": summary.Grades,
|
||||||
"gpa_summary": gpaSummary,
|
"gpa_summary": summary.GpaSummary,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -838,6 +838,8 @@ func (h *GroupHandler) HandleCreateAnnouncement(c *gin.Context) {
|
|||||||
|
|
||||||
// HandleGetAnnouncements 获取群公告列表
|
// HandleGetAnnouncements 获取群公告列表
|
||||||
// GET /api/v1/groups/:id/announcements
|
// GET /api/v1/groups/:id/announcements
|
||||||
|
//
|
||||||
|
// 可见性策略:仅群成员可见。非成员返回 403 NOT_GROUP_MEMBER。
|
||||||
func (h *GroupHandler) HandleGetAnnouncements(c *gin.Context) {
|
func (h *GroupHandler) HandleGetAnnouncements(c *gin.Context) {
|
||||||
userID := parseUserID(c)
|
userID := parseUserID(c)
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
@@ -851,6 +853,12 @@ func (h *GroupHandler) HandleGetAnnouncements(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 成员校验:公告仅群成员可见。
|
||||||
|
if !h.groupService.IsGroupMember(userID, groupID) {
|
||||||
|
response.Forbidden(c, "不是群成员")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
@@ -1387,7 +1395,9 @@ func (h *GroupHandler) GetMembersByCursor(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetAnnouncementsByCursor 游标分页获取群公告列表
|
// GetAnnouncementsByCursor 游标分页获取群公告列表
|
||||||
// GET /api/groups/:id/announcements/cursor
|
// GET /api/v1/groups/:id/announcements/cursor
|
||||||
|
//
|
||||||
|
// 可见性策略:仅群成员可见。非成员返回 403 NOT_GROUP_MEMBER。
|
||||||
func (h *GroupHandler) GetAnnouncementsByCursor(c *gin.Context) {
|
func (h *GroupHandler) GetAnnouncementsByCursor(c *gin.Context) {
|
||||||
userID := parseUserID(c)
|
userID := parseUserID(c)
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
@@ -1401,6 +1411,12 @@ func (h *GroupHandler) GetAnnouncementsByCursor(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 成员校验
|
||||||
|
if !h.groupService.IsGroupMember(userID, groupID) {
|
||||||
|
response.Forbidden(c, "不是群成员")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 解析游标分页请求
|
// 解析游标分页请求
|
||||||
req := h.parseCursorRequest(c)
|
req := h.parseCursorRequest(c)
|
||||||
|
|
||||||
@@ -1937,6 +1953,8 @@ func (h *GroupHandler) HandleRespondInvite(c *gin.Context) {
|
|||||||
|
|
||||||
// HandleGetGroupInfo 获取群信息
|
// HandleGetGroupInfo 获取群信息
|
||||||
// GET /api/v1/groups/:id
|
// GET /api/v1/groups/:id
|
||||||
|
//
|
||||||
|
// 可见性策略:成员返回完整详情;非成员仅返回公开字段(name/avatar/description/member_count)。
|
||||||
func (h *GroupHandler) HandleGetGroupInfo(c *gin.Context) {
|
func (h *GroupHandler) HandleGetGroupInfo(c *gin.Context) {
|
||||||
userID := parseUserID(c)
|
userID := parseUserID(c)
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
@@ -1967,11 +1985,26 @@ func (h *GroupHandler) HandleGetGroupInfo(c *gin.Context) {
|
|||||||
resp := dto.GroupToResponse(group)
|
resp := dto.GroupToResponse(group)
|
||||||
resp.MemberCount = memberCount
|
resp.MemberCount = memberCount
|
||||||
|
|
||||||
|
// 非成员仅返回公开字段:与成员分支同构(仍是 GroupResponse),但清空敏感字段。
|
||||||
|
// 与成员分支共用 GroupResponse 结构,依赖完整字段的客户端解析不会因字段缺失出错。
|
||||||
|
if !h.groupService.IsGroupMember(userID, groupID) {
|
||||||
|
resp.OwnerID = ""
|
||||||
|
resp.MaxMembers = 0
|
||||||
|
resp.JoinType = 0
|
||||||
|
resp.MuteAll = false
|
||||||
|
resp.IsMember = false
|
||||||
|
response.Success(c, resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.IsMember = true
|
||||||
response.Success(c, resp)
|
response.Success(c, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleGetGroupMemberList 获取群成员列表
|
// HandleGetGroupMemberList 获取群成员列表
|
||||||
// GET /api/v1/groups/:id/members
|
// GET /api/v1/groups/:id/members
|
||||||
|
//
|
||||||
|
// 可见性策略:仅群成员可见。非成员返回 403 NOT_GROUP_MEMBER。
|
||||||
func (h *GroupHandler) HandleGetGroupMemberList(c *gin.Context) {
|
func (h *GroupHandler) HandleGetGroupMemberList(c *gin.Context) {
|
||||||
userID := parseUserID(c)
|
userID := parseUserID(c)
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
@@ -1988,8 +2021,13 @@ func (h *GroupHandler) HandleGetGroupMemberList(c *gin.Context) {
|
|||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||||
|
|
||||||
members, total, err := h.groupService.GetMembers(groupID, page, pageSize)
|
// 成员校验在 service 层完成。
|
||||||
|
members, total, err := h.groupService.GetMembersForUser(userID, groupID, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrNotGroupMember) {
|
||||||
|
response.Forbidden(c, "不是群成员")
|
||||||
|
return
|
||||||
|
}
|
||||||
if errors.Is(err, service.ErrGroupNotFound) {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
|
|||||||
166
internal/handler/livekit_handler.go
Normal file
166
internal/handler/livekit_handler.go
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/livekit/protocol/livekit"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"with_you/internal/config"
|
||||||
|
"with_you/internal/pkg/response"
|
||||||
|
"with_you/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LiveKitHandler LiveKit 令牌和 Webhook 处理器
|
||||||
|
type LiveKitHandler struct {
|
||||||
|
liveKitService service.LiveKitService
|
||||||
|
callService service.CallService
|
||||||
|
config *config.LiveKitConfig
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLiveKitHandler 创建 LiveKit 处理器
|
||||||
|
func NewLiveKitHandler(
|
||||||
|
liveKitService service.LiveKitService,
|
||||||
|
callService service.CallService,
|
||||||
|
cfg *config.Config,
|
||||||
|
logger *zap.Logger,
|
||||||
|
) *LiveKitHandler {
|
||||||
|
return &LiveKitHandler{
|
||||||
|
liveKitService: liveKitService,
|
||||||
|
callService: callService,
|
||||||
|
config: &cfg.LiveKit,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetToken 生成 LiveKit 访问令牌
|
||||||
|
// GET /api/v1/calls/token?room=<callID>
|
||||||
|
func (h *LiveKitHandler) GetToken(c *gin.Context) {
|
||||||
|
if !h.config.Enabled {
|
||||||
|
response.Forbidden(c, "livekit is not enabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
response.Unauthorized(c, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
room := c.Query("room")
|
||||||
|
if room == "" {
|
||||||
|
response.BadRequest(c, "room parameter is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证用户是该通话的参与者
|
||||||
|
activeCall := h.callService.GetActiveCall(c.Request.Context(), room)
|
||||||
|
if activeCall == nil {
|
||||||
|
response.NotFound(c, "call not found or already ended")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isParticipant := false
|
||||||
|
for _, p := range activeCall.Participants {
|
||||||
|
if p.UserID == userID.(string) {
|
||||||
|
isParticipant = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isParticipant && activeCall.CallerID != userID.(string) && activeCall.CalleeID != userID.(string) {
|
||||||
|
response.Forbidden(c, "not a participant of this call")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
canPublish := true
|
||||||
|
canSubscribe := true
|
||||||
|
if cp := c.Query("can_publish"); cp != "" {
|
||||||
|
canPublish, _ = strconv.ParseBool(cp)
|
||||||
|
}
|
||||||
|
if cs := c.Query("can_subscribe"); cs != "" {
|
||||||
|
canSubscribe, _ = strconv.ParseBool(cs)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := h.liveKitService.GenerateToken(room, userID.(string), canPublish, canSubscribe)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to generate livekit token", zap.Error(err))
|
||||||
|
response.InternalServerError(c, "failed to generate token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, gin.H{
|
||||||
|
"token": token,
|
||||||
|
"url": h.config.URL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleWebhook 处理 LiveKit Webhook 回调
|
||||||
|
// POST /api/v1/calls/webhook
|
||||||
|
func (h *LiveKitHandler) HandleWebhook(c *gin.Context) {
|
||||||
|
if !h.config.Enabled {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "livekit is not enabled"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to read webhook body", zap.Error(err))
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
event, err := h.liveKitService.ProcessWebhook(c.Request.Context(), body, authHeader)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to process webhook", zap.Error(err))
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid webhook"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 webhook 事件类型处理
|
||||||
|
switch event.Event {
|
||||||
|
case "room_finished":
|
||||||
|
h.handleRoomFinished(c.Request.Context(), event)
|
||||||
|
case "participant_left":
|
||||||
|
identity := event.Participant.GetIdentity()
|
||||||
|
roomName := event.Room.GetName()
|
||||||
|
h.logger.Info("participant left room",
|
||||||
|
zap.String("room", roomName),
|
||||||
|
zap.String("identity", identity),
|
||||||
|
)
|
||||||
|
if identity != "" && roomName != "" {
|
||||||
|
if err := h.callService.ParticipantLeave(c.Request.Context(), roomName, identity, "left_livekit"); err != nil {
|
||||||
|
h.logger.Warn("failed to handle participant_left",
|
||||||
|
zap.String("room", roomName),
|
||||||
|
zap.String("identity", identity),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRoomFinished 处理 room_finished 事件,作为通话历史持久化的安全兜底
|
||||||
|
func (h *LiveKitHandler) handleRoomFinished(ctx context.Context, event *livekit.WebhookEvent) {
|
||||||
|
roomName := event.Room.GetName()
|
||||||
|
if roomName == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试结束通话(如果尚未结束)
|
||||||
|
// callService.End 会检查调用是否仍然活跃,如果已经结束则返回 nil
|
||||||
|
_, err := h.callService.End(ctx, roomName, "", "room_finished")
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Debug("room_finished: call end returned error (may already be ended)",
|
||||||
|
zap.String("room", roomName),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"with_you/internal/dto"
|
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
"with_you/internal/pkg/response"
|
"with_you/internal/pkg/response"
|
||||||
|
"with_you/internal/query"
|
||||||
"with_you/internal/service"
|
"with_you/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -148,7 +148,7 @@ func (h *MaterialHandler) ListMaterials(c *gin.Context) {
|
|||||||
fileType := c.Query("file_type")
|
fileType := c.Query("file_type")
|
||||||
keyword := c.Query("keyword")
|
keyword := c.Query("keyword")
|
||||||
|
|
||||||
params := dto.MaterialFileQueryParams{
|
params := query.MaterialFileQueryParams{
|
||||||
SubjectID: subjectID,
|
SubjectID: subjectID,
|
||||||
FileType: fileType,
|
FileType: fileType,
|
||||||
Keyword: keyword,
|
Keyword: keyword,
|
||||||
@@ -329,7 +329,7 @@ func (h *MaterialHandler) AdminListMaterials(c *gin.Context) {
|
|||||||
status := c.Query("status")
|
status := c.Query("status")
|
||||||
keyword := c.Query("keyword")
|
keyword := c.Query("keyword")
|
||||||
|
|
||||||
params := dto.MaterialFileQueryParams{
|
params := query.MaterialFileQueryParams{
|
||||||
SubjectID: subjectID,
|
SubjectID: subjectID,
|
||||||
FileType: fileType,
|
FileType: fileType,
|
||||||
Status: status,
|
Status: status,
|
||||||
|
|||||||
@@ -87,23 +87,40 @@ func (h *MessageHandler) enrichConversations(ctx context.Context, convs []*model
|
|||||||
// MessageHandler 消息处理器
|
// MessageHandler 消息处理器
|
||||||
type MessageHandler struct {
|
type MessageHandler struct {
|
||||||
chatService service.ChatService
|
chatService service.ChatService
|
||||||
messageService *service.MessageService
|
messageService service.MessageService
|
||||||
userService service.UserService
|
userService service.UserService
|
||||||
groupService service.GroupService
|
groupService service.GroupService
|
||||||
|
uploadService service.UploadService
|
||||||
wsPublisher ws.MessagePublisher
|
wsPublisher ws.MessagePublisher
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMessageHandler 创建消息处理器
|
// NewMessageHandler 创建消息处理器
|
||||||
func NewMessageHandler(chatService service.ChatService, messageService *service.MessageService, userService service.UserService, groupService service.GroupService, wsPublisher ws.MessagePublisher) *MessageHandler {
|
func NewMessageHandler(chatService service.ChatService, messageService service.MessageService, userService service.UserService, groupService service.GroupService, uploadService service.UploadService, wsPublisher ws.MessagePublisher) *MessageHandler {
|
||||||
return &MessageHandler{
|
return &MessageHandler{
|
||||||
chatService: chatService,
|
chatService: chatService,
|
||||||
messageService: messageService,
|
messageService: messageService,
|
||||||
userService: userService,
|
userService: userService,
|
||||||
groupService: groupService,
|
groupService: groupService,
|
||||||
|
uploadService: uploadService,
|
||||||
wsPublisher: wsPublisher,
|
wsPublisher: wsPublisher,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// convertMessagesWithExpiry 将消息列表转换为响应,并对已过期的文件 segment 注入 expired 标记。
|
||||||
|
// 若 uploadService 不可用或查询失败,降级为不带过期标记的普通转换。
|
||||||
|
func (h *MessageHandler) convertMessagesWithExpiry(ctx context.Context, messages []*model.Message) []*dto.MessageResponse {
|
||||||
|
var expiredSet map[string]struct{}
|
||||||
|
if h.uploadService != nil {
|
||||||
|
if es, err := h.uploadService.GetExpiredURLSet(ctx); err == nil {
|
||||||
|
expiredSet = es
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(expiredSet) == 0 {
|
||||||
|
return dto.ConvertMessagesToResponse(messages)
|
||||||
|
}
|
||||||
|
return dto.ConvertMessagesToResponseWithExpiry(messages, expiredSet)
|
||||||
|
}
|
||||||
|
|
||||||
// HandleTyping 输入状态上报
|
// HandleTyping 输入状态上报
|
||||||
// POST /api/v1/conversations/typing
|
// POST /api/v1/conversations/typing
|
||||||
func (h *MessageHandler) HandleTyping(c *gin.Context) {
|
func (h *MessageHandler) HandleTyping(c *gin.Context) {
|
||||||
@@ -287,8 +304,8 @@ func (h *MessageHandler) GetMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式(标记已过期文件)
|
||||||
result := dto.ConvertMessagesToResponse(messages)
|
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||||
|
|
||||||
response.Success(c, &dto.MessageSyncResponse{
|
response.Success(c, &dto.MessageSyncResponse{
|
||||||
Messages: result,
|
Messages: result,
|
||||||
@@ -315,8 +332,8 @@ func (h *MessageHandler) GetMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式(标记已过期文件)
|
||||||
result := dto.ConvertMessagesToResponse(messages)
|
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||||
|
|
||||||
response.Success(c, &dto.MessageSyncResponse{
|
response.Success(c, &dto.MessageSyncResponse{
|
||||||
Messages: result,
|
Messages: result,
|
||||||
@@ -335,12 +352,43 @@ func (h *MessageHandler) GetMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式(标记已过期文件)
|
||||||
result := dto.ConvertMessagesToResponse(messages)
|
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||||
|
|
||||||
response.Paginated(c, result, total, page, pageSize)
|
response.Paginated(c, result, total, page, pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetReplyMessage 按消息 ID 获取被引用消息(用于引用预览回填/跳转)
|
||||||
|
// GET /api/v1/messages/:id
|
||||||
|
func (h *MessageHandler) GetReplyMessage(c *gin.Context) {
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
response.Unauthorized(c, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
messageID := c.Param("id")
|
||||||
|
if messageID == "" {
|
||||||
|
response.BadRequest(c, "message id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
message, err := h.chatService.GetReplyMessage(c.Request.Context(), messageID, userID)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为响应格式(标记已过期文件 / 撤回占位等,保持与列表接口一致)
|
||||||
|
result := h.convertMessagesWithExpiry(c.Request.Context(), []*model.Message{message})
|
||||||
|
if len(result) == 0 {
|
||||||
|
response.InternalServerError(c, "failed to convert message")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, result[0])
|
||||||
|
}
|
||||||
|
|
||||||
// SendMessage 发送消息
|
// SendMessage 发送消息
|
||||||
// POST /api/conversations/:id/messages
|
// POST /api/conversations/:id/messages
|
||||||
func (h *MessageHandler) SendMessage(c *gin.Context) {
|
func (h *MessageHandler) SendMessage(c *gin.Context) {
|
||||||
@@ -418,7 +466,7 @@ func (h *MessageHandler) HandleSendMessage(c *gin.Context) {
|
|||||||
Type: "message",
|
Type: "message",
|
||||||
DetailType: params.DetailType,
|
DetailType: params.DetailType,
|
||||||
ConversationID: conversationID,
|
ConversationID: conversationID,
|
||||||
Seq: strconv.FormatInt(msg.Seq, 10),
|
Seq: msg.Seq,
|
||||||
Segments: params.Segments,
|
Segments: params.Segments,
|
||||||
SenderID: userID,
|
SenderID: userID,
|
||||||
}
|
}
|
||||||
@@ -585,6 +633,37 @@ func (h *MessageHandler) HandleGetSyncData(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleGetSyncByVersion 增量同步:按版本号获取会话变更
|
||||||
|
// GET /api/v1/conversations/sync?version=0&limit=100
|
||||||
|
func (h *MessageHandler) HandleGetSyncByVersion(c *gin.Context) {
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
response.Unauthorized(c, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
versionStr := c.DefaultQuery("version", "0")
|
||||||
|
sinceVersion, err := strconv.ParseInt(versionStr, 10, 64)
|
||||||
|
if err != nil || sinceVersion < 0 {
|
||||||
|
response.BadRequest(c, "invalid version parameter")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limitStr := c.DefaultQuery("limit", "100")
|
||||||
|
limit, err := strconv.Atoi(limitStr)
|
||||||
|
if err != nil || limit < 1 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.chatService.GetSyncByVersion(c.Request.Context(), userID, sinceVersion, limit)
|
||||||
|
if err != nil {
|
||||||
|
response.InternalServerError(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
// GetConversationUnreadCount 获取单个会话的未读数
|
// GetConversationUnreadCount 获取单个会话的未读数
|
||||||
// GET /api/conversations/:id/unread/count
|
// GET /api/conversations/:id/unread/count
|
||||||
func (h *MessageHandler) GetConversationUnreadCount(c *gin.Context) {
|
func (h *MessageHandler) GetConversationUnreadCount(c *gin.Context) {
|
||||||
@@ -826,8 +905,8 @@ func (h *MessageHandler) HandleGetMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式(标记已过期文件)
|
||||||
result := dto.ConvertMessagesToResponse(messages)
|
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||||
|
|
||||||
response.Success(c, &dto.MessageSyncResponse{
|
response.Success(c, &dto.MessageSyncResponse{
|
||||||
Messages: result,
|
Messages: result,
|
||||||
@@ -854,8 +933,8 @@ func (h *MessageHandler) HandleGetMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式(标记已过期文件)
|
||||||
result := dto.ConvertMessagesToResponse(messages)
|
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||||
|
|
||||||
response.Success(c, &dto.MessageSyncResponse{
|
response.Success(c, &dto.MessageSyncResponse{
|
||||||
Messages: result,
|
Messages: result,
|
||||||
@@ -874,8 +953,8 @@ func (h *MessageHandler) HandleGetMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式(标记已过期文件)
|
||||||
result := dto.ConvertMessagesToResponse(messages)
|
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||||
|
|
||||||
response.Paginated(c, result, total, page, pageSize)
|
response.Paginated(c, result, total, page, pageSize)
|
||||||
}
|
}
|
||||||
@@ -1029,15 +1108,15 @@ func (h *MessageHandler) GetMessagesByCursor(c *gin.Context) {
|
|||||||
// 解析游标分页参数
|
// 解析游标分页参数
|
||||||
pageReq := parseCursorPageRequest(c)
|
pageReq := parseCursorPageRequest(c)
|
||||||
|
|
||||||
// 调用服务层
|
// 调用服务层(内部会校验 currentUserID 是否是会话参与者,否则拒绝)
|
||||||
result, err := h.messageService.GetMessagesByCursor(c.Request.Context(), conversationID, pageReq)
|
result, err := h.messageService.GetMessagesByCursor(c.Request.Context(), conversationID, userID, pageReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to get messages")
|
response.HandleError(c, err, "failed to get messages")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式(标记已过期文件)
|
||||||
items := dto.ConvertMessagesToResponse(result.Items)
|
items := h.convertMessagesWithExpiry(c.Request.Context(), result.Items)
|
||||||
|
|
||||||
response.Success(c, &dto.MessageCursorPageResponse{
|
response.Success(c, &dto.MessageCursorPageResponse{
|
||||||
Items: items,
|
Items: items,
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ import (
|
|||||||
|
|
||||||
// NotificationHandler 通知处理器
|
// NotificationHandler 通知处理器
|
||||||
type NotificationHandler struct {
|
type NotificationHandler struct {
|
||||||
notificationService *service.NotificationService
|
notificationService service.NotificationService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewNotificationHandler 创建通知处理器
|
// NewNotificationHandler 创建通知处理器
|
||||||
func NewNotificationHandler(notificationService *service.NotificationService) *NotificationHandler {
|
func NewNotificationHandler(notificationService service.NotificationService) *NotificationHandler {
|
||||||
return &NotificationHandler{
|
return &NotificationHandler{
|
||||||
notificationService: notificationService,
|
notificationService: notificationService,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -105,11 +106,11 @@ func (h *PostHandler) Create(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
post, err := h.postService.Create(c.Request.Context(), userID, req.Title, content, segments, req.Images, req.ChannelID)
|
post, err := h.postService.Create(c.Request.Context(), userID, req.Title, content, segments, req.Images, req.ChannelID, req.ClientRequestID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var moderationErr *service.PostModerationRejectedError
|
// 幂等命中"进行中"占位:提示客户端稍候,避免重复提交
|
||||||
if errors.As(err, &moderationErr) {
|
if errors.Is(err, service.ErrDuplicatePostRequest) {
|
||||||
response.BadRequest(c, moderationErr.UserMessage())
|
response.ErrorWithStatusCode(c, http.StatusTooManyRequests, 429, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.InternalServerError(c, "failed to create post")
|
response.InternalServerError(c, "failed to create post")
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ import (
|
|||||||
|
|
||||||
// QRCodeHandler 二维码登录处理器
|
// QRCodeHandler 二维码登录处理器
|
||||||
type QRCodeHandler struct {
|
type QRCodeHandler struct {
|
||||||
qrcodeService *service.QRCodeLoginService
|
qrcodeService service.QRCodeLoginService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewQRCodeHandler 创建二维码登录处理器
|
// NewQRCodeHandler 创建二维码登录处理器
|
||||||
func NewQRCodeHandler(qrcodeService *service.QRCodeLoginService) *QRCodeHandler {
|
func NewQRCodeHandler(qrcodeService service.QRCodeLoginService) *QRCodeHandler {
|
||||||
return &QRCodeHandler{
|
return &QRCodeHandler{
|
||||||
qrcodeService: qrcodeService,
|
qrcodeService: qrcodeService,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import (
|
|||||||
|
|
||||||
// UploadHandler 上传处理器
|
// UploadHandler 上传处理器
|
||||||
type UploadHandler struct {
|
type UploadHandler struct {
|
||||||
uploadService *service.UploadService
|
uploadService service.UploadService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUploadHandler 创建上传处理器
|
// NewUploadHandler 创建上传处理器
|
||||||
func NewUploadHandler(uploadService *service.UploadService) *UploadHandler {
|
func NewUploadHandler(uploadService service.UploadService) *UploadHandler {
|
||||||
return &UploadHandler{
|
return &UploadHandler{
|
||||||
uploadService: uploadService,
|
uploadService: uploadService,
|
||||||
}
|
}
|
||||||
@@ -92,3 +92,43 @@ func (h *UploadHandler) UploadCover(c *gin.Context) {
|
|||||||
|
|
||||||
response.Success(c, gin.H{"url": url})
|
response.Success(c, gin.H{"url": url})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadFile 上传聊天/通用文件(非图片类资源)
|
||||||
|
// form 字段:file(必填)、folder(可选,默认 chat)
|
||||||
|
func (h *UploadHandler) UploadFile(c *gin.Context) {
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
response.Unauthorized(c, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "file is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
folder := c.DefaultPostForm("folder", "chat")
|
||||||
|
// 前端(尤其原生端 expo-file-system)无法自定义 multipart filename,
|
||||||
|
// 文件常被复制到缓存目录并以 UUID 命名,导致 header Filename 丢失真实名。
|
||||||
|
// 因此前端额外把真实文件名通过 "name" 字段回传,作为权威展示名。
|
||||||
|
displayName := c.PostForm("name")
|
||||||
|
|
||||||
|
result, err := h.uploadService.UploadFile(c.Request.Context(), file, folder, userID, displayName)
|
||||||
|
if err != nil {
|
||||||
|
// 参数类错误(大小/类型)返回 400,其余返回 500
|
||||||
|
if file.Size > 0 && file.Size <= 100<<20 {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.InternalServerError(c, "failed to upload file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, gin.H{
|
||||||
|
"url": result.URL,
|
||||||
|
"name": result.Name,
|
||||||
|
"size": result.Size,
|
||||||
|
"mime_type": result.MimeType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,12 +6,17 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"with_you/internal/cache"
|
||||||
"with_you/internal/dto"
|
"with_you/internal/dto"
|
||||||
"with_you/internal/middleware"
|
"with_you/internal/middleware"
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
|
apperrors "with_you/internal/errors"
|
||||||
|
"with_you/internal/pkg/auth"
|
||||||
"with_you/internal/pkg/response"
|
"with_you/internal/pkg/response"
|
||||||
"with_you/internal/service"
|
"with_you/internal/service"
|
||||||
)
|
)
|
||||||
@@ -36,7 +41,9 @@ type UserHandler struct {
|
|||||||
userService service.UserService
|
userService service.UserService
|
||||||
activityService service.UserActivityService
|
activityService service.UserActivityService
|
||||||
profileAuditService service.UserProfileAuditService
|
profileAuditService service.UserProfileAuditService
|
||||||
jwtService *service.JWTService
|
jwtService service.JWTService
|
||||||
|
sessionService service.SessionService
|
||||||
|
cache cache.Cache
|
||||||
logService *service.LogService
|
logService *service.LogService
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +60,7 @@ func (h *UserHandler) SetProfileAuditService(profileAuditService service.UserPro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetJWTService 设置JWT服务
|
// SetJWTService 设置JWT服务
|
||||||
func (h *UserHandler) SetJWTService(jwtService *service.JWTService) {
|
func (h *UserHandler) SetJWTService(jwtService service.JWTService) {
|
||||||
h.jwtService = jwtService
|
h.jwtService = jwtService
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +69,16 @@ func (h *UserHandler) SetActivityService(activityService service.UserActivitySer
|
|||||||
h.activityService = activityService
|
h.activityService = activityService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSessionService 设置会话服务(用于登出与令牌轮换)。
|
||||||
|
func (h *UserHandler) SetSessionService(sessionService service.SessionService) {
|
||||||
|
h.sessionService = sessionService
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCache 注入缓存实例(用于登出时失效 Principal/Session 缓存)。
|
||||||
|
func (h *UserHandler) SetCache(c cache.Cache) {
|
||||||
|
h.cache = c
|
||||||
|
}
|
||||||
|
|
||||||
// generateTokenID 生成Token ID
|
// generateTokenID 生成Token ID
|
||||||
func generateTokenID(token string) string {
|
func generateTokenID(token string) string {
|
||||||
h := sha256.New()
|
h := sha256.New()
|
||||||
@@ -92,12 +109,16 @@ func (h *UserHandler) Register(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成Token
|
// 签发带会话的令牌对:sid 写入 JWT,支持登出/封禁后撤销。
|
||||||
accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username)
|
|
||||||
refreshToken, _ := h.jwtService.GenerateRefreshToken(user.ID, user.Username)
|
|
||||||
|
|
||||||
ip := c.ClientIP()
|
ip := c.ClientIP()
|
||||||
userAgent := c.GetHeader("User-Agent")
|
userAgent := c.GetHeader("User-Agent")
|
||||||
|
refreshExpire := time.Duration(7*24) * time.Hour
|
||||||
|
_, accessToken, refreshToken, err := h.sessionService.Issue(c.Request.Context(), user.ID, user.Username, userAgent, ip, refreshExpire)
|
||||||
|
if err != nil {
|
||||||
|
// 会话签发失败视为登录不可用,避免签发无 sid 的旧式令牌。
|
||||||
|
response.InternalServerError(c, "failed to issue session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 记录用户活跃
|
// 记录用户活跃
|
||||||
if h.activityService != nil {
|
if h.activityService != nil {
|
||||||
@@ -161,12 +182,15 @@ func (h *UserHandler) Login(c *gin.Context) {
|
|||||||
|
|
||||||
middleware.ResetLoginFailures(c.ClientIP())
|
middleware.ResetLoginFailures(c.ClientIP())
|
||||||
|
|
||||||
// 生成Token
|
// 签发带会话的令牌对。
|
||||||
accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username)
|
|
||||||
refreshToken, _ := h.jwtService.GenerateRefreshToken(user.ID, user.Username)
|
|
||||||
|
|
||||||
ip := c.ClientIP()
|
ip := c.ClientIP()
|
||||||
userAgent := c.GetHeader("User-Agent")
|
userAgent := c.GetHeader("User-Agent")
|
||||||
|
refreshExpire := time.Duration(7*24) * time.Hour
|
||||||
|
_, accessToken, refreshToken, err := h.sessionService.Issue(c.Request.Context(), user.ID, user.Username, userAgent, ip, refreshExpire)
|
||||||
|
if err != nil {
|
||||||
|
response.InternalServerError(c, "failed to issue session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 记录用户活跃
|
// 记录用户活跃
|
||||||
if h.activityService != nil {
|
if h.activityService != nil {
|
||||||
@@ -179,8 +203,6 @@ func (h *UserHandler) Login(c *gin.Context) {
|
|||||||
|
|
||||||
// 更新登录日志(补充IP和UserAgent)
|
// 更新登录日志(补充IP和UserAgent)
|
||||||
if h.logService != nil {
|
if h.logService != nil {
|
||||||
ip := c.ClientIP()
|
|
||||||
userAgent := c.GetHeader("User-Agent")
|
|
||||||
go func() {
|
go func() {
|
||||||
h.logService.LoginLog.RecordLogin(&model.LoginLog{
|
h.logService.LoginLog.RecordLogin(&model.LoginLog{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
@@ -463,6 +485,11 @@ func (h *UserHandler) VerifyEmail(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RefreshToken 刷新Token
|
// RefreshToken 刷新Token
|
||||||
|
//
|
||||||
|
// 安全语义:
|
||||||
|
// - 仅接受 refresh token(jwt.ParseRefreshToken 严格校验 typ=refresh)。
|
||||||
|
// - 通过 SessionService 校验会话未撤销未过期,并轮换会话(旧 refresh token 失效)。
|
||||||
|
// - 不接受 access token,access token 不能在刷新端点换取新令牌。
|
||||||
func (h *UserHandler) RefreshToken(c *gin.Context) {
|
func (h *UserHandler) RefreshToken(c *gin.Context) {
|
||||||
type RefreshRequest struct {
|
type RefreshRequest struct {
|
||||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||||
@@ -474,26 +501,34 @@ func (h *UserHandler) RefreshToken(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析 refresh token
|
// 获取用户信息用于填充新令牌的 username 声明。
|
||||||
claims, err := h.jwtService.ParseToken(req.RefreshToken)
|
// 先解析 refresh 拿 userID,再查 DB;不直接信任 JWT 中的 username。
|
||||||
|
claims, err := h.jwtService.ParseRefreshToken(req.RefreshToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Unauthorized(c, "invalid refresh token")
|
response.HandleError(c, apperrors.ErrInvalidToken, "invalid refresh token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取用户信息
|
|
||||||
user, err := h.userService.GetUserByID(c.Request.Context(), claims.UserID)
|
user, err := h.userService.GetUserByID(c.Request.Context(), claims.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "user not found")
|
response.HandleError(c, apperrors.ErrUserNotFound, "user not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成新 token
|
if user.Status != model.UserStatusActive && user.Status != model.UserStatusPendingDeletion {
|
||||||
accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username)
|
response.HandleError(c, apperrors.ErrUserBanned, "user banned")
|
||||||
refreshToken, _ := h.jwtService.GenerateRefreshToken(user.ID, user.Username)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ip := c.ClientIP()
|
ip := c.ClientIP()
|
||||||
userAgent := c.GetHeader("User-Agent")
|
userAgent := c.GetHeader("User-Agent")
|
||||||
|
refreshExpire := time.Duration(7*24) * time.Hour
|
||||||
|
|
||||||
|
_, accessToken, refreshToken, err := h.sessionService.Rotate(c.Request.Context(), req.RefreshToken, user.Username, userAgent, ip, refreshExpire)
|
||||||
|
if err != nil {
|
||||||
|
response.HandleError(c, err, "failed to refresh token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 记录用户活跃
|
// 记录用户活跃
|
||||||
if h.activityService != nil {
|
if h.activityService != nil {
|
||||||
@@ -664,8 +699,9 @@ func (h *UserHandler) GetFollowingList(c *gin.Context) {
|
|||||||
|
|
||||||
page := c.DefaultQuery("page", "1")
|
page := c.DefaultQuery("page", "1")
|
||||||
pageSize := c.DefaultQuery("page_size", "20")
|
pageSize := c.DefaultQuery("page_size", "20")
|
||||||
|
keyword := c.Query("keyword")
|
||||||
|
|
||||||
users, err := h.userService.GetFollowingList(c.Request.Context(), userID, page, pageSize)
|
users, err := h.userService.GetFollowingList(c.Request.Context(), userID, page, pageSize, keyword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to get following list")
|
response.InternalServerError(c, "failed to get following list")
|
||||||
return
|
return
|
||||||
@@ -710,8 +746,9 @@ func (h *UserHandler) GetFollowersList(c *gin.Context) {
|
|||||||
|
|
||||||
page := c.DefaultQuery("page", "1")
|
page := c.DefaultQuery("page", "1")
|
||||||
pageSize := c.DefaultQuery("page_size", "20")
|
pageSize := c.DefaultQuery("page_size", "20")
|
||||||
|
keyword := c.Query("keyword")
|
||||||
|
|
||||||
users, err := h.userService.GetFollowersList(c.Request.Context(), userID, page, pageSize)
|
users, err := h.userService.GetFollowersList(c.Request.Context(), userID, page, pageSize, keyword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to get followers list")
|
response.InternalServerError(c, "failed to get followers list")
|
||||||
return
|
return
|
||||||
@@ -799,9 +836,14 @@ func (h *UserHandler) SendChangePasswordCode(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Logout 用户登出
|
// Logout 用户登出
|
||||||
|
//
|
||||||
|
// 安全语义:撤销当前会话,使 refresh token 立即失效。
|
||||||
|
// access token 在剩余 TTL 内仍可用(无状态 JWT 的固有限制),但 refresh token 已撤销,
|
||||||
|
// 攻击者无法用 refresh token 续期,access token 也会在短 TTL 内自然过期。
|
||||||
|
// 如需 access token 即时失效,可后续引入 jti 黑名单。
|
||||||
func (h *UserHandler) Logout(c *gin.Context) {
|
func (h *UserHandler) Logout(c *gin.Context) {
|
||||||
currentUserID := c.GetString("user_id")
|
principal, ok := auth.GetPrincipal(c)
|
||||||
if currentUserID == "" {
|
if !ok || principal == nil {
|
||||||
response.Unauthorized(c, "not logged in")
|
response.Unauthorized(c, "not logged in")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -809,8 +851,23 @@ func (h *UserHandler) Logout(c *gin.Context) {
|
|||||||
ip := c.ClientIP()
|
ip := c.ClientIP()
|
||||||
userAgent := c.GetHeader("User-Agent")
|
userAgent := c.GetHeader("User-Agent")
|
||||||
|
|
||||||
|
// 撤销当前会话。
|
||||||
|
if h.sessionService != nil && principal.SessionID != "" {
|
||||||
|
if err := h.sessionService.Revoke(c.Request.Context(), principal.SessionID); err != nil {
|
||||||
|
// 撤销失败不阻塞登出流程,但记日志便于排查。
|
||||||
|
zap.L().Warn("failed to revoke session on logout",
|
||||||
|
zap.String("session_id", principal.SessionID),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 失效 Principal/Session 缓存,避免登出后短时间内旧 token 仍命中缓存。
|
||||||
|
auth.InvalidatePrincipalCache(h.cache, principal.UserID)
|
||||||
|
auth.InvalidateSessionCache(h.cache, principal.SessionID)
|
||||||
|
|
||||||
// 获取用户信息
|
// 获取用户信息
|
||||||
user, err := h.userService.GetUserByID(c.Request.Context(), currentUserID)
|
user, err := h.userService.GetUserByID(c.Request.Context(), principal.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "user not found")
|
response.InternalServerError(c, "user not found")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ import (
|
|||||||
|
|
||||||
// VoteHandler 投票处理器
|
// VoteHandler 投票处理器
|
||||||
type VoteHandler struct {
|
type VoteHandler struct {
|
||||||
voteService *service.VoteService
|
voteService service.VoteService
|
||||||
postService service.PostService
|
postService service.PostService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewVoteHandler 创建投票处理器
|
// NewVoteHandler 创建投票处理器
|
||||||
func NewVoteHandler(voteService *service.VoteService, postService service.PostService) *VoteHandler {
|
func NewVoteHandler(voteService service.VoteService, postService service.PostService) *VoteHandler {
|
||||||
return &VoteHandler{
|
return &VoteHandler{
|
||||||
voteService: voteService,
|
voteService: voteService,
|
||||||
postService: postService,
|
postService: postService,
|
||||||
@@ -42,9 +42,9 @@ func (h *VoteHandler) CreateVotePost(c *gin.Context) {
|
|||||||
|
|
||||||
post, err := h.voteService.CreateVotePost(c.Request.Context(), userID, &req)
|
post, err := h.voteService.CreateVotePost(c.Request.Context(), userID, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var moderationErr *service.PostModerationRejectedError
|
// 幂等命中"进行中"占位:提示客户端稍候,避免重复提交
|
||||||
if errors.As(err, &moderationErr) {
|
if errors.Is(err, service.ErrDuplicatePostRequest) {
|
||||||
response.BadRequest(c, moderationErr.UserMessage())
|
response.ErrorWithStatusCode(c, http.StatusTooManyRequests, 429, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.Error(c, http.StatusBadRequest, err.Error())
|
response.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@@ -20,7 +19,6 @@ import (
|
|||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
"with_you/internal/pkg/response"
|
"with_you/internal/pkg/response"
|
||||||
"with_you/internal/pkg/ws"
|
"with_you/internal/pkg/ws"
|
||||||
"with_you/internal/repository"
|
|
||||||
"with_you/internal/service"
|
"with_you/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -70,9 +68,9 @@ type WSHandler struct {
|
|||||||
publisher ws.MessagePublisher
|
publisher ws.MessagePublisher
|
||||||
chatService service.ChatService
|
chatService service.ChatService
|
||||||
groupService service.GroupService
|
groupService service.GroupService
|
||||||
jwtService *service.JWTService
|
jwtService service.JWTService
|
||||||
callService service.CallService
|
callService service.CallService
|
||||||
userRepo repository.UserRepository
|
userService service.UserService
|
||||||
clientSeq uint64
|
clientSeq uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,9 +91,9 @@ func NewWSHandler(
|
|||||||
publisher ws.MessagePublisher,
|
publisher ws.MessagePublisher,
|
||||||
chatService service.ChatService,
|
chatService service.ChatService,
|
||||||
groupService service.GroupService,
|
groupService service.GroupService,
|
||||||
jwtService *service.JWTService,
|
jwtService service.JWTService,
|
||||||
callService service.CallService,
|
callService service.CallService,
|
||||||
userRepo repository.UserRepository,
|
userService service.UserService,
|
||||||
) *WSHandler {
|
) *WSHandler {
|
||||||
return &WSHandler{
|
return &WSHandler{
|
||||||
publisher: publisher,
|
publisher: publisher,
|
||||||
@@ -103,7 +101,7 @@ func NewWSHandler(
|
|||||||
groupService: groupService,
|
groupService: groupService,
|
||||||
jwtService: jwtService,
|
jwtService: jwtService,
|
||||||
callService: callService,
|
callService: callService,
|
||||||
userRepo: userRepo,
|
userService: userService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +125,8 @@ func (h *WSHandler) HandleWebSocket(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, err := h.jwtService.ParseToken(token)
|
// 仅接受 access token,防止 refresh token 通过 WebSocket 入口绕过限制。
|
||||||
|
claims, err := h.jwtService.ParseAccessToken(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Unauthorized(c, "invalid token")
|
response.Unauthorized(c, "invalid token")
|
||||||
return
|
return
|
||||||
@@ -151,12 +150,14 @@ func (h *WSHandler) HandleWebSocket(c *gin.Context) {
|
|||||||
|
|
||||||
// 3. 创建客户端
|
// 3. 创建客户端
|
||||||
clientID := atomic.AddUint64(&h.clientSeq, 1)
|
clientID := atomic.AddUint64(&h.clientSeq, 1)
|
||||||
|
compressEnabled := c.Query("compress") == "1"
|
||||||
client := &ws.Client{
|
client := &ws.Client{
|
||||||
ID: clientID,
|
ID: clientID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Send: make(chan []byte, defaultUserBufferSize),
|
Send: make(chan []byte, defaultUserBufferSize),
|
||||||
Quit: make(chan struct{}),
|
Quit: make(chan struct{}),
|
||||||
PendingAcks: make(map[string]time.Time),
|
PendingAcks: make(map[string]time.Time),
|
||||||
|
CompressEnabled: compressEnabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 注册客户端
|
// 4. 注册客户端
|
||||||
@@ -211,7 +212,7 @@ func (h *WSHandler) readPump(conn *websocket.Conn, client *ws.Client) {
|
|||||||
conn.Close()
|
conn.Close()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
conn.SetReadLimit(256 * 1024) // 256KB (SDP/ICE candidates can be large)
|
conn.SetReadLimit(256 * 1024) // 256KB
|
||||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
conn.SetPongHandler(func(string) error {
|
conn.SetPongHandler(func(string) error {
|
||||||
zap.L().Debug("WebSocket pong received",
|
zap.L().Debug("WebSocket pong received",
|
||||||
@@ -243,9 +244,21 @@ func (h *WSHandler) readPump(conn *websocket.Conn, client *ws.Client) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析消息
|
// 解析消息(支持 gzip 解压)
|
||||||
|
var rawData []byte
|
||||||
|
if client.CompressEnabled && ws.IsCompressed(message) {
|
||||||
|
decompressed, dErr := ws.DefaultCompressor.Decompress(message)
|
||||||
|
if dErr != nil {
|
||||||
|
h.publisher.SendError(client, "decompress_error", "failed to decompress gzip message")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rawData = decompressed
|
||||||
|
} else {
|
||||||
|
rawData = message
|
||||||
|
}
|
||||||
|
|
||||||
var msg ws.Message
|
var msg ws.Message
|
||||||
if err := json.Unmarshal(message, &msg); err != nil {
|
if err := json.Unmarshal(rawData, &msg); err != nil {
|
||||||
h.publisher.SendError(client, "parse_error", "invalid message format")
|
h.publisher.SendError(client, "parse_error", "invalid message format")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -280,31 +293,60 @@ func (h *WSHandler) writePump(conn *websocket.Conn, client *ws.Client) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w, err := conn.NextWriter(websocket.TextMessage)
|
if client.CompressEnabled {
|
||||||
if err != nil {
|
buf := make([]byte, 0, len(message)+64)
|
||||||
zap.L().Warn("WebSocket write error (NextWriter)",
|
buf = append(buf, message...)
|
||||||
zap.String("user_id", client.UserID),
|
n := len(client.Send)
|
||||||
zap.Uint64("client_id", client.ID),
|
for i := 0; i < n; i++ {
|
||||||
zap.Error(err),
|
buf = append(buf, '\n')
|
||||||
)
|
buf = append(buf, <-client.Send...)
|
||||||
return
|
}
|
||||||
}
|
compressed, cErr := ws.DefaultCompressor.Compress(buf)
|
||||||
w.Write(message)
|
if cErr != nil {
|
||||||
|
zap.L().Warn("WebSocket compress error, sending raw",
|
||||||
|
zap.String("user_id", client.UserID),
|
||||||
|
zap.Error(cErr),
|
||||||
|
)
|
||||||
|
w, err := conn.NextWriter(websocket.TextMessage)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write(buf)
|
||||||
|
w.Close()
|
||||||
|
} else {
|
||||||
|
w, err := conn.NextWriter(websocket.BinaryMessage)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write(compressed)
|
||||||
|
w.Close()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
w, err := conn.NextWriter(websocket.TextMessage)
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Warn("WebSocket write error (NextWriter)",
|
||||||
|
zap.String("user_id", client.UserID),
|
||||||
|
zap.Uint64("client_id", client.ID),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write(message)
|
||||||
|
|
||||||
// 批量发送缓冲区中的消息
|
n := len(client.Send)
|
||||||
n := len(client.Send)
|
for i := 0; i < n; i++ {
|
||||||
for i := 0; i < n; i++ {
|
w.Write([]byte{'\n'})
|
||||||
w.Write([]byte{'\n'})
|
w.Write(<-client.Send)
|
||||||
w.Write(<-client.Send)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if err := w.Close(); err != nil {
|
if err := w.Close(); err != nil {
|
||||||
zap.L().Warn("WebSocket write error (Close)",
|
zap.L().Warn("WebSocket write error (Close)",
|
||||||
zap.String("user_id", client.UserID),
|
zap.String("user_id", client.UserID),
|
||||||
zap.Uint64("client_id", client.ID),
|
zap.Uint64("client_id", client.ID),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
@@ -335,20 +377,22 @@ func (h *WSHandler) handleMessage(client *ws.Client, msg *ws.Message) {
|
|||||||
// 通话信令
|
// 通话信令
|
||||||
case "call_invite":
|
case "call_invite":
|
||||||
h.handleCallInvite(ctx, client, msg.Payload)
|
h.handleCallInvite(ctx, client, msg.Payload)
|
||||||
|
case "call_group_invite":
|
||||||
|
h.handleCallGroupInvite(ctx, client, msg.Payload)
|
||||||
case "call_answer":
|
case "call_answer":
|
||||||
h.handleCallAnswer(ctx, client, msg.Payload)
|
h.handleCallAnswer(ctx, client, msg.Payload)
|
||||||
case "call_reject":
|
case "call_reject":
|
||||||
h.handleCallReject(ctx, client, msg.Payload)
|
h.handleCallReject(ctx, client, msg.Payload)
|
||||||
case "call_busy":
|
case "call_busy":
|
||||||
h.handleCallBusy(ctx, client, msg.Payload)
|
h.handleCallBusy(ctx, client, msg.Payload)
|
||||||
case "call_sdp":
|
case "call_ready":
|
||||||
h.handleCallSDP(ctx, client, msg.Payload)
|
h.handleCallReady(ctx, client, msg.Payload)
|
||||||
case "call_ice":
|
|
||||||
h.handleCallICE(ctx, client, msg.Payload)
|
|
||||||
case "call_end":
|
case "call_end":
|
||||||
h.handleCallEnd(ctx, client, msg.Payload)
|
h.handleCallEnd(ctx, client, msg.Payload)
|
||||||
case "call_mute":
|
case "call_mute":
|
||||||
h.handleCallMute(ctx, client, msg.Payload)
|
h.handleCallMute(ctx, client, msg.Payload)
|
||||||
|
case "call_participant_join":
|
||||||
|
h.handleCallParticipantJoin(ctx, client, msg.Payload)
|
||||||
default:
|
default:
|
||||||
h.publisher.SendError(client, "unknown_type", fmt.Sprintf("unknown message type: %s", msg.Type))
|
h.publisher.SendError(client, "unknown_type", fmt.Sprintf("unknown message type: %s", msg.Type))
|
||||||
}
|
}
|
||||||
@@ -416,7 +460,7 @@ func (h *WSHandler) handleChat(ctx context.Context, client *ws.Client, payload j
|
|||||||
Type: "message",
|
Type: "message",
|
||||||
DetailType: detailType,
|
DetailType: detailType,
|
||||||
ConversationID: req.ConversationID,
|
ConversationID: req.ConversationID,
|
||||||
Seq: strconv.FormatInt(message.Seq, 10),
|
Seq: message.Seq,
|
||||||
Segments: req.Segments,
|
Segments: req.Segments,
|
||||||
SenderID: client.UserID,
|
SenderID: client.UserID,
|
||||||
}
|
}
|
||||||
@@ -534,7 +578,7 @@ func (h *WSHandler) handleAck(client *ws.Client, payload json.RawMessage) {
|
|||||||
|
|
||||||
// isVerified 检查用户是否已通过身份认证
|
// isVerified 检查用户是否已通过身份认证
|
||||||
func (h *WSHandler) isVerified(ctx context.Context, client *ws.Client) bool {
|
func (h *WSHandler) isVerified(ctx context.Context, client *ws.Client) bool {
|
||||||
user, err := h.userRepo.GetByID(client.UserID)
|
user, err := h.userService.GetUserByID(ctx, client.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.publisher.SendError(client, "internal_error", "获取用户信息失败")
|
h.publisher.SendError(client, "internal_error", "获取用户信息失败")
|
||||||
return false
|
return false
|
||||||
@@ -608,6 +652,61 @@ func (h *WSHandler) handleCallInvite(ctx context.Context, client *ws.Client, pay
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleCallGroupInvite 处理群组通话邀请
|
||||||
|
func (h *WSHandler) handleCallGroupInvite(ctx context.Context, client *ws.Client, payload json.RawMessage) {
|
||||||
|
if !h.isVerified(ctx, client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
GroupID string `json:"group_id"`
|
||||||
|
ConversationID string `json:"conversation_id"`
|
||||||
|
MediaType string `json:"call_type"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payload, &req); err != nil {
|
||||||
|
h.publisher.SendError(client, "parse_error", "invalid call_group_invite format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.GroupID == "" || req.ConversationID == "" {
|
||||||
|
h.publisher.SendError(client, "invalid_params", "group_id and conversation_id are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaType := req.MediaType
|
||||||
|
if mediaType != "video" {
|
||||||
|
mediaType = "voice"
|
||||||
|
}
|
||||||
|
|
||||||
|
call, err := h.callService.GroupInvite(ctx, client.UserID, req.GroupID, req.ConversationID, mediaType)
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Warn("Failed to invite group call",
|
||||||
|
zap.String("caller_id", client.UserID),
|
||||||
|
zap.String("group_id", req.GroupID),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
h.publisher.SendError(client, "call_group_invite_failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := ws.ResponseMessage{
|
||||||
|
EventID: h.publisher.NextID(),
|
||||||
|
Type: "call_invited",
|
||||||
|
TS: time.Now().UnixMilli(),
|
||||||
|
Payload: map[string]any{
|
||||||
|
"call_id": call.ID,
|
||||||
|
"conversation_id": req.ConversationID,
|
||||||
|
"group_id": req.GroupID,
|
||||||
|
"call_type": "group",
|
||||||
|
"lifetime": service.CallLifetimeMs,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(resp)
|
||||||
|
select {
|
||||||
|
case <-client.Quit:
|
||||||
|
case client.Send <- data:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// handleCallAnswer 处理接听
|
// handleCallAnswer 处理接听
|
||||||
func (h *WSHandler) handleCallAnswer(ctx context.Context, client *ws.Client, payload json.RawMessage) {
|
func (h *WSHandler) handleCallAnswer(ctx context.Context, client *ws.Client, payload json.RawMessage) {
|
||||||
var req struct {
|
var req struct {
|
||||||
@@ -675,43 +774,18 @@ func (h *WSHandler) handleCallBusy(ctx context.Context, client *ws.Client, paylo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleCallSDP 转发 SDP
|
// handleCallReady 处理通话就绪(LiveKit 房间连接成功)
|
||||||
func (h *WSHandler) handleCallSDP(ctx context.Context, client *ws.Client, payload json.RawMessage) {
|
func (h *WSHandler) handleCallReady(ctx context.Context, client *ws.Client, payload json.RawMessage) {
|
||||||
var req struct {
|
var req struct {
|
||||||
CallID string `json:"call_id"`
|
CallID string `json:"call_id"`
|
||||||
SDPType string `json:"sdp_type"`
|
|
||||||
SDP json.RawMessage `json:"sdp"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" || req.SDPType == "" {
|
|
||||||
h.publisher.SendError(client, "invalid_params", "call_id and sdp_type are required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
signalPayload, _ := json.Marshal(map[string]any{
|
|
||||||
"sdp_type": req.SDPType,
|
|
||||||
"sdp": json.RawMessage(req.SDP),
|
|
||||||
})
|
|
||||||
if err := h.callService.RelaySignal(ctx, req.CallID, client.UserID, "call_sdp", signalPayload); err != nil {
|
|
||||||
h.publisher.SendError(client, "call_sdp_failed", err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleCallICE 转发 ICE candidate
|
|
||||||
func (h *WSHandler) handleCallICE(ctx context.Context, client *ws.Client, payload json.RawMessage) {
|
|
||||||
var req struct {
|
|
||||||
CallID string `json:"call_id"`
|
|
||||||
Candidate json.RawMessage `json:"candidate"`
|
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
||||||
h.publisher.SendError(client, "invalid_params", "call_id is required")
|
h.publisher.SendError(client, "invalid_params", "call_id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
signalPayload, _ := json.Marshal(map[string]any{
|
if err := h.callService.Ready(ctx, req.CallID, client.UserID); err != nil {
|
||||||
"candidate": json.RawMessage(req.Candidate),
|
h.publisher.SendError(client, "call_ready_failed", err.Error())
|
||||||
})
|
|
||||||
if err := h.callService.RelaySignal(ctx, req.CallID, client.UserID, "call_ice", signalPayload); err != nil {
|
|
||||||
h.publisher.SendError(client, "call_ice_failed", err.Error())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -763,3 +837,18 @@ func (h *WSHandler) handleCallMute(ctx context.Context, client *ws.Client, paylo
|
|||||||
h.publisher.SendError(client, "call_mute_failed", err.Error())
|
h.publisher.SendError(client, "call_mute_failed", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleCallParticipantJoin 处理群组通话参与者加入
|
||||||
|
func (h *WSHandler) handleCallParticipantJoin(ctx context.Context, client *ws.Client, payload json.RawMessage) {
|
||||||
|
var req struct {
|
||||||
|
CallID string `json:"call_id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
||||||
|
h.publisher.SendError(client, "invalid_params", "call_id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.callService.ParticipantJoin(ctx, req.CallID, client.UserID); err != nil {
|
||||||
|
h.publisher.SendError(client, "call_participant_join_failed", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
35
internal/middleware/active.go
Normal file
35
internal/middleware/active.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"with_you/internal/pkg/auth"
|
||||||
|
"with_you/internal/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequireActive 要求账户状态为 active。
|
||||||
|
//
|
||||||
|
// 业务策略:
|
||||||
|
// - active:放行。
|
||||||
|
// - pending_deletion:在 /users/me/* 路由允许(用户可取消注销),但在 /admin/* 与敏感操作拒绝。
|
||||||
|
// - banned/inactive:已由 RequireAuth 在认证管道拦截,不会到达此处。
|
||||||
|
//
|
||||||
|
// 应挂在需要严格 active 状态的路由组上(如 /admin)。
|
||||||
|
func RequireActive() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
p, ok := auth.GetPrincipal(c)
|
||||||
|
if !ok || p == nil {
|
||||||
|
response.ErrorWithStringCode(c, http.StatusUnauthorized, "UNAUTHORIZED", "未授权")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !p.IsActive() {
|
||||||
|
response.ErrorWithStringCode(c, http.StatusForbidden, "ACCOUNT_INACTIVE", "账号未激活")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,76 +1,6 @@
|
|||||||
package middleware
|
// Package middleware 提供路由层中间件。
|
||||||
|
//
|
||||||
import (
|
// 注意:旧的 Auth/OptionalAuth 函数已被 auth_pipeline.go 中的
|
||||||
"strings"
|
// RequireAuth / OptionalAuth 替代,新版本内置令牌类型校验、账户状态校验与会话校验。
|
||||||
|
// 路由层应使用 RequireAuth(jwt, idp, cache) 与 OptionalAuth(jwt, idp, cache)。
|
||||||
"github.com/gin-gonic/gin"
|
package middleware
|
||||||
|
|
||||||
"with_you/internal/pkg/response"
|
|
||||||
"with_you/internal/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Auth 认证中间件
|
|
||||||
func Auth(jwtService *service.JWTService) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
authHeader := c.GetHeader("Authorization")
|
|
||||||
|
|
||||||
if authHeader == "" {
|
|
||||||
response.Unauthorized(c, "authorization header is required")
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提取Token
|
|
||||||
prefix, token, found := strings.Cut(authHeader, " ")
|
|
||||||
if !found || prefix != "Bearer" {
|
|
||||||
response.Unauthorized(c, "invalid authorization header format")
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证Token
|
|
||||||
claims, err := jwtService.ParseToken(token)
|
|
||||||
if err != nil {
|
|
||||||
response.Unauthorized(c, "invalid token")
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将用户信息存入上下文
|
|
||||||
c.Set("user_id", claims.UserID)
|
|
||||||
c.Set("username", claims.Username)
|
|
||||||
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// OptionalAuth 可选认证中间件
|
|
||||||
func OptionalAuth(jwtService *service.JWTService) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
authHeader := c.GetHeader("Authorization")
|
|
||||||
if authHeader == "" {
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提取Token
|
|
||||||
prefix, token, found := strings.Cut(authHeader, " ")
|
|
||||||
if !found || prefix != "Bearer" {
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证Token
|
|
||||||
claims, err := jwtService.ParseToken(token)
|
|
||||||
if err != nil {
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将用户信息存入上下文
|
|
||||||
c.Set("user_id", claims.UserID)
|
|
||||||
c.Set("username", claims.Username)
|
|
||||||
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
287
internal/middleware/auth_pipeline.go
Normal file
287
internal/middleware/auth_pipeline.go
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"with_you/internal/cache"
|
||||||
|
apperrors "with_you/internal/errors"
|
||||||
|
"with_you/internal/model"
|
||||||
|
"with_you/internal/pkg/auth"
|
||||||
|
"with_you/internal/pkg/jwt"
|
||||||
|
"with_you/internal/pkg/response"
|
||||||
|
"with_you/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 认证管道相关常量。
|
||||||
|
const (
|
||||||
|
// principalCacheTTL Principal 缓存 TTL:账户状态/角色变更后最多延迟生效时间。
|
||||||
|
// 30s 内可容忍多一次 DB 查询,平衡安全即时性与登录热点流量。
|
||||||
|
principalCacheTTL = 30 * time.Second
|
||||||
|
|
||||||
|
// sessionCacheTTL 会话撤销状态缓存 TTL:登出/封禁后旧 token 在此时长内可能仍被放行。
|
||||||
|
// 由于 session 表由服务端控制,30s 内的撤销延迟可接受;后续可调整为 0(不缓存)。
|
||||||
|
sessionCacheTTL = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// IdentityProvider 认证管道依赖的身份与角色查询接口。
|
||||||
|
//
|
||||||
|
// 由 service 层实现并注入,避免中间件直接依赖具体 service;
|
||||||
|
// 同时便于测试以 fake 注入。
|
||||||
|
type IdentityProvider interface {
|
||||||
|
// GetUserByID 加载用户实体(含状态、verification_status)。
|
||||||
|
GetUserByID(ctx context.Context, userID string) (*model.User, error)
|
||||||
|
// GetRolesForUser 返回用户的角色集合。
|
||||||
|
GetRolesForUser(ctx context.Context, userID string) ([]string, error)
|
||||||
|
// GetSessionByID 返回会话实体(用于校验是否撤销/过期)。
|
||||||
|
// 当 sessionID 为空(旧 access token 兼容窗口)应返回 nil,nil。
|
||||||
|
GetSessionByID(ctx context.Context, sessionID string) (*model.Session, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// principalCacheValue 缓存 Principal 的载体,便于区分 hit/miss 与缓存空角色。
|
||||||
|
type principalCacheValue struct {
|
||||||
|
Principal *auth.Principal
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireAuth 强制认证中间件。
|
||||||
|
//
|
||||||
|
// 流程:
|
||||||
|
// 1. 提取 Bearer token,缺失 → 401。
|
||||||
|
// 2. ParseAccessToken(拒绝 refresh token)。
|
||||||
|
// 3. 从 cache/DB 加载用户与角色,组装 Principal。
|
||||||
|
// 4. 校验账户状态:active 允许;pending_deletion 允许访问(业务策略:允许取消注销);
|
||||||
|
// banned/inactive/其他 → 403。
|
||||||
|
// 5. 校验 sessionID(sid 非空时):撤销/过期 → 401。
|
||||||
|
// 6. Principal 写入 context。
|
||||||
|
func RequireAuth(jwtService service.JWTService, idp IdentityProvider, cache cache.Cache) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
claims, ok := extractAccessToken(c, jwtService)
|
||||||
|
if !ok {
|
||||||
|
authDeny(c, http.StatusUnauthorized, apperrors.ErrInvalidToken, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
principal, err := buildPrincipal(c.Request.Context(), idp, cache, claims)
|
||||||
|
if err != nil {
|
||||||
|
// 用户不存在或加载失败:以 401 处理(令牌虽有效但身份已失效)。
|
||||||
|
authDeny(c, http.StatusUnauthorized, apperrors.ErrInvalidToken, claims.UserID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkAccountStatus(principal); err != nil {
|
||||||
|
authDeny(c, http.StatusForbidden, err, principal.UserID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateSession(c.Request.Context(), idp, cache, principal); err != nil {
|
||||||
|
authDeny(c, http.StatusUnauthorized, err, principal.UserID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.WithContext(c, principal)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptionalAuth 可选认证中间件。
|
||||||
|
//
|
||||||
|
// 行为:
|
||||||
|
// - 缺失 Authorization 头 → 当游客,Principal 不写入 context,c.Next()。
|
||||||
|
// - 携带了 token 但解析/状态/会话失败 → 401(语义更清晰,避免静默降级到游客)。
|
||||||
|
func OptionalAuth(jwtService service.JWTService, idp IdentityProvider, cache cache.Cache) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式明显错误(非 Bearer)→ 当游客,保持现有游客可见接口语义。
|
||||||
|
prefix, token, found := strings.Cut(authHeader, " ")
|
||||||
|
if !found || prefix != "Bearer" || token == "" {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := jwtService.ParseAccessToken(token)
|
||||||
|
if err != nil {
|
||||||
|
authDeny(c, http.StatusUnauthorized, apperrors.ErrInvalidToken, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
principal, err := buildPrincipal(c.Request.Context(), idp, cache, claims)
|
||||||
|
if err != nil {
|
||||||
|
authDeny(c, http.StatusUnauthorized, apperrors.ErrInvalidToken, claims.UserID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkAccountStatus(principal); err != nil {
|
||||||
|
authDeny(c, http.StatusForbidden, err, principal.UserID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateSession(c.Request.Context(), idp, cache, principal); err != nil {
|
||||||
|
authDeny(c, http.StatusUnauthorized, err, principal.UserID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.WithContext(c, principal)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractAccessToken 从请求中提取并解析 access token。
|
||||||
|
//
|
||||||
|
// 第二返回值 ok=false 表示应当拒绝(已写响应);true 表示已拿到 claims。
|
||||||
|
func extractAccessToken(c *gin.Context, jwtService service.JWTService) (*jwt.Claims, bool) {
|
||||||
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
prefix, token, found := strings.Cut(authHeader, " ")
|
||||||
|
if !found || prefix != "Bearer" || token == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := jwtService.ParseAccessToken(token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return claims, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPrincipal 从缓存或 IDP 加载用户/角色,组装 Principal。
|
||||||
|
func buildPrincipal(ctx context.Context, idp IdentityProvider, cch cache.Cache, claims *jwt.Claims) (*auth.Principal, error) {
|
||||||
|
cacheKey := ""
|
||||||
|
if cch != nil {
|
||||||
|
cacheKey = auth.PrincipalCacheKey(claims.UserID)
|
||||||
|
if cached, ok := cch.Get(cacheKey); ok {
|
||||||
|
if v, ok := cached.(*principalCacheValue); ok && v != nil {
|
||||||
|
return v.Principal, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := idp.GetUserByID(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, apperrors.ErrUserNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
roles, err := idp.GetRolesForUser(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if roles == nil {
|
||||||
|
roles = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &auth.Principal{
|
||||||
|
UserID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
SessionID: claims.SessionID,
|
||||||
|
Status: user.Status,
|
||||||
|
VerificationStatus: user.VerificationStatus,
|
||||||
|
Roles: roles,
|
||||||
|
TokenID: claims.ID,
|
||||||
|
IsLegacyToken: claims.TokenType == "",
|
||||||
|
}
|
||||||
|
|
||||||
|
if cacheKey != "" {
|
||||||
|
cch.Set(cacheKey, &principalCacheValue{Principal: p}, principalCacheTTL)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAccountStatus 校验账户状态。
|
||||||
|
//
|
||||||
|
// 策略:active 允许;pending_deletion 允许(用户可访问取消注销接口);
|
||||||
|
// banned/inactive/其他拒绝。pending_deletion 在管理后台路由由 RequireActive 单独再加一道。
|
||||||
|
func checkAccountStatus(p *auth.Principal) error {
|
||||||
|
switch p.Status {
|
||||||
|
case model.UserStatusActive, model.UserStatusPendingDeletion:
|
||||||
|
return nil
|
||||||
|
case model.UserStatusBanned:
|
||||||
|
return apperrors.ErrUserBanned
|
||||||
|
case model.UserStatusInactive:
|
||||||
|
return apperrors.ErrAccountInactive
|
||||||
|
default:
|
||||||
|
return apperrors.ErrAccountInactive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateSession 校验会话有效性。
|
||||||
|
//
|
||||||
|
// 旧 access token(无 sid)在兼容窗口内跳过会话校验,由 access TTL 自然过期收口。
|
||||||
|
func validateSession(ctx context.Context, idp IdentityProvider, cch cache.Cache, p *auth.Principal) error {
|
||||||
|
if p.SessionID == "" {
|
||||||
|
// 旧 token 兼容窗口。
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if cch != nil {
|
||||||
|
key := auth.SessionCacheKey(p.SessionID)
|
||||||
|
if cached, ok := cch.Get(key); ok {
|
||||||
|
if valid, ok := cached.(bool); ok && !valid {
|
||||||
|
return apperrors.ErrSessionRevoked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := idp.GetSessionByID(ctx, p.SessionID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return apperrors.ErrSessionRevoked
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if session == nil || !session.IsValid(time.Now()) {
|
||||||
|
if cch != nil {
|
||||||
|
cch.Set(auth.SessionCacheKey(p.SessionID), false, sessionCacheTTL)
|
||||||
|
}
|
||||||
|
return apperrors.ErrSessionRevoked
|
||||||
|
}
|
||||||
|
|
||||||
|
if cch != nil {
|
||||||
|
cch.Set(auth.SessionCacheKey(p.SessionID), true, sessionCacheTTL)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidatePrincipalCache 与 InvalidateSessionCache 已迁移至 internal/pkg/auth 包,
|
||||||
|
// 以避免 service → middleware 的包循环(service 层修改用户状态后需要失效缓存,
|
||||||
|
// 但 middleware 又依赖 service 接口)。
|
||||||
|
//
|
||||||
|
// 保留本文件中的 InvalidatePrincipalCache/InvalidateSessionCache 仅作为转发会引入
|
||||||
|
// 循环,故直接删除;调用方请使用 auth.InvalidatePrincipalCache / auth.InvalidateSessionCache。
|
||||||
|
|
||||||
|
// authDeny 拒绝访问的统一响应:记日志 + 返回结构化错误。
|
||||||
|
func authDeny(c *gin.Context, statusCode int, appErr error, userID string) {
|
||||||
|
zap.L().Warn("auth denied",
|
||||||
|
zap.Int("status", statusCode),
|
||||||
|
zap.String("user_id", userID),
|
||||||
|
zap.String("ip", c.ClientIP()),
|
||||||
|
zap.String("user_agent", c.GetHeader("User-Agent")),
|
||||||
|
zap.String("path", c.Request.URL.Path),
|
||||||
|
zap.Any("err", appErr),
|
||||||
|
)
|
||||||
|
|
||||||
|
code := apperrors.ErrInvalidToken.Code
|
||||||
|
msg := apperrors.ErrInvalidToken.Message
|
||||||
|
var target *apperrors.AppError
|
||||||
|
if errors.As(appErr, &target) {
|
||||||
|
code = target.Code
|
||||||
|
msg = target.Message
|
||||||
|
}
|
||||||
|
response.ErrorWithStringCode(c, statusCode, code, msg)
|
||||||
|
c.Abort()
|
||||||
|
}
|
||||||
225
internal/middleware/auth_pipeline_test.go
Normal file
225
internal/middleware/auth_pipeline_test.go
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"with_you/internal/model"
|
||||||
|
"with_you/internal/pkg/jwt"
|
||||||
|
"with_you/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeIDP 实现 IdentityProvider 接口,用于测试。
|
||||||
|
type fakeIDP struct {
|
||||||
|
user *model.User
|
||||||
|
userErr error
|
||||||
|
roles []string
|
||||||
|
roleErr error
|
||||||
|
session *model.Session
|
||||||
|
sessErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeIDP) GetUserByID(ctx context.Context, id string) (*model.User, error) {
|
||||||
|
if f.userErr != nil {
|
||||||
|
return nil, f.userErr
|
||||||
|
}
|
||||||
|
return f.user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeIDP) GetRolesForUser(ctx context.Context, id string) ([]string, error) {
|
||||||
|
if f.roleErr != nil {
|
||||||
|
return nil, f.roleErr
|
||||||
|
}
|
||||||
|
if f.roles == nil {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
return f.roles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeIDP) GetSessionByID(ctx context.Context, id string) (*model.Session, error) {
|
||||||
|
if f.sessErr != nil {
|
||||||
|
return nil, f.sessErr
|
||||||
|
}
|
||||||
|
return f.session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestJWT 构造测试用 JWT 服务。
|
||||||
|
func newTestJWT() service.JWTService {
|
||||||
|
return service.NewJWTService("test-secret", 3600, 86400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runRequireAuth 挂载 RequireAuth 并发起请求,返回 (status, principal)。
|
||||||
|
//
|
||||||
|
// Authorization 头直接设在 req 上;旧实现里 r.Use 内重复 Set 对 NewRecorder 无效,已移除。
|
||||||
|
func runRequireAuth(t *testing.T, jwtSvc service.JWTService, idp IdentityProvider, token string) (int, *testPrincipal) {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
nextCalled := false
|
||||||
|
r := gin.New()
|
||||||
|
r.GET("/test", RequireAuth(jwtSvc, idp, nil), func(c *gin.Context) {
|
||||||
|
nextCalled = true
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
return w.Code, &testPrincipal{called: nextCalled}
|
||||||
|
}
|
||||||
|
|
||||||
|
type testPrincipal struct {
|
||||||
|
called bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequireAuth_MissingHeader 缺失 Authorization → 401。
|
||||||
|
func TestRequireAuth_MissingHeader(t *testing.T) {
|
||||||
|
jwtSvc := newTestJWT()
|
||||||
|
idp := &fakeIDP{user: &model.User{ID: "u1", Status: model.UserStatusActive}}
|
||||||
|
code, _ := runRequireAuth(t, jwtSvc, idp, "")
|
||||||
|
if code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("status = %d, want %d", code, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequireAuth_RefreshTokenRejected refresh token 走 RequireAuth → 401。
|
||||||
|
func TestRequireAuth_RefreshTokenRejected(t *testing.T) {
|
||||||
|
jwtSvc := newTestJWT()
|
||||||
|
// 用 SessionService.Issue 签发带 sid 的 refresh token 比较繁琐,这里直接用 JWT 层签发。
|
||||||
|
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
|
||||||
|
refreshTok, _ := jwtLib.GenerateRefreshToken("u1", "alice", "s1")
|
||||||
|
|
||||||
|
idp := &fakeIDP{user: &model.User{ID: "u1", Status: model.UserStatusActive}, session: &model.Session{ID: "s1", ExpiresAt: time.Now().Add(time.Hour)}}
|
||||||
|
code, _ := runRequireAuth(t, jwtSvc, idp, refreshTok)
|
||||||
|
if code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("refresh token should be rejected, status = %d, want %d", code, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequireAuth_BannedUser banned 用户 → 403。
|
||||||
|
func TestRequireAuth_BannedUser(t *testing.T) {
|
||||||
|
jwtSvc := newTestJWT()
|
||||||
|
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
|
||||||
|
accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "s1")
|
||||||
|
|
||||||
|
idp := &fakeIDP{
|
||||||
|
user: &model.User{ID: "u1", Status: model.UserStatusBanned},
|
||||||
|
roles: []string{},
|
||||||
|
session: &model.Session{ID: "s1", ExpiresAt: time.Now().Add(time.Hour)},
|
||||||
|
}
|
||||||
|
code, _ := runRequireAuth(t, jwtSvc, idp, accessTok)
|
||||||
|
if code != http.StatusForbidden {
|
||||||
|
t.Errorf("banned user should be 403, status = %d, want %d", code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequireAuth_RevokedSession 会话已撤销 → 401。
|
||||||
|
func TestRequireAuth_RevokedSession(t *testing.T) {
|
||||||
|
jwtSvc := newTestJWT()
|
||||||
|
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
|
||||||
|
accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "s1")
|
||||||
|
|
||||||
|
revokedAt := time.Now().Add(-time.Minute)
|
||||||
|
idp := &fakeIDP{
|
||||||
|
user: &model.User{ID: "u1", Status: model.UserStatusActive},
|
||||||
|
roles: []string{},
|
||||||
|
session: &model.Session{ID: "s1", ExpiresAt: time.Now().Add(time.Hour), RevokedAt: &revokedAt},
|
||||||
|
}
|
||||||
|
code, _ := runRequireAuth(t, jwtSvc, idp, accessTok)
|
||||||
|
if code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("revoked session should be 401, status = %d, want %d", code, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequireAuth_UserNotFound 用户不存在 → 401。
|
||||||
|
func TestRequireAuth_UserNotFound(t *testing.T) {
|
||||||
|
jwtSvc := newTestJWT()
|
||||||
|
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
|
||||||
|
accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "s1")
|
||||||
|
|
||||||
|
idp := &fakeIDP{
|
||||||
|
userErr: gorm.ErrRecordNotFound,
|
||||||
|
}
|
||||||
|
code, _ := runRequireAuth(t, jwtSvc, idp, accessTok)
|
||||||
|
if code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("user not found should be 401, status = %d, want %d", code, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequireAuth_ValidAccess 正常 access token → 200 且 next 被调用。
|
||||||
|
func TestRequireAuth_ValidAccess(t *testing.T) {
|
||||||
|
jwtSvc := newTestJWT()
|
||||||
|
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
|
||||||
|
accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "s1")
|
||||||
|
|
||||||
|
idp := &fakeIDP{
|
||||||
|
user: &model.User{ID: "u1", Status: model.UserStatusActive},
|
||||||
|
roles: []string{model.RoleUser},
|
||||||
|
session: &model.Session{ID: "s1", ExpiresAt: time.Now().Add(time.Hour)},
|
||||||
|
}
|
||||||
|
// 这里需要校验 next 被调用,所以用更完整的测试。
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
nextCalled := false
|
||||||
|
r := gin.New()
|
||||||
|
r.GET("/test", RequireAuth(jwtSvc, idp, nil), func(c *gin.Context) {
|
||||||
|
nextCalled = true
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessTok)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("valid access token should be 200, status = %d, want %d", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if !nextCalled {
|
||||||
|
t.Error("next handler should be called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequireAuth_LegacyToken 旧 access token(无 sid)宽容通过。
|
||||||
|
func TestRequireAuth_LegacyToken(t *testing.T) {
|
||||||
|
jwtSvc := newTestJWT()
|
||||||
|
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
|
||||||
|
// 签发不带 sid 的 access token(模拟旧客户端)。
|
||||||
|
accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "")
|
||||||
|
|
||||||
|
idp := &fakeIDP{
|
||||||
|
user: &model.User{ID: "u1", Status: model.UserStatusActive},
|
||||||
|
roles: []string{},
|
||||||
|
// 旧 token 无 sid,GetSessionByID 不应被调用。
|
||||||
|
}
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
nextCalled := false
|
||||||
|
r := gin.New()
|
||||||
|
r.GET("/test", RequireAuth(jwtSvc, idp, nil), func(c *gin.Context) {
|
||||||
|
nextCalled = true
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessTok)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("legacy access token should be 200 (compat window), status = %d", w.Code)
|
||||||
|
}
|
||||||
|
if !nextCalled {
|
||||||
|
t.Error("next handler should be called for legacy token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeIDP 的 session 路径校验:确保不传 session 时也不影响。
|
||||||
|
var _ = errors.New
|
||||||
50
internal/middleware/authorize.go
Normal file
50
internal/middleware/authorize.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
apperrors "with_you/internal/errors"
|
||||||
|
"with_you/internal/pkg/auth"
|
||||||
|
"with_you/internal/pkg/response"
|
||||||
|
"with_you/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Authorize 基于 Casbin 资源/动作策略的路由级授权中间件。
|
||||||
|
//
|
||||||
|
// 用法:挂在 RequireAuth 之后,按路由标注 (resource, action)。
|
||||||
|
// admin.POST("/users/:id/roles", middleware.Authorize(casbin, "admin/users/roles", "write"), h.AssignRole)
|
||||||
|
//
|
||||||
|
// 校验流程:
|
||||||
|
// 1. 从 context 取出 Principal(由 RequireAuth 写入)。
|
||||||
|
// 2. 调 CasbinService.EnforceForUser(userID, resource, action)。
|
||||||
|
// - 内部先查 user_roles 表得到角色集合,再对每个角色调 Enforce(role, resource, action)。
|
||||||
|
// - super_admin 通过 admin/** 通配策略获得所有 admin 能力。
|
||||||
|
// 3. 不匹配 → 403 PERMISSION_DENIED。
|
||||||
|
//
|
||||||
|
// 纵深防御:service 层对最敏感操作(如分配 super_admin)应再次校验 operator 角色,
|
||||||
|
// 即使路由 Authorize 漏配,Service 仍能拦截。
|
||||||
|
func Authorize(casbinService service.CasbinService, resource, action string) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
p, ok := auth.GetPrincipal(c)
|
||||||
|
if !ok || p == nil {
|
||||||
|
response.ErrorWithStringCode(c, http.StatusUnauthorized, "UNAUTHORIZED", "未授权")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed, err := casbinService.EnforceForUser(c.Request.Context(), p.UserID, resource, action)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorWithStringCode(c, http.StatusInternalServerError, "CASBIN_INTERNAL_ERROR", "权限系统内部错误")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
response.ErrorWithStringCode(c, http.StatusForbidden, apperrors.ErrPermissionDenied.Code, apperrors.ErrPermissionDenied.Message)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,80 +9,6 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CasbinAuth Casbin 权限中间件
|
|
||||||
// 需要在 Auth 中间件之后使用
|
|
||||||
func CasbinAuth(casbinService service.CasbinService) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
// 获取请求路径和方法
|
|
||||||
path := c.Request.URL.Path
|
|
||||||
method := c.Request.Method
|
|
||||||
|
|
||||||
// 从上下文获取用户ID (由 Auth 中间件设置)
|
|
||||||
userID, exists := c.Get("user_id")
|
|
||||||
|
|
||||||
// 如果用户未登录,检查是否是公开路由
|
|
||||||
if !exists {
|
|
||||||
// 对于未登录用户,使用匿名角色检查权限
|
|
||||||
allowed, err := casbinService.Enforce(c.Request.Context(), "anonymous", path, method)
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithStatusJSON(500, gin.H{
|
|
||||||
"code": "INTERNAL_ERROR",
|
|
||||||
"message": "权限检查失败",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !allowed {
|
|
||||||
c.AbortWithStatusJSON(401, gin.H{
|
|
||||||
"code": "UNAUTHORIZED",
|
|
||||||
"message": "请先登录",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查用户权限
|
|
||||||
allowed, err := casbinService.EnforceForUser(c.Request.Context(), userID.(string), path, method)
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithStatusJSON(500, gin.H{
|
|
||||||
"code": "INTERNAL_ERROR",
|
|
||||||
"message": "权限检查失败",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !allowed {
|
|
||||||
c.AbortWithStatusJSON(403, gin.H{
|
|
||||||
"code": "FORBIDDEN",
|
|
||||||
"message": "权限不足",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// OptionalCasbinAuth 可选的 Casbin 权限检查
|
|
||||||
// 不阻止请求,但会设置权限标志
|
|
||||||
func OptionalCasbinAuth(casbinService service.CasbinService) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
userID, exists := c.Get("user_id")
|
|
||||||
if exists {
|
|
||||||
path := c.Request.URL.Path
|
|
||||||
method := c.Request.Method
|
|
||||||
allowed, err := casbinService.EnforceForUser(c.Request.Context(), userID.(string), path, method)
|
|
||||||
if err == nil {
|
|
||||||
c.Set("permission_granted", allowed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequireRole 要求特定角色的中间件
|
// RequireRole 要求特定角色的中间件
|
||||||
func RequireRole(casbinService service.CasbinService, requiredRoles ...string) gin.HandlerFunc {
|
func RequireRole(casbinService service.CasbinService, requiredRoles ...string) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Logger 日志中间件
|
|
||||||
func Logger(logger *zap.Logger) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
start := time.Now()
|
|
||||||
path := c.Request.URL.Path
|
|
||||||
|
|
||||||
c.Next()
|
|
||||||
|
|
||||||
latency := time.Since(start)
|
|
||||||
statusCode := c.Writer.Status()
|
|
||||||
|
|
||||||
logger.Info("request",
|
|
||||||
zap.String("method", c.Request.Method),
|
|
||||||
zap.String("path", path),
|
|
||||||
zap.Int("status", statusCode),
|
|
||||||
zap.Duration("latency", latency),
|
|
||||||
zap.String("ip", c.ClientIP()),
|
|
||||||
zap.String("user-agent", c.Request.UserAgent()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recovery 恢复中间件
|
|
||||||
func Recovery(logger *zap.Logger) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
defer func() {
|
|
||||||
if err := recover(); err != nil {
|
|
||||||
logger.Error("panic recovered",
|
|
||||||
zap.Any("error", err),
|
|
||||||
)
|
|
||||||
c.JSON(500, gin.H{
|
|
||||||
"code": 500,
|
|
||||||
"message": "internal server error",
|
|
||||||
})
|
|
||||||
c.Abort()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -75,27 +75,6 @@ func (rl *RateLimiter) isAllowed(key string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rl *RateLimiter) count(key string) int {
|
|
||||||
rl.mu.Lock()
|
|
||||||
defer rl.mu.Unlock()
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
times := rl.requests[key]
|
|
||||||
|
|
||||||
var valid []time.Time
|
|
||||||
for _, t := range times {
|
|
||||||
if now.Sub(t) < rl.window {
|
|
||||||
valid = append(valid, t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rl.requests[key] = valid
|
|
||||||
return len(valid)
|
|
||||||
}
|
|
||||||
|
|
||||||
func RateLimit(requestsPerMinute int) gin.HandlerFunc {
|
|
||||||
return RateLimitWithDuration(requestsPerMinute, time.Minute)
|
|
||||||
}
|
|
||||||
|
|
||||||
func RateLimitWithDuration(limit int, window time.Duration) gin.HandlerFunc {
|
func RateLimitWithDuration(limit int, window time.Duration) gin.HandlerFunc {
|
||||||
limiter := NewRateLimiter(limit, window)
|
limiter := NewRateLimiter(limit, window)
|
||||||
|
|
||||||
@@ -115,32 +94,6 @@ func RateLimitWithDuration(limit int, window time.Duration) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func RateLimitWithKey(requestsPerMinute int, keyFunc func(c *gin.Context) string) gin.HandlerFunc {
|
|
||||||
return RateLimitWithDurationAndKey(requestsPerMinute, time.Minute, keyFunc)
|
|
||||||
}
|
|
||||||
|
|
||||||
func RateLimitWithDurationAndKey(limit int, window time.Duration, keyFunc func(c *gin.Context) string) gin.HandlerFunc {
|
|
||||||
limiter := NewRateLimiter(limit, window)
|
|
||||||
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
key := keyFunc(c)
|
|
||||||
if key == "" {
|
|
||||||
key = c.ClientIP()
|
|
||||||
}
|
|
||||||
|
|
||||||
if !limiter.isAllowed(key) {
|
|
||||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
|
||||||
"code": 429,
|
|
||||||
"message": "too many requests, please try again later",
|
|
||||||
})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// IP 封禁与登录失败追踪
|
// IP 封禁与登录失败追踪
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
"with_you/internal/pkg/response"
|
"with_you/internal/pkg/response"
|
||||||
"with_you/internal/repository"
|
"with_you/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RequireVerified(userRepo repository.UserRepository) gin.HandlerFunc {
|
// RequireVerified 要求用户已通过身份认证。
|
||||||
|
// 通过 service.UserService 查询用户(而非直接访问 repository),保持分层一致:
|
||||||
|
//
|
||||||
|
// middleware → service → repository
|
||||||
|
func RequireVerified(userService service.UserService) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
userID, exists := c.Get("user_id")
|
userID, exists := c.Get("user_id")
|
||||||
if !exists {
|
if !exists {
|
||||||
@@ -19,7 +23,7 @@ func RequireVerified(userRepo repository.UserRepository) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := userRepo.GetByID(userID.(string))
|
user, err := userService.GetUserByID(c.Request.Context(), userID.(string))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "获取用户信息失败")
|
response.InternalServerError(c, "获取用户信息失败")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
|
|||||||
110
internal/middleware/verification_test.go
Normal file
110
internal/middleware/verification_test.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"with_you/internal/model"
|
||||||
|
"with_you/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stubUserService 通过嵌入 service.UserService 接口(nil)仅覆盖 GetUserByID。
|
||||||
|
// 未覆盖方法若被调用会 panic,可在测试中暴露非预期的依赖调用。
|
||||||
|
type stubUserService struct {
|
||||||
|
service.UserService // nil 嵌入
|
||||||
|
|
||||||
|
user *model.User
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubUserService) GetUserByID(ctx context.Context, id string) (*model.User, error) {
|
||||||
|
if s.err != nil {
|
||||||
|
return nil, s.err
|
||||||
|
}
|
||||||
|
return s.user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runMiddleware 构造一个 gin 引擎,挂载 RequireVerified + 一个标记 handler,
|
||||||
|
// 发起请求并返回 (status, body, nextCalled)。
|
||||||
|
func runMiddleware(t *testing.T, svc service.UserService, setUserID bool, userID any) (int, bool) {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
nextCalled := false
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(func(c *gin.Context) {
|
||||||
|
if setUserID {
|
||||||
|
c.Set("user_id", userID)
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
r.GET("/test", RequireVerified(svc), func(c *gin.Context) {
|
||||||
|
nextCalled = true
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
return w.Code, nextCalled
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequireVerified_Approved 已认证用户 → 放行(200,next 被调用)。
|
||||||
|
func TestRequireVerified_Approved(t *testing.T) {
|
||||||
|
svc := &stubUserService{
|
||||||
|
user: &model.User{ID: "u1", VerificationStatus: model.VerificationStatusApproved},
|
||||||
|
}
|
||||||
|
code, next := runMiddleware(t, svc, true, "u1")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want %d", code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if !next {
|
||||||
|
t.Error("next handler should be called for verified user")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequireVerified_NotApproved 未通过认证用户 → 403 VERIFICATION_REQUIRED。
|
||||||
|
func TestRequireVerified_NotApproved(t *testing.T) {
|
||||||
|
svc := &stubUserService{
|
||||||
|
user: &model.User{ID: "u1", VerificationStatus: model.VerificationStatusPending},
|
||||||
|
}
|
||||||
|
code, next := runMiddleware(t, svc, true, "u1")
|
||||||
|
if code != http.StatusForbidden {
|
||||||
|
t.Errorf("status = %d, want %d", code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
if next {
|
||||||
|
t.Error("next handler should NOT be called for unverified user")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequireVerified_UserLookupError 查询用户失败 → 500。
|
||||||
|
func TestRequireVerified_UserLookupError(t *testing.T) {
|
||||||
|
svc := &stubUserService{err: errors.New("db down")}
|
||||||
|
code, next := runMiddleware(t, svc, true, "u1")
|
||||||
|
if code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("status = %d, want %d", code, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
if next {
|
||||||
|
t.Error("next handler should NOT be called on lookup error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequireVerified_NoUserID 未登录(无 user_id)→ 401。
|
||||||
|
func TestRequireVerified_NoUserID(t *testing.T) {
|
||||||
|
svc := &stubUserService{
|
||||||
|
user: &model.User{ID: "u1", VerificationStatus: model.VerificationStatusApproved},
|
||||||
|
}
|
||||||
|
code, next := runMiddleware(t, svc, false, nil)
|
||||||
|
if code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("status = %d, want %d", code, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
if next {
|
||||||
|
t.Error("next handler should NOT be called when user_id missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AuditTargetType 审核对象类型
|
|
||||||
type AuditTargetType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
AuditTargetTypePost AuditTargetType = "post" // 帖子
|
|
||||||
AuditTargetTypeComment AuditTargetType = "comment" // 评论
|
|
||||||
AuditTargetTypeMessage AuditTargetType = "message" // 私信
|
|
||||||
AuditTargetTypeUser AuditTargetType = "user" // 用户资料
|
|
||||||
AuditTargetTypeImage AuditTargetType = "image" // 图片
|
|
||||||
AuditTargetTypeAvatar AuditTargetType = "avatar" // 头像
|
|
||||||
AuditTargetTypeCover AuditTargetType = "cover" // 背景图
|
|
||||||
AuditTargetTypeBio AuditTargetType = "bio" // 个性签名
|
|
||||||
AuditTargetTypeUserProfile AuditTargetType = "user_profile" // 用户资料审核任务
|
|
||||||
)
|
|
||||||
|
|
||||||
// AuditResult 审核结果
|
|
||||||
type AuditResult string
|
|
||||||
|
|
||||||
const (
|
|
||||||
AuditResultPass AuditResult = "pass" // 通过
|
|
||||||
AuditResultReview AuditResult = "review" // 需人工复审
|
|
||||||
AuditResultBlock AuditResult = "block" // 违规拦截
|
|
||||||
AuditResultUnknown AuditResult = "unknown" // 未知
|
|
||||||
)
|
|
||||||
|
|
||||||
// AuditRiskLevel 风险等级
|
|
||||||
type AuditRiskLevel string
|
|
||||||
|
|
||||||
const (
|
|
||||||
AuditRiskLevelLow AuditRiskLevel = "low" // 低风险
|
|
||||||
AuditRiskLevelMedium AuditRiskLevel = "medium" // 中风险
|
|
||||||
AuditRiskLevelHigh AuditRiskLevel = "high" // 高风险
|
|
||||||
)
|
|
||||||
|
|
||||||
// AuditSource 审核来源
|
|
||||||
type AuditSource string
|
|
||||||
|
|
||||||
const (
|
|
||||||
AuditSourceAuto AuditSource = "auto" // 自动审核
|
|
||||||
AuditSourceManual AuditSource = "manual" // 人工审核
|
|
||||||
AuditSourceCallback AuditSource = "callback" // 回调审核
|
|
||||||
)
|
|
||||||
|
|
||||||
// AuditLog 审核日志实体
|
|
||||||
type AuditLog struct {
|
|
||||||
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
|
|
||||||
TargetType AuditTargetType `json:"target_type" gorm:"type:varchar(50);index"`
|
|
||||||
TargetID string `json:"target_id" gorm:"type:varchar(255);index"`
|
|
||||||
Content string `json:"content" gorm:"type:text"` // 待审核内容
|
|
||||||
ContentType string `json:"content_type" gorm:"type:varchar(50)"` // 内容类型: text, image
|
|
||||||
ContentURL string `json:"content_url" gorm:"type:text"` // 图片/文件URL
|
|
||||||
AuditType string `json:"audit_type" gorm:"type:varchar(50)"` // 审核类型: porn, violence, ad, political, fraud, gamble
|
|
||||||
Result AuditResult `json:"result" gorm:"type:varchar(50);index"`
|
|
||||||
RiskLevel AuditRiskLevel `json:"risk_level" gorm:"type:varchar(20)"`
|
|
||||||
Labels string `json:"labels" gorm:"type:text"` // JSON数组,标签列表
|
|
||||||
Suggestion string `json:"suggestion" gorm:"type:varchar(50)"` // pass, review, block
|
|
||||||
Detail string `json:"detail" gorm:"type:text"` // 详细说明
|
|
||||||
ThirdPartyID string `json:"third_party_id" gorm:"type:varchar(255)"` // 第三方审核服务返回的ID
|
|
||||||
Source AuditSource `json:"source" gorm:"type:varchar(20);default:auto"`
|
|
||||||
ReviewerID string `json:"reviewer_id" gorm:"type:varchar(255)"` // 审核人ID(人工审核时使用)
|
|
||||||
ReviewerName string `json:"reviewer_name" gorm:"type:varchar(100)"` // 审核人名称
|
|
||||||
ReviewTime *time.Time `json:"review_time" gorm:"index"` // 审核时间
|
|
||||||
UserID string `json:"user_id" gorm:"type:varchar(255);index"` // 内容发布者ID
|
|
||||||
UserIP string `json:"user_ip" gorm:"type:varchar(45)"` // 用户IP
|
|
||||||
Status string `json:"status" gorm:"type:varchar(20);default:pending"` // pending, completed, failed
|
|
||||||
RejectReason string `json:"reject_reason" gorm:"type:text"` // 拒绝原因(人工审核时使用)
|
|
||||||
ExtraData string `json:"extra_data" gorm:"type:text"` // 额外数据,JSON格式
|
|
||||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
|
||||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// BeforeCreate 创建前生成UUID
|
|
||||||
func (al *AuditLog) BeforeCreate(tx *gorm.DB) error {
|
|
||||||
SetUUIDIfEmpty(&al.ID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (AuditLog) TableName() string {
|
|
||||||
return "audit_logs"
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuditLogRequest 创建审核日志请求
|
|
||||||
type AuditLogRequest struct {
|
|
||||||
TargetType AuditTargetType `json:"target_type" validate:"required"`
|
|
||||||
TargetID string `json:"target_id" validate:"required"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
ContentType string `json:"content_type"`
|
|
||||||
ContentURL string `json:"content_url"`
|
|
||||||
AuditType string `json:"audit_type"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
UserIP string `json:"user_ip"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuditLogListItem 审核日志列表项
|
|
||||||
type AuditLogListItem struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
TargetType AuditTargetType `json:"target_type"`
|
|
||||||
TargetID string `json:"target_id"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
ContentType string `json:"content_type"`
|
|
||||||
Result AuditResult `json:"result"`
|
|
||||||
RiskLevel AuditRiskLevel `json:"risk_level"`
|
|
||||||
Suggestion string `json:"suggestion"`
|
|
||||||
Source AuditSource `json:"source"`
|
|
||||||
ReviewerID string `json:"reviewer_id"`
|
|
||||||
ReviewTime *time.Time `json:"review_time"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
@@ -43,7 +43,9 @@ type CallSession struct {
|
|||||||
GroupID *string `gorm:"index" json:"group_id,omitempty"` // 群组ID(群聊时使用)
|
GroupID *string `gorm:"index" json:"group_id,omitempty"` // 群组ID(群聊时使用)
|
||||||
CallerID string `gorm:"column:caller_id;type:varchar(50);index;not null" json:"caller_id"` // 发起者ID
|
CallerID string `gorm:"column:caller_id;type:varchar(50);index;not null" json:"caller_id"` // 发起者ID
|
||||||
CallType CallType `gorm:"type:varchar(20);not null" json:"call_type"` // 通话类型
|
CallType CallType `gorm:"type:varchar(20);not null" json:"call_type"` // 通话类型
|
||||||
|
MediaType string `gorm:"type:varchar(20);default:'voice'" json:"media_type"` // 媒体类型: voice/video
|
||||||
Status CallStatus `gorm:"type:varchar(20);default:'calling'" json:"status"` // 通话状态
|
Status CallStatus `gorm:"type:varchar(20);default:'calling'" json:"status"` // 通话状态
|
||||||
|
EndedBy *string `gorm:"type:varchar(50)" json:"ended_by,omitempty"` // 结束通话的用户ID
|
||||||
StartedAt *time.Time `json:"started_at,omitempty"` // 接通时间
|
StartedAt *time.Time `json:"started_at,omitempty"` // 接通时间
|
||||||
EndedAt *time.Time `json:"ended_at,omitempty"` // 结束时间
|
EndedAt *time.Time `json:"ended_at,omitempty"` // 结束时间
|
||||||
Duration int64 `gorm:"default:0" json:"duration"` // 通话时长(秒)
|
Duration int64 `gorm:"default:0" json:"duration"` // 通话时长(秒)
|
||||||
|
|||||||
13
internal/model/conversation_version_log.go
Normal file
13
internal/model/conversation_version_log.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ConversationVersionLog 会话版本日志(用于增量同步)
|
||||||
|
type ConversationVersionLog struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||||
|
UserID string `gorm:"not null;size:36;index:idx_vlog_user_version,priority:1"`
|
||||||
|
ConversationID string `gorm:"not null;size:20;index:idx_vlog_conv"`
|
||||||
|
Version int64 `gorm:"not null;index:idx_vlog_user_version,priority:2"`
|
||||||
|
ChangeType string `gorm:"not null;type:varchar(20)"` // new_msg, read, pin, unpin, mute, unmute, hide, group_update
|
||||||
|
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||||
|
}
|
||||||
26
internal/model/empty_classroom.go
Normal file
26
internal/model/empty_classroom.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EmptyClassroom struct {
|
||||||
|
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
|
||||||
|
UserID string `json:"user_id" gorm:"type:varchar(36);index;not null"`
|
||||||
|
Semester string `json:"semester" gorm:"type:varchar(30);not null"`
|
||||||
|
Classroom string `json:"classroom" gorm:"type:varchar(60);not null"`
|
||||||
|
Weekday string `json:"weekday" gorm:"type:varchar(100)"`
|
||||||
|
Periods string `json:"periods" gorm:"type:text"`
|
||||||
|
Weeks string `json:"weeks" gorm:"type:varchar(30)"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EmptyClassroom) BeforeCreate(tx *gorm.DB) error {
|
||||||
|
SetUUIDIfEmpty(&e.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (EmptyClassroom) TableName() string { return "empty_classrooms" }
|
||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"gorm.io/gorm"
|
|
||||||
|
|
||||||
"with_you/internal/pkg/utils"
|
"with_you/internal/pkg/utils"
|
||||||
)
|
)
|
||||||
@@ -37,20 +36,3 @@ func SetSnowflakeInt64ID(id *int64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateUUID() string {
|
|
||||||
return uuid.New().String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func GenerateSnowflakeString() (string, error) {
|
|
||||||
id, err := utils.GetSnowflake().GenerateID()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return strconv.FormatInt(id, 10), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GenerateSnowflakeInt64() (int64, error) {
|
|
||||||
return utils.GetSnowflake().GenerateID()
|
|
||||||
}
|
|
||||||
|
|
||||||
func nop(_ *gorm.DB) error { return nil }
|
|
||||||
@@ -1,423 +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"
|
|
||||||
|
|
||||||
"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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 自动迁移
|
|
||||||
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{},
|
|
||||||
|
|
||||||
// 举报
|
|
||||||
&Report{},
|
|
||||||
|
|
||||||
// 日志相关
|
|
||||||
&OperationLog{}, // 操作日志
|
|
||||||
&LoginLog{}, // 登录日志
|
|
||||||
&DataChangeLog{}, // 数据变更日志
|
|
||||||
|
|
||||||
// 群组相关
|
|
||||||
&Group{},
|
|
||||||
&GroupMember{},
|
|
||||||
&GroupAnnouncement{},
|
|
||||||
&GroupJoinRequest{},
|
|
||||||
|
|
||||||
// 自定义表情
|
|
||||||
&UserSticker{},
|
|
||||||
|
|
||||||
// 课表
|
|
||||||
&ScheduleCourse{},
|
|
||||||
|
|
||||||
// 成绩与考试
|
|
||||||
&Grade{},
|
|
||||||
&GpaSummary{},
|
|
||||||
&Exam{},
|
|
||||||
|
|
||||||
// 用户活跃相关
|
|
||||||
&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
|
|
||||||
}
|
|
||||||
|
|
||||||
// CloseDB 关闭数据库连接
|
|
||||||
func CloseDB(db *gorm.DB) {
|
|
||||||
if sqlDB, err := db.DB(); err == nil {
|
|
||||||
sqlDB.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql/driver"
|
"database/sql/driver"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -303,79 +302,14 @@ func (m *Message) IsInteractionNotification() bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// BatchDecryptMessages 批量并行解密消息
|
// BatchDecryptMessagesParallel 批量解密消息段。
|
||||||
// 用于批量查询后提高解密性能,比逐条调用 AfterFind 更高效
|
// 并发逻辑收敛在 crypto.MessageEncryptor.BatchDecrypt(worker pool),
|
||||||
// workerCount 指定并行工作数,如果 <= 0 则使用默认值
|
// 本函数仅负责「提取密文 → 回填明文到 Message.Segments」,不再自起 goroutine。
|
||||||
func BatchDecryptMessages(messages []*Message, workerCount int) error {
|
//
|
||||||
if len(messages) == 0 {
|
// 行为保持与历史版本一致:
|
||||||
return nil
|
// - 加密器未初始化时,降级为明文 JSON 直接解析(兼容旧数据);
|
||||||
}
|
// - 单条解密失败时回退尝试直接 JSON 解析(兼容未加密旧数据);
|
||||||
|
// - 已解密或无加密内容的消息跳过。
|
||||||
encryptor := crypto.GetMessageEncryptor()
|
|
||||||
if encryptor == nil {
|
|
||||||
// 加密器未初始化,直接解析 JSON(兼容模式)
|
|
||||||
for _, m := range messages {
|
|
||||||
if m.decrypted || m.SegmentsEncrypted == "" {
|
|
||||||
m.decrypted = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(m.SegmentsEncrypted), &m.Segments); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
m.decrypted = true
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 小批量直接串行处理
|
|
||||||
if len(messages) <= 10 {
|
|
||||||
for _, m := range messages {
|
|
||||||
if err := m.decryptSegments(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 并行解密
|
|
||||||
// 先收集需要解密的密文
|
|
||||||
ciphertexts := make([]string, len(messages))
|
|
||||||
for i, m := range messages {
|
|
||||||
ciphertexts[i] = m.SegmentsEncrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
// 批量并行解密
|
|
||||||
plaintexts := encryptor.BatchDecrypt(ciphertexts, workerCount)
|
|
||||||
|
|
||||||
// 解析结果
|
|
||||||
for i, m := range messages {
|
|
||||||
if m.decrypted {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if m.SegmentsEncrypted == "" {
|
|
||||||
m.decrypted = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
plaintext := plaintexts[i]
|
|
||||||
if plaintext == nil {
|
|
||||||
// 解密失败,尝试直接解析(兼容旧数据)
|
|
||||||
if err := json.Unmarshal([]byte(m.SegmentsEncrypted), &m.Segments); err != nil {
|
|
||||||
continue // 忽略单条错误
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := json.Unmarshal(plaintext, &m.Segments); err != nil {
|
|
||||||
continue // 忽略单条错误
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.decrypted = true
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// BatchDecryptMessagesParallel 使用 worker pool 并行解密
|
|
||||||
// 这是一个更高效的版本,适用于大量消息
|
|
||||||
func BatchDecryptMessagesParallel(messages []*Message) {
|
func BatchDecryptMessagesParallel(messages []*Message) {
|
||||||
if len(messages) == 0 {
|
if len(messages) == 0 {
|
||||||
return
|
return
|
||||||
@@ -383,7 +317,7 @@ func BatchDecryptMessagesParallel(messages []*Message) {
|
|||||||
|
|
||||||
encryptor := crypto.GetMessageEncryptor()
|
encryptor := crypto.GetMessageEncryptor()
|
||||||
if encryptor == nil {
|
if encryptor == nil {
|
||||||
// 加密器未初始化,串行解析
|
// 加密器未初始化,串行解析为明文 JSON(兼容模式)
|
||||||
for _, m := range messages {
|
for _, m := range messages {
|
||||||
if !m.decrypted && m.SegmentsEncrypted != "" {
|
if !m.decrypted && m.SegmentsEncrypted != "" {
|
||||||
_ = json.Unmarshal([]byte(m.SegmentsEncrypted), &m.Segments)
|
_ = json.Unmarshal([]byte(m.SegmentsEncrypted), &m.Segments)
|
||||||
@@ -393,46 +327,34 @@ func BatchDecryptMessagesParallel(messages []*Message) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确定并行度
|
// 提取待解密的密文索引与值
|
||||||
workerCount := 4
|
indices := make([]int, 0, len(messages))
|
||||||
if len(messages) < 20 {
|
ciphertexts := make([]string, 0, len(messages))
|
||||||
workerCount = 2
|
for i, m := range messages {
|
||||||
} else if len(messages) > 100 {
|
if m.decrypted || m.SegmentsEncrypted == "" {
|
||||||
workerCount = 8
|
m.decrypted = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
indices = append(indices, i)
|
||||||
|
ciphertexts = append(ciphertexts, m.SegmentsEncrypted)
|
||||||
|
}
|
||||||
|
if len(ciphertexts) == 0 {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
jobs := make(chan int, len(messages))
|
// 并发解密(worker pool 由 crypto.BatchDecrypt 内部管理)
|
||||||
var wg sync.WaitGroup
|
plaintexts := encryptor.BatchDecrypt(ciphertexts, 0)
|
||||||
|
|
||||||
// 启动 workers
|
// 回填明文;解密失败的位置回退直接 JSON 解析
|
||||||
for w := 0; w < workerCount; w++ {
|
for j, idx := range indices {
|
||||||
wg.Add(1)
|
m := messages[idx]
|
||||||
go func() {
|
pt := plaintexts[j]
|
||||||
defer wg.Done()
|
if pt == nil {
|
||||||
for i := range jobs {
|
// 解密失败,兼容未加密的旧数据
|
||||||
m := messages[i]
|
_ = json.Unmarshal([]byte(m.SegmentsEncrypted), &m.Segments)
|
||||||
if m.decrypted || m.SegmentsEncrypted == "" {
|
} else {
|
||||||
m.decrypted = true
|
_ = json.Unmarshal(pt, &m.Segments)
|
||||||
continue
|
}
|
||||||
}
|
m.decrypted = true
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分发任务
|
|
||||||
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
|
||||||
55
internal/model/session.go
Normal file
55
internal/model/session.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session 用户登录会话
|
||||||
|
//
|
||||||
|
// 用于支持令牌撤销:
|
||||||
|
// - access token 携带 SessionID(JWT sid),中间件校验会话是否仍有效。
|
||||||
|
// - refresh token 同样携带 SessionID,刷新时轮换并校验。
|
||||||
|
// - 登出/封禁时撤销会话,旧 token 在 access TTL 内可见但 refresh 立即失效。
|
||||||
|
//
|
||||||
|
// RefreshTokenHash 存储 refresh token 的 SHA256 哈希,便于按 refresh token 反查会话,
|
||||||
|
// 不直接存储 token 明文以降低泄露风险。
|
||||||
|
type Session struct {
|
||||||
|
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
|
||||||
|
UserID string `json:"user_id" gorm:"type:varchar(36);index;not null"`
|
||||||
|
RefreshTokenHash string `json:"-" gorm:"type:varchar(64);uniqueIndex;not null"`
|
||||||
|
UserAgent string `json:"user_agent" gorm:"type:varchar(255)"`
|
||||||
|
IP string `json:"ip" gorm:"type:varchar(45)"`
|
||||||
|
IssuedAt time.Time `json:"issued_at"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at" gorm:"index"`
|
||||||
|
RevokedAt *time.Time `json:"revoked_at,omitempty" gorm:"index"`
|
||||||
|
LastUsedAt time.Time `json:"last_used_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Session) TableName() string {
|
||||||
|
return "sessions"
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeforeCreate 创建前生成 UUID。
|
||||||
|
func (s *Session) BeforeCreate(tx *gorm.DB) error {
|
||||||
|
SetUUIDIfEmpty(&s.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRevoked 会话是否已撤销。
|
||||||
|
func (s *Session) IsRevoked() bool {
|
||||||
|
return s != nil && s.RevokedAt != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsExpired 会话是否已过期(按 ExpiresAt)。
|
||||||
|
func (s *Session) IsExpired(now time.Time) bool {
|
||||||
|
return s != nil && !s.ExpiresAt.IsZero() && now.After(s.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValid 会话是否仍有效(未撤销且未过期)。
|
||||||
|
func (s *Session) IsValid(now time.Time) bool {
|
||||||
|
return s != nil && !s.IsRevoked() && !s.IsExpired(now)
|
||||||
|
}
|
||||||
26
internal/model/uploaded_file.go
Normal file
26
internal/model/uploaded_file.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// UploadedFile 聊天/通用文件上传记录
|
||||||
|
// 用于跟踪 files/ 前缀下的 S3 对象,配合 FileCleanupWorker 实现 TTL 过期清理。
|
||||||
|
// 注意:images/avatars/covers 等内容寻址资源不记录本表,不会被自动清理。
|
||||||
|
type UploadedFile struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
URL string `gorm:"size:512;index;not null" json:"url"` // 完整访问 URL,DTO 标记失效用
|
||||||
|
ObjectName string `gorm:"size:256;index;not null" json:"object_name"` // S3 对象名 files/{folder}/{hash}{ext}
|
||||||
|
Name string `gorm:"size:255" json:"name"` // 原始文件名
|
||||||
|
Size int64 `json:"size"` // 字节数
|
||||||
|
MimeType string `gorm:"size:128" json:"mime_type"`
|
||||||
|
Folder string `gorm:"size:32;index" json:"folder"` // chat / post 等来源
|
||||||
|
UploaderID string `gorm:"size:50;index" json:"uploader_id"` // 上传者 UUID
|
||||||
|
Expired bool `gorm:"index;default:false" json:"expired"` // 是否已从 S3 清理
|
||||||
|
ExpiredAt *time.Time `json:"expired_at,omitempty"` // 清理时间
|
||||||
|
CreatedAt time.Time `gorm:"index" json:"created_at"` // 上传时间,TTL 计算基准
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 表名
|
||||||
|
func (UploadedFile) TableName() string {
|
||||||
|
return "uploaded_files"
|
||||||
|
}
|
||||||
22
internal/pkg/auth/cache.go
Normal file
22
internal/pkg/auth/cache.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import "with_you/internal/cache"
|
||||||
|
|
||||||
|
// InvalidatePrincipalCache 失效用户的 Principal 缓存。
|
||||||
|
//
|
||||||
|
// 供角色变更、账户状态变更、登出等场景调用:让 RequireAuth 在下一次请求
|
||||||
|
// 重新从 IDP 加载用户与角色。键定义在本包以避免 service → middleware 的包循环。
|
||||||
|
func InvalidatePrincipalCache(cch cache.Cache, userID string) {
|
||||||
|
if cch == nil || userID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cch.Delete(PrincipalCacheKey(userID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateSessionCache 失效会话有效性缓存。
|
||||||
|
func InvalidateSessionCache(cch cache.Cache, sessionID string) {
|
||||||
|
if cch == nil || sessionID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cch.Delete(SessionCacheKey(sessionID))
|
||||||
|
}
|
||||||
116
internal/pkg/auth/principal.go
Normal file
116
internal/pkg/auth/principal.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
// Package auth 提供统一的安全状态载体与认证管道。
|
||||||
|
//
|
||||||
|
// Principal 是贯穿 middleware → handler → service 的类型化身份契约,
|
||||||
|
// 取代散落的 c.Set("user_id", ...) 字符串约定,让账户状态/角色/会话在编译期显式可见。
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"with_you/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ContextKey 是 Principal 在 gin.Context 中的键。
|
||||||
|
// 使用私有类型别名以避免与其他包的字符串键冲突。
|
||||||
|
contextKey principalCtxKey = "auth_principal"
|
||||||
|
)
|
||||||
|
|
||||||
|
type principalCtxKey string
|
||||||
|
|
||||||
|
// Principal 已认证主体
|
||||||
|
//
|
||||||
|
// 由 RequireAuth 中间件一次性组装完成,包含:
|
||||||
|
// - 身份信息(UserID / Username / SessionID)
|
||||||
|
// - 账户状态(Status / VerificationStatus)
|
||||||
|
// - 角色集合(Roles,由 Casbin/App DB 加载)
|
||||||
|
//
|
||||||
|
// 不再由各 handler/service 重复查询数据库,统一由认证管道负责加载与缓存。
|
||||||
|
type Principal struct {
|
||||||
|
UserID string
|
||||||
|
Username string
|
||||||
|
SessionID string
|
||||||
|
Status model.UserStatus
|
||||||
|
VerificationStatus model.VerificationStatus
|
||||||
|
Roles []string
|
||||||
|
TokenID string
|
||||||
|
IsLegacyToken bool // 旧客户端签发的 access token(无 typ/sid),过渡期宽容通过
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasRole 检查主体是否拥有指定角色。
|
||||||
|
func (p *Principal) HasRole(role string) bool {
|
||||||
|
if p == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range p.Roles {
|
||||||
|
if r == role {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSuperAdmin 是否超级管理员。
|
||||||
|
func (p *Principal) IsSuperAdmin() bool {
|
||||||
|
return p.HasRole(model.RoleSuperAdmin)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAdmin 是否管理员(含超级管理员)。
|
||||||
|
func (p *Principal) IsAdmin() bool {
|
||||||
|
return p.HasRole(model.RoleAdmin) || p.IsSuperAdmin()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsActive 账户状态是否为 active。
|
||||||
|
func (p *Principal) IsActive() bool {
|
||||||
|
return p != nil && p.Status == model.UserStatusActive
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsVerified 是否已通过身份认证。
|
||||||
|
func (p *Principal) IsVerified() bool {
|
||||||
|
return p != nil && p.VerificationStatus == model.VerificationStatusApproved
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithContext 把 Principal 写入 gin.Context。
|
||||||
|
func WithContext(c *gin.Context, p *Principal) {
|
||||||
|
c.Set(string(contextKey), p)
|
||||||
|
// 同时保留 user_id / username 字符串键以兼容存量 handler 调用。
|
||||||
|
c.Set("user_id", p.UserID)
|
||||||
|
c.Set("username", p.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrincipal 从 gin.Context 读取 Principal,返回是否已认证。
|
||||||
|
func GetPrincipal(c *gin.Context) (*Principal, bool) {
|
||||||
|
v, ok := c.Get(string(contextKey))
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
p, ok := v.(*Principal)
|
||||||
|
return p, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustPrincipal 从 gin.Context 读取 Principal,未认证时返回 nil。
|
||||||
|
// 调用方应仅在 RequireAuth 之后使用。
|
||||||
|
func MustPrincipal(c *gin.Context) *Principal {
|
||||||
|
p, _ := GetPrincipal(c)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrincipalCacheKey 返回某用户的 Principal 缓存键。
|
||||||
|
//
|
||||||
|
// 缓存键与 middleware.RequireAuth / InvalidatePrincipalCache 共享,
|
||||||
|
// 定义在本包以避免 service → middleware 的包循环(service 仅需失效键,
|
||||||
|
// middleware 负责读写 Principal 实体)。
|
||||||
|
func PrincipalCacheKey(userID string) string {
|
||||||
|
return "auth:principal:" + userID
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionCacheKey 返回某会话有效性缓存键。
|
||||||
|
func SessionCacheKey(sessionID string) string {
|
||||||
|
return "auth:session:" + sessionID + ":valid"
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheKeyPrefix 命名空间前缀,用于按前缀批量失效。
|
||||||
|
const (
|
||||||
|
CacheKeyPrefixPrincipal = "auth:principal:"
|
||||||
|
CacheKeyPrefixSession = "auth:session:"
|
||||||
|
)
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
package avatar
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
|
||||||
"unicode/utf8"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 预定义一组好看的颜色
|
|
||||||
var colors = []string{
|
|
||||||
"#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4",
|
|
||||||
"#FFEAA7", "#DDA0DD", "#98D8C8", "#F7DC6F",
|
|
||||||
"#BB8FCE", "#85C1E9", "#F8B500", "#00CED1",
|
|
||||||
"#E74C3C", "#3498DB", "#2ECC71", "#9B59B6",
|
|
||||||
"#1ABC9C", "#F39C12", "#E67E22", "#16A085",
|
|
||||||
}
|
|
||||||
|
|
||||||
// SVG模板
|
|
||||||
const svgTemplate = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="%d" height="%d">
|
|
||||||
<rect width="100" height="100" fill="%s"/>
|
|
||||||
<text x="50" y="50" font-family="Arial, sans-serif" font-size="40" font-weight="bold" fill="#ffffff" text-anchor="middle" dominant-baseline="central">%s</text>
|
|
||||||
</svg>`
|
|
||||||
|
|
||||||
// GenerateSVGAvatar 根据用户名生成SVG头像
|
|
||||||
// username: 用户名
|
|
||||||
// size: 头像尺寸(像素)
|
|
||||||
func GenerateSVGAvatar(username string, size int) string {
|
|
||||||
initials := getInitials(username)
|
|
||||||
color := stringToColor(username)
|
|
||||||
return fmt.Sprintf(svgTemplate, size, size, color, initials)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateAvatarDataURI 生成Data URI格式的头像
|
|
||||||
// 可以直接在HTML img标签或CSS background-image中使用
|
|
||||||
func GenerateAvatarDataURI(username string, size int) string {
|
|
||||||
svg := GenerateSVGAvatar(username, size)
|
|
||||||
encoded := base64.StdEncoding.EncodeToString([]byte(svg))
|
|
||||||
return fmt.Sprintf("data:image/svg+xml;base64,%s", encoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getInitials 获取用户名首字母
|
|
||||||
// 中文取第一个字,英文取首字母(最多2个)
|
|
||||||
func getInitials(username string) string {
|
|
||||||
if username == "" {
|
|
||||||
return "?"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否是中文字符
|
|
||||||
firstRune, _ := utf8.DecodeRuneInString(username)
|
|
||||||
if isChinese(firstRune) {
|
|
||||||
// 中文直接返回第一个字符
|
|
||||||
return string(firstRune)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 英文处理:取前两个单词的首字母
|
|
||||||
// 例如: "John Doe" -> "JD", "john" -> "J"
|
|
||||||
result := []rune{}
|
|
||||||
for i, r := range username {
|
|
||||||
if i == 0 {
|
|
||||||
result = append(result, toUpper(r))
|
|
||||||
} else if r == ' ' || r == '_' || r == '-' {
|
|
||||||
// 找到下一个字符作为第二个首字母
|
|
||||||
nextIdx := i + 1
|
|
||||||
if nextIdx < len(username) {
|
|
||||||
nextRune, _ := utf8.DecodeRuneInString(username[nextIdx:])
|
|
||||||
if nextRune != utf8.RuneError && nextRune != ' ' {
|
|
||||||
result = append(result, toUpper(nextRune))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(result) == 0 {
|
|
||||||
return "?"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 最多返回2个字符
|
|
||||||
if len(result) > 2 {
|
|
||||||
result = result[:2]
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// isChinese 判断是否是中文字符
|
|
||||||
func isChinese(r rune) bool {
|
|
||||||
return r >= 0x4E00 && r <= 0x9FFF
|
|
||||||
}
|
|
||||||
|
|
||||||
// toUpper 将字母转换为大写
|
|
||||||
func toUpper(r rune) rune {
|
|
||||||
if r >= 'a' && r <= 'z' {
|
|
||||||
return r - 32
|
|
||||||
}
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// stringToColor 根据字符串生成颜色
|
|
||||||
// 使用简单的哈希算法确保同一用户名每次生成的颜色一致
|
|
||||||
func stringToColor(s string) string {
|
|
||||||
if s == "" {
|
|
||||||
return colors[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
hash := 0
|
|
||||||
for _, r := range s {
|
|
||||||
hash = (hash*31 + int(r)) % len(colors)
|
|
||||||
}
|
|
||||||
if hash < 0 {
|
|
||||||
hash = -hash
|
|
||||||
}
|
|
||||||
|
|
||||||
return colors[hash%len(colors)]
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
package avatar
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGetInitials(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
username string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"中文用户名", "张三", "张"},
|
|
||||||
{"英文用户名", "John", "J"},
|
|
||||||
{"英文全名", "John Doe", "JD"},
|
|
||||||
{"带下划线", "john_doe", "JD"},
|
|
||||||
{"带连字符", "john-doe", "JD"},
|
|
||||||
{"空字符串", "", "?"},
|
|
||||||
{"小写英文", "alice", "A"},
|
|
||||||
{"中文复合", "李小龙", "李"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := getInitials(tt.username)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("getInitials(%q) = %q, want %q", tt.username, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStringToColor(t *testing.T) {
|
|
||||||
// 测试同一用户名生成的颜色一致
|
|
||||||
color1 := stringToColor("张三")
|
|
||||||
color2 := stringToColor("张三")
|
|
||||||
if color1 != color2 {
|
|
||||||
t.Errorf("stringToColor should return consistent colors for the same input")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试不同用户名生成不同颜色(大概率)
|
|
||||||
color3 := stringToColor("李四")
|
|
||||||
if color1 == color3 {
|
|
||||||
t.Logf("Warning: different usernames generated the same color (possible but unlikely)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试空字符串
|
|
||||||
color4 := stringToColor("")
|
|
||||||
if color4 == "" {
|
|
||||||
t.Errorf("stringToColor should return a color for empty string")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证颜色格式
|
|
||||||
if !strings.HasPrefix(color4, "#") {
|
|
||||||
t.Errorf("stringToColor should return hex color format starting with #")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateSVGAvatar(t *testing.T) {
|
|
||||||
svg := GenerateSVGAvatar("张三", 100)
|
|
||||||
|
|
||||||
// 验证SVG结构
|
|
||||||
if !strings.Contains(svg, "<svg") {
|
|
||||||
t.Errorf("SVG should contain <svg tag")
|
|
||||||
}
|
|
||||||
if !strings.Contains(svg, "</svg>") {
|
|
||||||
t.Errorf("SVG should contain </svg> tag")
|
|
||||||
}
|
|
||||||
if !strings.Contains(svg, "width=\"100\"") {
|
|
||||||
t.Errorf("SVG should have width=100")
|
|
||||||
}
|
|
||||||
if !strings.Contains(svg, "height=\"100\"") {
|
|
||||||
t.Errorf("SVG should have height=100")
|
|
||||||
}
|
|
||||||
if !strings.Contains(svg, "张") {
|
|
||||||
t.Errorf("SVG should contain the initial character")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateAvatarDataURI(t *testing.T) {
|
|
||||||
dataURI := GenerateAvatarDataURI("张三", 100)
|
|
||||||
|
|
||||||
// 验证Data URI格式
|
|
||||||
if !strings.HasPrefix(dataURI, "data:image/svg+xml;base64,") {
|
|
||||||
t.Errorf("Data URI should start with data:image/svg+xml;base64,")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证base64部分不为空
|
|
||||||
parts := strings.Split(dataURI, ",")
|
|
||||||
if len(parts) != 2 {
|
|
||||||
t.Errorf("Data URI should have two parts separated by comma")
|
|
||||||
}
|
|
||||||
if parts[1] == "" {
|
|
||||||
t.Errorf("Base64 part should not be empty")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsChinese(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
r rune
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{'中', true},
|
|
||||||
{'文', true},
|
|
||||||
{'a', false},
|
|
||||||
{'Z', false},
|
|
||||||
{'0', false},
|
|
||||||
{'_', false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
got := isChinese(tt.r)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("isChinese(%q) = %v, want %v", tt.r, got, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -22,15 +22,6 @@ type Config struct {
|
|||||||
Name string // 熔断器名称,用于日志
|
Name string // 熔断器名称,用于日志
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultConfig(name string) Config {
|
|
||||||
return Config{
|
|
||||||
FailureThreshold: 5,
|
|
||||||
SuccessThreshold: 3,
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
Name: name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Breaker struct {
|
type Breaker struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
package crypto
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// BatchDecryptResult 批量解密结果
|
|
||||||
type BatchDecryptResult struct {
|
|
||||||
Index int
|
|
||||||
Plaintext []byte
|
|
||||||
Error error
|
|
||||||
}
|
|
||||||
|
|
||||||
// BatchDecrypt 批量并行解密
|
|
||||||
// workerCount 指定并行工作数,如果 <= 0 则使用 CPU 核数
|
|
||||||
func (e *MessageEncryptor) BatchDecrypt(ciphertexts []string, workerCount int) [][]byte {
|
|
||||||
if e == nil || len(ciphertexts) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
results := make([][]byte, len(ciphertexts))
|
|
||||||
|
|
||||||
// 小批量直接串行处理
|
|
||||||
if len(ciphertexts) <= 10 {
|
|
||||||
for i, ct := range ciphertexts {
|
|
||||||
if ct == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
plaintext, err := e.Decrypt(ct)
|
|
||||||
if err == nil {
|
|
||||||
results[i] = plaintext
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
// 并行处理
|
|
||||||
jobs := make(chan int, len(ciphertexts))
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
|
|
||||||
// 确定工作数
|
|
||||||
if workerCount <= 0 {
|
|
||||||
workerCount = 4 // 默认4个并行
|
|
||||||
}
|
|
||||||
if workerCount > len(ciphertexts) {
|
|
||||||
workerCount = len(ciphertexts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 启动 worker
|
|
||||||
for w := 0; w < workerCount; w++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
for i := range jobs {
|
|
||||||
ct := ciphertexts[i]
|
|
||||||
if ct == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
plaintext, err := e.Decrypt(ct)
|
|
||||||
if err == nil {
|
|
||||||
results[i] = plaintext
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分发任务
|
|
||||||
for i := range ciphertexts {
|
|
||||||
jobs <- i
|
|
||||||
}
|
|
||||||
close(jobs)
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
// DecryptMessageSegments 解密消息段 JSON
|
|
||||||
// 这是一个便捷方法,直接返回解析后的 MessageSegments
|
|
||||||
func (e *MessageEncryptor) DecryptMessageSegments(ciphertext string) ([]byte, error) {
|
|
||||||
if e == nil || ciphertext == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return e.Decrypt(ciphertext)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TryParseMessageSegments 尝试解析消息段
|
|
||||||
// 先尝试解密,如果失败则尝试直接解析(兼容未加密的旧数据)
|
|
||||||
func TryParseMessageSegments(ciphertext string, segments any) error {
|
|
||||||
if ciphertext == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
encryptor := GetMessageEncryptor()
|
|
||||||
if encryptor == nil {
|
|
||||||
// 加密器未初始化,直接解析
|
|
||||||
return json.Unmarshal([]byte(ciphertext), segments)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 尝试解密
|
|
||||||
plaintext, err := encryptor.Decrypt(ciphertext)
|
|
||||||
if err != nil {
|
|
||||||
// 解密失败,可能是未加密的旧数据,尝试直接解析
|
|
||||||
return json.Unmarshal([]byte(ciphertext), segments)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析解密后的数据
|
|
||||||
return json.Unmarshal(plaintext, segments)
|
|
||||||
}
|
|
||||||
@@ -21,36 +21,6 @@ func BenchmarkEncryptDecryptSingle(b *testing.B) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// BenchmarkBatchDecrypt 批量并行解密性能基准
|
|
||||||
func BenchmarkBatchDecrypt(b *testing.B) {
|
|
||||||
key := "12345678901234567890123456789012"
|
|
||||||
_ = InitMessageEncryptor(key, 1)
|
|
||||||
encryptor := GetMessageEncryptor()
|
|
||||||
|
|
||||||
// 准备测试数据
|
|
||||||
sizes := []int{10, 50, 100, 500}
|
|
||||||
|
|
||||||
for _, size := range sizes {
|
|
||||||
b.Run(fmt.Sprintf("size_%d", size), func(b *testing.B) {
|
|
||||||
// 生成加密数据
|
|
||||||
ciphertexts := make([]string, size)
|
|
||||||
for i := 0; i < size; i++ {
|
|
||||||
msg := map[string]interface{}{
|
|
||||||
"type": "text",
|
|
||||||
"data": map[string]string{"content": fmt.Sprintf("消息内容 %d", i)},
|
|
||||||
}
|
|
||||||
plaintext, _ := json.Marshal(msg)
|
|
||||||
ciphertexts[i], _ = encryptor.Encrypt(plaintext)
|
|
||||||
}
|
|
||||||
|
|
||||||
b.ResetTimer()
|
|
||||||
for i := 0; i < b.N; i++ {
|
|
||||||
_ = encryptor.BatchDecrypt(ciphertexts, 4)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// BenchmarkSerialDecrypt 串行解密性能基准(对比用)
|
// BenchmarkSerialDecrypt 串行解密性能基准(对比用)
|
||||||
func BenchmarkSerialDecrypt(b *testing.B) {
|
func BenchmarkSerialDecrypt(b *testing.B) {
|
||||||
key := "12345678901234567890123456789012"
|
key := "12345678901234567890123456789012"
|
||||||
@@ -81,12 +51,41 @@ func BenchmarkSerialDecrypt(b *testing.B) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BenchmarkBatchDecrypt 批量并行解密性能基准
|
||||||
|
func BenchmarkBatchDecrypt(b *testing.B) {
|
||||||
|
key := "12345678901234567890123456789012"
|
||||||
|
_ = InitMessageEncryptor(key, 1)
|
||||||
|
encryptor := GetMessageEncryptor()
|
||||||
|
|
||||||
|
sizes := []int{10, 50, 100, 500}
|
||||||
|
|
||||||
|
for _, size := range sizes {
|
||||||
|
b.Run(fmt.Sprintf("size_%d", size), func(b *testing.B) {
|
||||||
|
// 生成加密数据
|
||||||
|
ciphertexts := make([]string, size)
|
||||||
|
for i := 0; i < size; i++ {
|
||||||
|
msg := map[string]interface{}{
|
||||||
|
"type": "text",
|
||||||
|
"data": map[string]string{"content": fmt.Sprintf("消息内容 %d", i)},
|
||||||
|
}
|
||||||
|
plaintext, _ := json.Marshal(msg)
|
||||||
|
ciphertexts[i], _ = encryptor.Encrypt(plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = encryptor.BatchDecrypt(ciphertexts, 4)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBatchDecrypt 验证批量解密与单条解密结果一致
|
||||||
func TestBatchDecrypt(t *testing.T) {
|
func TestBatchDecrypt(t *testing.T) {
|
||||||
key := "12345678901234567890123456789012"
|
key := "12345678901234567890123456789012"
|
||||||
_ = InitMessageEncryptor(key, 1)
|
_ = InitMessageEncryptor(key, 1)
|
||||||
encryptor := GetMessageEncryptor()
|
encryptor := GetMessageEncryptor()
|
||||||
|
|
||||||
// 准备测试数据
|
|
||||||
count := 100
|
count := 100
|
||||||
ciphertexts := make([]string, count)
|
ciphertexts := make([]string, count)
|
||||||
expectedContents := make([]string, count)
|
expectedContents := make([]string, count)
|
||||||
@@ -105,6 +104,10 @@ func TestBatchDecrypt(t *testing.T) {
|
|||||||
// 批量解密
|
// 批量解密
|
||||||
results := encryptor.BatchDecrypt(ciphertexts, 4)
|
results := encryptor.BatchDecrypt(ciphertexts, 4)
|
||||||
|
|
||||||
|
if len(results) != count {
|
||||||
|
t.Fatalf("BatchDecrypt returned %d results, want %d", len(results), count)
|
||||||
|
}
|
||||||
|
|
||||||
// 验证结果
|
// 验证结果
|
||||||
for i, result := range results {
|
for i, result := range results {
|
||||||
if result == nil {
|
if result == nil {
|
||||||
|
|||||||
@@ -139,35 +139,70 @@ func (e *MessageEncryptor) GetKeyVersion() int {
|
|||||||
return e.keyVersion
|
return e.keyVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
// RotateKey 密钥轮换(用于密钥升级)
|
// BatchDecrypt 批量解密多条密文,返回与输入等长的明文字节切片数组。
|
||||||
// 新密钥必须也是32字节
|
// 并发度由 workers 指定(<=0 时按密文数量自适应)。
|
||||||
func (e *MessageEncryptor) RotateKey(newKey string, newVersion int) error {
|
// 解密失败或空密文的位置返回 nil,调用方可据此判断。
|
||||||
keyBytes := []byte(newKey)
|
// 这是 Decrypt 的无锁读优化版:AEAD 的 Open 不修改内部状态,
|
||||||
if len(keyBytes) != 32 {
|
// 但仍走 RLock 以兼容未来可能的密钥轮换语义。
|
||||||
return ErrInvalidKey
|
func (e *MessageEncryptor) BatchDecrypt(ciphertexts []string, workers int) [][]byte {
|
||||||
|
results := make([][]byte, len(ciphertexts))
|
||||||
|
if e == nil || len(ciphertexts) == 0 {
|
||||||
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
block, err := aes.NewCipher(keyBytes)
|
// 自适应并发度:与历史实现保持一致
|
||||||
if err != nil {
|
if workers <= 0 {
|
||||||
return err
|
switch {
|
||||||
|
case len(ciphertexts) < 20:
|
||||||
|
workers = 2
|
||||||
|
case len(ciphertexts) > 100:
|
||||||
|
workers = 8
|
||||||
|
default:
|
||||||
|
workers = 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if workers > len(ciphertexts) {
|
||||||
|
workers = len(ciphertexts)
|
||||||
}
|
}
|
||||||
|
|
||||||
gcm, err := cipher.NewGCM(block)
|
// 预取一次 AEAD,避免 worker 内重复进入 RLock
|
||||||
if err != nil {
|
e.mu.RLock()
|
||||||
return err
|
gcm := e.gcm
|
||||||
|
nonceSize := e.gcm.NonceSize()
|
||||||
|
e.mu.RUnlock()
|
||||||
|
|
||||||
|
jobs := make(chan int, len(ciphertexts))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for w := 0; w < workers; w++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := range jobs {
|
||||||
|
ct := ciphertexts[i]
|
||||||
|
if ct == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(ct)
|
||||||
|
if err != nil || len(raw) < nonceSize {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nonce := raw[:nonceSize]
|
||||||
|
actual := raw[nonceSize:]
|
||||||
|
plaintext, err := gcm.Open(nil, nonce, actual, nil)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results[i] = plaintext
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
e.mu.Lock()
|
for i := range ciphertexts {
|
||||||
defer e.mu.Unlock()
|
jobs <- i
|
||||||
|
}
|
||||||
|
close(jobs)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
e.key = keyBytes
|
return results
|
||||||
e.gcm = gcm
|
|
||||||
e.keyVersion = newVersion
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsInitialized 检查加密器是否已初始化
|
|
||||||
func IsInitialized() bool {
|
|
||||||
return globalEncryptor != nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,16 +150,6 @@ func TestMessageEncryptor_KeyVersion(t *testing.T) {
|
|||||||
if encryptor.GetKeyVersion() != 1 {
|
if encryptor.GetKeyVersion() != 1 {
|
||||||
t.Errorf("GetKeyVersion() = %d, want 1", encryptor.GetKeyVersion())
|
t.Errorf("GetKeyVersion() = %d, want 1", encryptor.GetKeyVersion())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 测试密钥轮换
|
|
||||||
err = encryptor.RotateKey("abcdefghijklmnopqrstuvwxyz123456", 2)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if encryptor.GetKeyVersion() != 2 {
|
|
||||||
t.Errorf("GetKeyVersion() after rotation = %d, want 2", encryptor.GetKeyVersion())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustMarshal(v interface{}) []byte {
|
func mustMarshal(v interface{}) []byte {
|
||||||
|
|||||||
@@ -64,19 +64,6 @@ func (b *CursorBuilder) WithPageSize(size int) *CursorBuilder {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithDirection 设置分页方向
|
|
||||||
func (b *CursorBuilder) WithDirection(direction Direction) *CursorBuilder {
|
|
||||||
if b.err != nil {
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
if direction == "" {
|
|
||||||
b.direction = Forward
|
|
||||||
} else {
|
|
||||||
b.direction = direction
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error 返回构建过程中的错误
|
// Error 返回构建过程中的错误
|
||||||
func (b *CursorBuilder) Error() error {
|
func (b *CursorBuilder) Error() error {
|
||||||
@@ -166,79 +153,11 @@ func HasMore(itemsLen int, pageSize int) bool {
|
|||||||
return itemsLen > pageSize
|
return itemsLen > pageSize
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrimItems 裁剪结果到实际页面大小
|
|
||||||
func TrimItems(itemsLen int, pageSize int) int {
|
|
||||||
if itemsLen > pageSize {
|
|
||||||
return pageSize
|
|
||||||
}
|
|
||||||
return itemsLen
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildResponse 从结果构建响应
|
|
||||||
// items: 结果切片(需要是切片类型)
|
|
||||||
// getSortValue: 获取排序字段值的函数
|
|
||||||
// getID: 获取ID的函数
|
|
||||||
func BuildResponse[T any](items []T, order SortOrder, pageSize int, getSortValue func(T) string, getID func(T) string) *PageResponse {
|
|
||||||
hasMore := len(items) > pageSize
|
|
||||||
|
|
||||||
// 裁剪到实际页面大小
|
|
||||||
if hasMore {
|
|
||||||
items = items[:pageSize]
|
|
||||||
}
|
|
||||||
|
|
||||||
var nextCursor, prevCursor string
|
|
||||||
|
|
||||||
// 生成下一页游标
|
|
||||||
if hasMore && len(items) > 0 {
|
|
||||||
lastItem := items[len(items)-1]
|
|
||||||
nextCursor = NewCursor(getSortValue(lastItem), getID(lastItem), order).Encode()
|
|
||||||
}
|
|
||||||
|
|
||||||
return &PageResponse{
|
|
||||||
Items: items,
|
|
||||||
NextCursor: nextCursor,
|
|
||||||
PrevCursor: prevCursor,
|
|
||||||
HasMore: hasMore,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildResponseWithPrev 从结果构建响应(包含上一页游标)
|
|
||||||
func BuildResponseWithPrev[T any](items []T, order SortOrder, pageSize int, getSortValue func(T) string, getID func(T) string) *PageResponse {
|
|
||||||
hasMore := len(items) > pageSize
|
|
||||||
|
|
||||||
// 裁剪到实际页面大小
|
|
||||||
if hasMore {
|
|
||||||
items = items[:pageSize]
|
|
||||||
}
|
|
||||||
|
|
||||||
var nextCursor, prevCursor string
|
|
||||||
|
|
||||||
// 生成下一页游标
|
|
||||||
if hasMore && len(items) > 0 {
|
|
||||||
lastItem := items[len(items)-1]
|
|
||||||
nextCursor = NewCursor(getSortValue(lastItem), getID(lastItem), order).Encode()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成上一页游标
|
|
||||||
if len(items) > 0 {
|
|
||||||
firstItem := items[0]
|
|
||||||
prevCursor = NewCursor(getSortValue(firstItem), getID(firstItem), order).Encode()
|
|
||||||
}
|
|
||||||
|
|
||||||
return &PageResponse{
|
|
||||||
Items: items,
|
|
||||||
NextCursor: nextCursor,
|
|
||||||
PrevCursor: prevCursor,
|
|
||||||
HasMore: hasMore,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormatTime 格式化时间为字符串(用于游标)
|
// FormatTime 格式化时间为字符串(用于游标)
|
||||||
func FormatTime(t time.Time) string {
|
func FormatTime(t time.Time) string {
|
||||||
return t.Format(time.RFC3339Nano)
|
return t.Format(time.RFC3339Nano)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseTime 解析时间字符串(从游标)
|
|
||||||
func ParseTime(s string) (time.Time, error) {
|
|
||||||
return time.Parse(time.RFC3339Nano, s)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -99,15 +99,6 @@ type PageResponse struct {
|
|||||||
HasMore bool `json:"has_more"`
|
HasMore bool `json:"has_more"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPageResponse 创建分页响应
|
|
||||||
func NewPageResponse(items any, nextCursor, prevCursor string, hasMore bool) *PageResponse {
|
|
||||||
return &PageResponse{
|
|
||||||
Items: items,
|
|
||||||
NextCursor: nextCursor,
|
|
||||||
PrevCursor: prevCursor,
|
|
||||||
HasMore: hasMore,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CursorPageResult 游标分页结果(泛型版本,供内部使用)
|
// CursorPageResult 游标分页结果(泛型版本,供内部使用)
|
||||||
type CursorPageResult[T any] struct {
|
type CursorPageResult[T any] struct {
|
||||||
|
|||||||
@@ -187,11 +187,6 @@ func WithAsync() HookOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithTimeout(timeout time.Duration) HookOption {
|
|
||||||
return func(h *Hook) {
|
|
||||||
h.Timeout = timeout
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) Trigger(ctx context.Context, hookType HookType, userID uint, data any) error {
|
func (m *Manager) Trigger(ctx context.Context, hookType HookType, userID uint, data any) error {
|
||||||
return m.TriggerWithMetadata(ctx, hookType, userID, data, nil)
|
return m.TriggerWithMetadata(ctx, hookType, userID, data, nil)
|
||||||
@@ -220,6 +215,7 @@ func (m *Manager) TriggerWithMetadata(ctx context.Context, hookType HookType, us
|
|||||||
|
|
||||||
var asyncHooks []*hookEntry
|
var asyncHooks []*hookEntry
|
||||||
|
|
||||||
|
var firstSyncErr error
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if !entry.hook.Enabled {
|
if !entry.hook.Enabled {
|
||||||
continue
|
continue
|
||||||
@@ -231,6 +227,9 @@ func (m *Manager) TriggerWithMetadata(ctx context.Context, hookType HookType, us
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := m.executeHook(entry, hookCtx); err != nil {
|
if err := m.executeHook(entry, hookCtx); err != nil {
|
||||||
|
if firstSyncErr == nil {
|
||||||
|
firstSyncErr = err
|
||||||
|
}
|
||||||
zap.L().Error("Hook execution failed",
|
zap.L().Error("Hook execution failed",
|
||||||
zap.String("name", entry.hook.Name),
|
zap.String("name", entry.hook.Name),
|
||||||
zap.String("type", string(hookType)),
|
zap.String("type", string(hookType)),
|
||||||
@@ -261,12 +260,29 @@ func (m *Manager) TriggerWithMetadata(ctx context.Context, hookType HookType, us
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return firstSyncErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) executeHook(entry *hookEntry, ctx *HookContext) error {
|
func (m *Manager) executeHook(entry *hookEntry, ctx *HookContext) error {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
|
// Derive a cancellable context bounded by the hook's Timeout so that
|
||||||
|
// long-running hook work (e.g. HTTP calls to the moderation API) is
|
||||||
|
// actually cancelled when the timeout fires, instead of leaking and
|
||||||
|
// racing on shared metadata after executeHook returns.
|
||||||
|
if entry.hook.Timeout > 0 {
|
||||||
|
hookCtx, cancel := context.WithTimeout(ctx.Ctx, entry.hook.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ctx = &HookContext{
|
||||||
|
Ctx: hookCtx,
|
||||||
|
HookType: ctx.HookType,
|
||||||
|
UserID: ctx.UserID,
|
||||||
|
Data: ctx.Data,
|
||||||
|
Metadata: ctx.Metadata,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
if entry.hook.Timeout > 0 {
|
if entry.hook.Timeout > 0 {
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
@@ -285,13 +301,17 @@ func (m *Manager) executeHook(entry *hookEntry, ctx *HookContext) error {
|
|||||||
|
|
||||||
select {
|
select {
|
||||||
case err = <-done:
|
case err = <-done:
|
||||||
case <-time.After(entry.hook.Timeout):
|
case <-ctx.Ctx.Done():
|
||||||
err = context.DeadlineExceeded
|
err = ctx.Ctx.Err()
|
||||||
zap.L().Warn("Hook timeout",
|
zap.L().Warn("Hook timeout",
|
||||||
zap.String("name", entry.hook.Name),
|
zap.String("name", entry.hook.Name),
|
||||||
zap.Duration("timeout", entry.hook.Timeout),
|
zap.Duration("timeout", entry.hook.Timeout),
|
||||||
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Wait for the spawned goroutine to observe cancellation and return,
|
||||||
|
// so it cannot keep writing to ctx.Metadata after executeHook returns.
|
||||||
|
<-done
|
||||||
} else {
|
} else {
|
||||||
err = entry.hook.Func(ctx)
|
err = entry.hook.Func(ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -382,40 +383,69 @@ func (m *ModerationHooks) RegisterAll(manager *Manager) {
|
|||||||
|
|
||||||
func (m *ModerationHooks) ModeratePost(ctx context.Context, manager *Manager, data *PostModerateHookData) *ModerationResult {
|
func (m *ModerationHooks) ModeratePost(ctx context.Context, manager *Manager, data *PostModerateHookData) *ModerationResult {
|
||||||
result := &ModerationResult{}
|
result := &ModerationResult{}
|
||||||
manager.TriggerWithMetadata(ctx, HookPostPreModerate, data.AuthorID, data, map[string]any{
|
if err := manager.TriggerWithMetadata(ctx, HookPostPreModerate, data.AuthorID, data, map[string]any{
|
||||||
"result": result,
|
"result": result,
|
||||||
})
|
}); err != nil {
|
||||||
|
applyModerationFallback(result, err)
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ModerationHooks) ModerateComment(ctx context.Context, manager *Manager, data *CommentModerateHookData) *ModerationResult {
|
func (m *ModerationHooks) ModerateComment(ctx context.Context, manager *Manager, data *CommentModerateHookData) *ModerationResult {
|
||||||
result := &ModerationResult{}
|
result := &ModerationResult{}
|
||||||
manager.TriggerWithMetadata(ctx, HookCommentPreModerate, data.AuthorID, data, map[string]any{
|
if err := manager.TriggerWithMetadata(ctx, HookCommentPreModerate, data.AuthorID, data, map[string]any{
|
||||||
"result": result,
|
"result": result,
|
||||||
})
|
}); err != nil {
|
||||||
|
applyModerationFallback(result, err)
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ModerationHooks) ModerateAvatar(ctx context.Context, manager *Manager, data *UserAvatarModerateHookData) *ModerationResult {
|
func (m *ModerationHooks) ModerateAvatar(ctx context.Context, manager *Manager, data *UserAvatarModerateHookData) *ModerationResult {
|
||||||
result := &ModerationResult{}
|
result := &ModerationResult{}
|
||||||
manager.TriggerWithMetadata(ctx, HookUserAvatarPreModerate, 0, data, map[string]any{
|
if err := manager.TriggerWithMetadata(ctx, HookUserAvatarPreModerate, 0, data, map[string]any{
|
||||||
"result": result,
|
"result": result,
|
||||||
})
|
}); err != nil {
|
||||||
|
applyModerationFallback(result, err)
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ModerationHooks) ModerateCover(ctx context.Context, manager *Manager, data *UserCoverModerateHookData) *ModerationResult {
|
func (m *ModerationHooks) ModerateCover(ctx context.Context, manager *Manager, data *UserCoverModerateHookData) *ModerationResult {
|
||||||
result := &ModerationResult{}
|
result := &ModerationResult{}
|
||||||
manager.TriggerWithMetadata(ctx, HookUserCoverPreModerate, 0, data, map[string]any{
|
if err := manager.TriggerWithMetadata(ctx, HookUserCoverPreModerate, 0, data, map[string]any{
|
||||||
"result": result,
|
"result": result,
|
||||||
})
|
}); err != nil {
|
||||||
|
applyModerationFallback(result, err)
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ModerationHooks) ModerateBio(ctx context.Context, manager *Manager, data *UserBioModerateHookData) *ModerationResult {
|
func (m *ModerationHooks) ModerateBio(ctx context.Context, manager *Manager, data *UserBioModerateHookData) *ModerationResult {
|
||||||
result := &ModerationResult{}
|
result := &ModerationResult{}
|
||||||
manager.TriggerWithMetadata(ctx, HookUserBioPreModerate, 0, data, map[string]any{
|
if err := manager.TriggerWithMetadata(ctx, HookUserBioPreModerate, 0, data, map[string]any{
|
||||||
"result": result,
|
"result": result,
|
||||||
})
|
}); err != nil {
|
||||||
|
applyModerationFallback(result, err)
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyModerationFallback handles moderation hook timeouts/cancellations by
|
||||||
|
// falling back to "approved by system" instead of leaving the zero-value
|
||||||
|
// result (Approved=false) which would silently reject otherwise-fine content.
|
||||||
|
func applyModerationFallback(result *ModerationResult, err error) {
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||||
|
result.Approved = true
|
||||||
|
result.ReviewedBy = "system"
|
||||||
|
result.RejectReason = ""
|
||||||
|
result.NeedsReview = false
|
||||||
|
result.Error = err
|
||||||
|
zap.L().Warn("Moderation hook timed out or was cancelled, fallback approve",
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package hook
|
package hook
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -196,144 +194,3 @@ func (b *BuiltinHooks) registerLoggingHooks() {
|
|||||||
func (b *BuiltinHooks) registerCacheInvalidationHooks() {
|
func (b *BuiltinHooks) registerCacheInvalidationHooks() {
|
||||||
}
|
}
|
||||||
|
|
||||||
type HookTriggerHelper struct {
|
|
||||||
manager *Manager
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewHookTriggerHelper(manager *Manager) *HookTriggerHelper {
|
|
||||||
return &HookTriggerHelper{manager: manager}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerPostCreated(ctx context.Context, userID uint, data *PostHookData) {
|
|
||||||
h.manager.Trigger(ctx, HookPostCreated, userID, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerPostUpdated(ctx context.Context, userID uint, data *PostHookData) {
|
|
||||||
h.manager.Trigger(ctx, HookPostUpdated, userID, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerPostDeleted(ctx context.Context, userID uint, postID uint) {
|
|
||||||
h.manager.Trigger(ctx, HookPostDeleted, userID, postID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerCommentCreated(ctx context.Context, userID uint, data *CommentHookData) {
|
|
||||||
h.manager.Trigger(ctx, HookCommentCreated, userID, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerCommentDeleted(ctx context.Context, userID uint, commentID uint) {
|
|
||||||
h.manager.Trigger(ctx, HookCommentDeleted, userID, commentID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerUserCreated(ctx context.Context, data *UserHookData) {
|
|
||||||
h.manager.Trigger(ctx, HookUserCreated, data.UserID, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerUserUpdated(ctx context.Context, userID uint, data *UserHookData) {
|
|
||||||
h.manager.Trigger(ctx, HookUserUpdated, userID, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerUserDeleted(ctx context.Context, userID uint) {
|
|
||||||
h.manager.Trigger(ctx, HookUserDeleted, userID, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerUserLogin(ctx context.Context, userID uint, data *UserHookData) {
|
|
||||||
h.manager.Trigger(ctx, HookUserLogin, userID, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerUserLogout(ctx context.Context, userID uint) {
|
|
||||||
h.manager.Trigger(ctx, HookUserLogout, userID, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerGroupCreated(ctx context.Context, userID uint, data *GroupHookData) {
|
|
||||||
h.manager.Trigger(ctx, HookGroupCreated, userID, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerGroupUpdated(ctx context.Context, userID uint, data *GroupHookData) {
|
|
||||||
h.manager.Trigger(ctx, HookGroupUpdated, userID, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerGroupDeleted(ctx context.Context, userID uint, groupID uint) {
|
|
||||||
h.manager.Trigger(ctx, HookGroupDeleted, userID, groupID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerGroupJoined(ctx context.Context, userID uint, groupID uint) {
|
|
||||||
h.manager.Trigger(ctx, HookGroupJoined, userID, groupID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerGroupLeft(ctx context.Context, userID uint, groupID uint) {
|
|
||||||
h.manager.Trigger(ctx, HookGroupLeft, userID, groupID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerMessageSent(ctx context.Context, userID uint, data *MessageHookData) {
|
|
||||||
h.manager.Trigger(ctx, HookMessageSent, userID, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerMessageRead(ctx context.Context, userID uint, messageID uint) {
|
|
||||||
h.manager.Trigger(ctx, HookMessageRead, userID, messageID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerNotificationSent(ctx context.Context, userID uint, data *NotificationHookData) {
|
|
||||||
h.manager.Trigger(ctx, HookNotificationSent, userID, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerVoteCreated(ctx context.Context, userID uint, data *VoteHookData) {
|
|
||||||
h.manager.Trigger(ctx, HookVoteCreated, userID, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerVoteUpdated(ctx context.Context, userID uint, data *VoteHookData) {
|
|
||||||
h.manager.Trigger(ctx, HookVoteUpdated, userID, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerFileUploaded(ctx context.Context, userID uint, data *FileHookData) {
|
|
||||||
h.manager.Trigger(ctx, HookFileUploaded, userID, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerPostPreModerate(ctx context.Context, userID uint, data *PostModerateHookData, result *ModerationResult) {
|
|
||||||
h.manager.TriggerWithMetadata(ctx, HookPostPreModerate, userID, data, map[string]any{
|
|
||||||
"result": result,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerPostModerated(ctx context.Context, userID uint, data *PostModeratedHookData) {
|
|
||||||
h.manager.Trigger(ctx, HookPostModerated, userID, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerCommentPreModerate(ctx context.Context, userID uint, data *CommentModerateHookData, result *ModerationResult) {
|
|
||||||
h.manager.TriggerWithMetadata(ctx, HookCommentPreModerate, userID, data, map[string]any{
|
|
||||||
"result": result,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerCommentModerated(ctx context.Context, userID uint, data *CommentModeratedHookData) {
|
|
||||||
h.manager.Trigger(ctx, HookCommentModerated, userID, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerUserAvatarPreModerate(ctx context.Context, userID uint, data *UserAvatarModerateHookData, result *ModerationResult) {
|
|
||||||
h.manager.TriggerWithMetadata(ctx, HookUserAvatarPreModerate, userID, data, map[string]any{
|
|
||||||
"result": result,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerUserAvatarModerated(ctx context.Context, userID uint, data *UserAvatarModeratedHookData) {
|
|
||||||
h.manager.Trigger(ctx, HookUserAvatarModerated, userID, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerUserCoverPreModerate(ctx context.Context, userID uint, data *UserCoverModerateHookData, result *ModerationResult) {
|
|
||||||
h.manager.TriggerWithMetadata(ctx, HookUserCoverPreModerate, userID, data, map[string]any{
|
|
||||||
"result": result,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerUserCoverModerated(ctx context.Context, userID uint, data *UserCoverModeratedHookData) {
|
|
||||||
h.manager.Trigger(ctx, HookUserCoverModerated, userID, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerUserBioPreModerate(ctx context.Context, userID uint, data *UserBioModerateHookData, result *ModerationResult) {
|
|
||||||
h.manager.TriggerWithMetadata(ctx, HookUserBioPreModerate, userID, data, map[string]any{
|
|
||||||
"result": result,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HookTriggerHelper) TriggerUserBioModerated(ctx context.Context, userID uint, data *UserBioModeratedHookData) {
|
|
||||||
h.manager.Trigger(ctx, HookUserBioModerated, userID, data)
|
|
||||||
}
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user