Compare commits

...

27 Commits

Author SHA1 Message Date
lafay
a971fb0e29 feat(moderation): make moderation more lenient and add timeout fallback behavior
All checks were successful
Build Backend / build (push) Successful in 2m20s
Build Backend / build-docker (push) Successful in 1m7s
The moderation system now uses a "pass/review" strategy instead of "pass/review/block", treating hard violations the same as review (only routing to manual review rather than auto-rejecting). Add fallback-approve behavior in the hook system so slow moderation APIs never silently block normal content when they time out or get cancelled - timed-out moderation now approves with "ReviewedBy: system" instead of leaving the zero-value result that would reject content. Update post/comment/vote services and handlers to use the new unified behavior. Simplify moderation prompts to emphasize "宁可放过,不可误伤" (prefer false positives over false negatives).
2026-07-08 01:47:48 +08:00
lafay
eb931bf1c6 feat(auth): implement session-based token management with revocation support
All checks were successful
Build Backend / build (push) Successful in 2m19s
Build Backend / build-docker (push) Successful in 46s
Add Session model, SessionService, and SessionRepository to track user login
sessions and enable token revocation on auth-critical events.

- Introduce explicit TokenType (access/refresh) in JWT claims to prevent
  refresh token misuse via access token endpoints
- Add SessionID field to JWT claims, enabling stateless JWT validation
  against revoked sessions
- Replace legacy Auth middleware with RequireAuth/OptionalAuth pipeline
  that validates token type, account status, and session validity
- Implement session revocation on password change, reset, user ban/inactive,
  and explicit logout
- Add Principal cache with active invalidation for banned/role-changed users
- Fix IDOR vulnerability: GetMessagesByCursor now validates currentUserID
  is conversation participant via GetParticipantStrict
- Add group member visibility checks: announcements, group info, member list
  now require group membership
- Simplify Casbin policy: remove g grouping, use r.sub == p.sub matcher
  with globMatch; user_roles table is single source of truth for roles
- Add migration logic to clean legacy casbin g rules and migrate old p rules
  from path-style to admin/<domain> resource naming
2026-07-05 18:28:08 +08:00
lafay
d240485d0e feat(chat): add endpoint to get quoted message by ID
All checks were successful
Build Backend / build (push) Successful in 2m21s
Build Backend / build-docker (push) Successful in 58s
Add GET /api/v1/messages/:id endpoint to retrieve quoted/replied messages
for preview fill and navigation purposes. The endpoint includes authorization
checks ensuring only conversation participants can access the message.

Additionally improve file upload handling for native clients (expo-file-system)
by accepting explicit displayName from frontend, with sanitization to prevent
path traversal and 255-character length limit. File extension detection now
prioritizes the real display name over the multipart header filename.
2026-07-02 23:22:24 +08:00
lafay
d8e56e60dc feat(post): add idempotency support for post creation with client request ID
All checks were successful
Build Backend / build (push) Successful in 3m42s
Build Backend / build-docker (push) Successful in 1m7s
Add client_request_id field to post creation endpoints (CreateVotePost and CreatePostWithSegments).
When provided, the server uses Redis SetNX-based idempotency to prevent duplicate posts
from double-clicks or network retries within a 24-hour TTL. Duplicate requests return
the originally created post; concurrent in-flight requests return 429 "正在发布中,请稍候".
2026-06-26 17:00:38 +08:00
lafay
28cfcbf9a8 refactor(comment): unify status transitions with atomic count maintenance
All checks were successful
Build Backend / build (push) Successful in 3m31s
Build Backend / build-docker (push) Successful in 1m25s
Consolidate comment status changes through transitionStatus() to ensure
posts.comments_count and comments.replies_count are atomically maintained
within the same transaction, preventing inconsistencies between displayed
and stored counts.

- Remove separate ApplyPublishedStats() calls, now handled in UpdateModerationStatus
- Add cache invalidation for admin moderation operations
- Update report auto-hide to use unified status transition
2026-06-21 17:17:38 +08:00
lafay
322aa9eebb feat(worker): add device registration_id cleanup worker
All checks were successful
Build Backend / build (push) Successful in 2m18s
Build Backend / build-docker (push) Successful in 1m19s
Add DeviceCleanupWorker to periodically clean up stale device tokens that haven't
been used within the retention period. This prevents accumulation of invalid
registration_ids and maintains data hygiene.

- New DeviceCleanupConfig with enabled, retention_days (3), and interval_minutes (360)
- New repository methods DeleteStaleDevices and DeleteOldestActiveDevice
- Updated RegisterDevice to evict oldest active device when reaching limit
- Reduced MaxDevicesPerUser from 10 to 3 per user
- Worker integrated into app startup/shutdown lifecycle
2026-06-18 20:36:25 +08:00
lafay
cb85c62093 feat(admin): add admin endpoint to list user devices with JPush registration IDs
All checks were successful
Build Backend / build (push) Successful in 2m32s
Build Backend / build-docker (push) Successful in 6m22s
- Add AdminDeviceTokenResponse DTO including push_token (RegistrationID) for push debugging
- Add DeviceTokenToAdminResponse and DeviceTokensToAdminResponse converters
- Inject PushService into AdminUserHandler for retrieving user devices
- Register GET /api/v1/admin/users/:id/devices route for querying target user's device tokens
2026-06-18 20:07:24 +08:00
lafay
821e066446 feat(jpush): add vendor-specific message classification support for push notifications
All checks were successful
Build Backend / build (push) Successful in 3m4s
Build Backend / build-docker (push) Successful in 1m15s
- Add OPPO category/notify_level support (2024.11.20 regulation: category required when notify_level present)
- Add Honor importance field (NORMAL=service/LOW=marketing) for notification classification
- Add vivo category field (IM/ACCOUNT) for message scenario identification
- Set Xiaomi channel/template defaults in code (system=153609, chat=153608, templates P10761/M10289)
- Add classification option to JPush push payloads (0=operation, 1=system)
- Update xiaomi template keywords mapping to match actual template placeholders
- Add keyword search support for GetFollowers and GetFollowing endpoints
- Bind new config fields to environment variables for OPPO/Honor/vivo
2026-06-18 19:41:32 +08:00
lafay
5b83f98fb8 refactor(config): enable environment variable binding for database replica settings
All checks were successful
Build Backend / build (push) Successful in 2m21s
Build Backend / build-docker (push) Successful in 1m16s
Viper's AutomaticEnv does not bind nested keys when sections are commented out
in YAML. Add explicit BindEnv calls for APP_DATABASE_REPLICA_* variables so
they work when the replica section is not defined in config.yaml. Also bind
optional connection pool parameters (MAX_IDLE_CONNS, MAX_OPEN_CONNS).
2026-06-18 02:01:57 +08:00
lafay
755392e999 refactor(jpush): move vendor field handling into ThirdPartyChannel struct
All checks were successful
Build Backend / build (push) Successful in 2m20s
Build Backend / build-docker (push) Successful in 1m15s
Move the vendor switch logic into the ThirdPartyChannel struct as Set/IsEmpty/Normalize
methods to improve encapsulation and eliminate code duplication in the vendor params
builder. Also simplify messageToParams to reuse buildMessagePayload results and remove
unused notification_type extras from push notifications.
2026-06-18 00:02:00 +08:00
lafay
a0e210feab feat(server): implement chat file TTL cleanup and enhance JPush vendor channel support
All checks were successful
Build Backend / build (push) Successful in 3m1s
Build Backend / build-docker (push) Successful in 2m10s
Add FileCleanupWorker for automatic expiration of chat files with configurable retention period and batch processing. Files uploaded via `/api/v1/uploads/files` are tracked in `uploaded_files` table with expiration timestamps, then deleted from S3 and database upon expiry. Expired file URLs are injected as `"expired": true` in message responses.

Extend JPush push notification configuration with vendor-specific channel_id support (xiaomi, huawei, oppo, vivo, meizu, honor, fcm) for differentiating system vs chat notifications. Add iOS APNs thread-id grouping configuration for notification categorization.
2026-06-17 20:41:55 +08:00
lan
d9aa4b46c3 refactor(server): decouple services and improve architecture
All checks were successful
Build Backend / build (push) Successful in 4m55s
Build Backend / build-docker (push) Successful in 10m34s
- Introduce interfaces for all major services (JWT, PostAI, Comment, Message, Notification, QRCodeLogin, Upload, Vote, etc.) to support dependency inversion.
- Move query parameters from `internal/dto` to a new `internal/query` package to separate request payloads from data transfer objects.
- Refactor `internal/router` to use embedded `RouterDeps` for cleaner dependency management.
- Decouple handlers from repositories by injecting services instead of direct repository access, ensuring proper layering.
- Improve database initialization by moving it from `internal/model` to `internal/database`.
- Optimize message decryption by implementing a more efficient `BatchDecrypt` method in `MessageEncryptor` using a worker pool.
- Enhance error handling and security by implementing fail-fast checks for encryption key length during startup.
- Clean up unused code, including the `avatar` package and several unused DTOs.
2026-06-15 03:41:59 +08:00
lan
9951043034 chore(server): inject emptyClassroomHandler into dependency graph
All checks were successful
Build Backend / build (push) Successful in 2m8s
Build Backend / build-docker (push) Successful in 1m51s
2026-06-08 00:25:43 +08:00
lan
60654bc0f4 feat(call): implement group calling and enhance call management
All checks were successful
Build Backend / build (push) Successful in 2m14s
Build Backend / build-docker (push) Successful in 1m15s
Introduces group calling capabilities, including group invites and participant
lifecycle management. Enhances the existing call system with support for
media types (voice/video) and improved participant tracking.

- Add `GroupInvite` and `ParticipantJoin/Leave` to `CallService`.
- Implement WebSocket handlers for `call_group_invite` and
  `call_participant_join`.
- Update `CallSession` model to support group IDs, media types, and
  tracking who ended the call.
- Integrate `GroupService` into `CallService` via dependency injection.
- Add error constants for group call limitations and participant capacity.
- Update LiveKit webhook handling to process participant leave events.

Refactor grade synchronization to remove semester-based filtering in favor
of a more flexible user-based approach and improve GPA summary updates.
2026-06-07 00:11:47 +08:00
lan
c741a7d61d chore(wire): link task manager to runner hub
All checks were successful
Build Backend / build (push) Successful in 2m12s
Build Backend / build-docker (push) Successful in 1m14s
2026-06-02 14:24:44 +08:00
lan
34992030b9 chore(server): add livekit handler to dependency injection graph
All checks were successful
Build Backend / build (push) Successful in 13m47s
Build Backend / build-docker (push) Successful in 1m37s
2026-06-02 11:45:10 +08:00
lan
14114db68d feat(call): integrate LiveKit for voice and video calling
All checks were successful
Build Backend / build (push) Successful in 3m47s
Build Backend / build-docker (push) Successful in 5m9s
Replace the manual WebRTC signaling implementation with LiveKit SFU. This includes:
- Adding LiveKit service, handler, and configuration.
- Updating Docker Compose to include LiveKit server, Redis, and PostgreSQL.
- Refactoring `CallService` and `WSHandler` to support LiveKit room readiness instead of raw SDP/ICE relaying.
- Adding new API endpoints for LiveKit token generation and webhooks.
- Removing deprecated WebRTC configuration and manual signaling DTOs.
2026-06-01 13:41:02 +08:00
lan
9356cb6876 feat(runner): implement empty classroom sync functionality
Some checks failed
Build Backend / build (push) Successful in 2m22s
Build Backend / build-docker (push) Failing after 3m3s
Add support for fetching and synchronizing empty classroom data. This includes new protobuf definitions for task payloads and results, database models, repository, service, and HTTP handlers.

- Add `TASK_TYPE_GET_EMPTY_CLASSROOM` to `TaskType` enum
- Implement `EmptyClassroom` model and auto-migration
- Add `EmptyClassroomHandler` with `GET` and `POST /sync` routes
- Implement `EmptyClassroomSyncService` and `EmptyClassroomRepository`
- Update dependency injection with wire sets
2026-05-31 21:29:45 +08:00
lan
2084473fb5 refactor(messaging): shift sequence generation responsibility to database transactions
All checks were successful
Build Backend / build (push) Successful in 1m59s
Build Backend / build-docker (push) Successful in 1m20s
Move the responsibility of sequence (`seq`) allocation from the application/cache layer to the database layer to ensure absolute consistency. Previously, sequences were pre-allocated via Redis/Lua, which introduced complexity in managing synchronization between the cache and the database.

Key changes:
- Implement `CreateMessageWithSeq` in `message_repo.go` using `SELECT FOR UPDATE` to lock the conversation row and atomically increment the `last_seq` within a single transaction.
- Simplify `SeqBufferManager` by removing the complex local buffering and Lua-based pre-allocation logic, reverting to a simpler model.
- Update `chat_service.go`, `group_service.go`, and `message_service.go` to remove manual sequence retrieval, relying instead on the repository's transactional allocation.
- Introduce `SyncConvSeq` in `conversation_cache.go` to perform "write-through" updates to Redis, ensuring the cache remains synchronized with the database's source of truth.
- Improve cold-start handling in `conversation_cache.go` with a new `syncSeqLua` script to prevent stale sequence reads.
2026-05-25 14:51:46 +08:00
lan
2748c80095 fix(cache): enhance sequence generation atomicity and concurrency safety
All checks were successful
Build Backend / build (push) Successful in 2m20s
Build Backend / build-docker (push) Successful in 2m12s
Implement Lua scripts for atomic sequence incrementing and cold-start initialization in Redis to prevent race conditions. Improve the sequence buffer manager with CAS-like logic to prevent interval leakage during concurrent allocations. Update the message repository to use `SELECT ... FOR UPDATE` and conditional updates for `last_seq` to ensure database consistency during high concurrency.

- Add `nextSeqLua` and `initSeqLua` for atomic Redis operations
- Implement thread-safe buffer management in `seq_buffer.go`
- Add row-level locking in `message_repo.go`
- Update service layers to support group-aware sequence retrieval
2026-05-25 14:08:06 +08:00
lan
3fc8b38184 refactor(dto): change seq field type from string to int64
All checks were successful
Build Backend / build (push) Successful in 2m2s
Build Backend / build-docker (push) Successful in 1m48s
Update the `Seq` field in `WSEventResponse` and its usages across message and websocket handlers to use `int64` instead of `string`. This aligns the data transfer object with the underlying message sequence type and removes unnecessary string conversions.
2026-05-25 02:31:13 +08:00
lan
6bf87fec46 feat(chat): implement sequence pre-allocation, versioned sync, and push worker
All checks were successful
Build Backend / build (push) Successful in 4m20s
Build Backend / build-docker (push) Successful in 1m7s
Introduce several performance and synchronization enhancements:
- Implement `SeqBufferManager` to allow sequence number pre-allocation via Redis Lua scripts, reducing atomic increment overhead.
- Add `PushWorker` to handle asynchronous message pushing using Redis Streams.
- Implement incremental conversation synchronization via `ConversationVersionLog` to allow clients to fetch only recent changes.
- Add support for Gzip compression in WebSocket communications to reduce bandwidth usage.
- Update dependency injection and configuration to support these new components.
2026-05-17 23:38:04 +08:00
lan
f63c795dcb feat(db): implement read-write splitting for postgres
All checks were successful
Build Backend / build (push) Successful in 2m1s
Build Backend / build-docker (push) Successful in 1m26s
Add support for database read replicas using the GORM dbresolver plugin. This allows for scaling read operations by distributing them across one or multiple replica nodes.

- Update `DatabaseConfig` to support single and multiple replica configurations.
- Add configuration options for replica connection pooling and selection policy.
- Integrate `dbresolver` in the database initialization process for PostgreSQL.
- Add helper methods to aggregate replica configurations.
2026-05-15 14:44:40 +08:00
lan
de0766df5e refactor(cache): remove database fallback from conversation sequence retrieval
All checks were successful
Build Backend / build (push) Successful in 1m59s
Build Backend / build-docker (push) Successful in 1m13s
Remove the fallback mechanism that queried the database when Redis keys were missing or unavailable. The `GetConvMaxSeq` and `GetConvMaxSeqBatch` methods now strictly rely on the Redis cache and return `ErrKeyNotFound` if the data is not present.
2026-05-15 13:48:29 +08:00
lan
6c14309624 feat(cache): implement database fallback for conversation sequence management
All checks were successful
Build Backend / build (push) Successful in 2m2s
Build Backend / build-docker (push) Successful in 1m23s
Improve the reliability of conversation sequence generation by implementing a fallback mechanism to the database when Redis is unavailable or when keys are missing.

- Add `GetNextSeq` to `MessageRepository` and implement it in `MessageRepositoryAdapter`.
- Update `ConversationCache` to fallback to DB in `GetConvMaxSeq`, `GetConvMaxSeqBatch`, and `GetNextSeq`.
- Ensure Redis TTL is refreshed after every `INCR` operation to prevent sequence counter expiration.
- Refactor service layers to handle sequence generation errors more explicitly.
- Add `IsEnabled` helper to `LayeredCache` for cleaner availability checks.
2026-05-15 13:08:22 +08:00
lan
570b2d7afe chore(config): remove unused redis and s3 client constructors
All checks were successful
Build Backend / build (push) Successful in 2m25s
Build Backend / build-docker (push) Successful in 1m34s
Remove `NewRedis` and `NewS3` factory functions from the config package
to eliminate unused initialization logic and reduce dependency bloat.
2026-05-14 02:26:28 +08:00
lan
9a1851f023 refactor: cleanup unused code and simplify internal packages
Some checks failed
Build Backend / build (push) Successful in 1m56s
Build Backend / build-docker (push) Has been cancelled
This commit performs a significant cleanup of the codebase by removing unused functions, methods, and entire files across various internal modules. This reduces technical debt and simplifies the project structure.

Key changes include:
- **cache**: Removed unused cache key generators and metrics snapshots.
- **dto**: Removed redundant converter functions and segment creation helpers.
- **middleware**: Deleted the unused `logger.go` middleware and simplified `ratelimit.go` and `casbin.go`.
- **model**: Removed unused ID helpers, database closing functions, and batch decryption logic.
- **pkg**: Cleaned up unused utility functions in `circuitbreaker`, `crypto`, `cursor`, `hook`, and `utils`.
- **service**: Deleted `account_cleanup_service.go` and removed unused helper functions in `log_cleanup_service.go` and `sensitive_service.go`.
- **repository**: Removed unused private loading methods in `comment_repo.go`.
2026-05-14 02:24:30 +08:00
187 changed files with 13887 additions and 4858 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# Build artifacts # Build artifacts
/server /server
*.exe
*.tar *.tar
# Runtime files # Runtime files

View File

@@ -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 启动 PushWorkerRedis 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()

View File

@@ -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,
}) })
} }

View File

@@ -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,
}) })
} }

View File

@@ -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 使用 globMatchdoublestar 语义):
# - '* 匹配单层资源段(不跨 '/'
# - '**' 跨多层(如 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 == "*")

View File

@@ -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
# 荣耀通知栏消息智能分类importanceNORMAL=服务通讯/LOW=资讯营销):
# APP_JPUSH_CHANNEL_HONOR_SYSTEM_IMPORTANCE, APP_JPUSH_CHANNEL_HONOR_CHAT_IMPORTANCE
# vivo 厂商消息场景标识categoryclassification=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
View 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

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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 原子递增 seqkey 存在时 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 INCRkey 存在时递增,不存在时返回 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_seqSETNX+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 不可用,降级到 DBSELECT 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
View 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
}

View File

@@ -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)
}

View File

@@ -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()),
)
}

View File

@@ -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() {

View File

@@ -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
View 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. 尝试 INCRkey 存在时直接递增,原子操作)
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)
}

View File

@@ -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 中未出现的嵌套 keyreplica 段在 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)

View File

@@ -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
}

View File

@@ -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/ACCOUNTnotify_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 即时通讯)
} }

View 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)
}

View 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"`
}

View 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
}

View File

@@ -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
}

View 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
}

View File

@@ -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"` // 扫描间隔(分钟),默认 3606 小时)
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"` // 保留天数,默认 33 天不活跃即清理)
IntervalMinutes int `mapstructure:"interval_minutes"` // 扫描间隔(分钟),默认 3606 小时)
}

View File

@@ -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
}

View 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"` // 超过此版本差距建议全量同步
}

View File

@@ -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"`
}

View 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
}

View File

@@ -0,0 +1,222 @@
package database
import (
"os"
"path/filepath"
"testing"
"with_you/internal/config"
"with_you/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// 注意:完整的 NewDB→autoMigrate 在 SQLite 下会因 model 包多个表共用
// 同名索引 idx_createddata_change_log / login_log / operation_log而冲突。
// 这是 model 包的既有约束PostgreSQL 经 CREATE INDEX IF NOT EXISTS 容忍),
// 与本次分层拆分无关。因此种子逻辑测试在隔离的 SQLite 库上验证:
// 仅迁移被种子函数读写的表,避开跨表索引冲突,专注验证种子正确性与幂等性。
// newSeedTestDB 创建一个仅迁移种子相关表的 SQLite 测试库。
func newSeedTestDB(t *testing.T) *gorm.DB {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "seed_test.db")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite failed: %v", err)
}
// 仅迁移种子函数涉及的表(无 idx_created 冲突)
if err := db.AutoMigrate(
&model.Role{},
&model.CasbinRule{},
&model.Channel{},
&model.MaterialSubject{},
&model.Post{}, // dropPostsHotScoreColumnIfExists 依赖 posts 表存在
); err != nil {
t.Fatalf("autoMigrate seed tables failed: %v", err)
}
t.Cleanup(func() {
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
})
return db
}
// TestSeedRoles 验证角色种子写入 5 个预定义角色。
func TestSeedRoles(t *testing.T) {
db := newSeedTestDB(t)
if err := seedRoles(db); err != nil {
t.Fatalf("seedRoles failed: %v", err)
}
var count int64
db.Model(&model.Role{}).Count(&count)
if count != 5 {
t.Errorf("expected 5 seeded roles, got %d", count)
}
expected := []string{
model.RoleSuperAdmin, model.RoleAdmin, model.RoleModerator,
model.RoleUser, model.RoleBanned,
}
for _, name := range expected {
var role model.Role
if err := db.Where("name = ?", name).First(&role).Error; err != nil {
t.Errorf("expected role %q: %v", name, err)
}
}
}
// TestSeedRoles_Idempotent 验证角色种子幂等:表非空时跳过。
func TestSeedRoles_Idempotent(t *testing.T) {
db := newSeedTestDB(t)
if err := seedRoles(db); err != nil {
t.Fatalf("first seedRoles: %v", err)
}
// 第二次调用应跳过(表已有数据)
if err := seedRoles(db); err != nil {
t.Fatalf("second seedRoles: %v", err)
}
var count int64
db.Model(&model.Role{}).Count(&count)
if count != 5 {
t.Errorf("idempotent seed failed: expected 5 roles, got %d", count)
}
}
// TestSeedPermissions 验证权限策略种子写入,含超级管理员通配规则。
func TestSeedPermissions(t *testing.T) {
db := newSeedTestDB(t)
if err := seedPermissions(db); err != nil {
t.Fatalf("seedPermissions failed: %v", err)
}
var count int64
db.Model(&model.CasbinRule{}).Where("ptype = ?", "p").Count(&count)
if count == 0 {
t.Fatal("expected seeded permissions, got 0")
}
// 超级管理员 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 引用可用(供未来临时文件场景扩展)

View File

@@ -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
}

View File

@@ -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响应结构体

View File

@@ -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
}

View File

@@ -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
View 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"`
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
View 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"`
}

View File

@@ -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,49 +26,38 @@ 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"`
@@ -76,44 +65,38 @@ type AdminReportDetailResponse struct {
// 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{

View File

@@ -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
View 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"`
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 RegistrationIDpush_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))
}

View File

@@ -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
} }
} }

View File

@@ -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,
})
}

View File

@@ -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,
} }

View 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})
}

View File

@@ -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

View File

@@ -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,
}) })
} }

View File

@@ -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

View 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),
)
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
} }

View File

@@ -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")

View File

@@ -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,
} }

View File

@@ -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,
})
}

View File

@@ -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 tokenjwt.ParseRefreshToken 严格校验 typ=refresh
// - 通过 SessionService 校验会话未撤销未过期,并轮换会话(旧 refresh token 失效)。
// - 不接受 access tokenaccess 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

View File

@@ -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())

View File

@@ -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())
}
}

View 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()
}
}

View File

@@ -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()
}
}

View 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. 校验 sessionIDsid 非空时):撤销/过期 → 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 不写入 contextc.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()
}

View 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 无 sidGetSessionByID 不应被调用。
}
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

View 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()
}
}

View File

@@ -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) {

View File

@@ -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()
}
}

View File

@@ -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 封禁与登录失败追踪
// ============================================================ // ============================================================

View File

@@ -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()

View 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 已认证用户 → 放行200next 被调用)。
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")
}
}

View File

@@ -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"`
}

View File

@@ -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"` // 通话时长(秒)

View 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"`
}

View 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" }

View File

@@ -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 }

View File

@@ -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()
}
}

View File

@@ -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.BatchDecryptworker 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()
} }

View 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
View File

@@ -0,0 +1,55 @@
package model
import (
"time"
"gorm.io/gorm"
)
// Session 用户登录会话
//
// 用于支持令牌撤销:
// - access token 携带 SessionIDJWT 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)
}

View 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"` // 完整访问 URLDTO 标记失效用
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"
}

View 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))
}

View 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:"
)

View File

@@ -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)]
}

View File

@@ -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)
}
}
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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
} }

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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)
} }

View File

@@ -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),
)
}
}

View File

@@ -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