Compare commits

...

62 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
lafay
d2894066f8 Merge branch 'master' of https://code.littlelan.cn/carrot_bbs/backend
All checks were successful
Build Backend / build (push) Successful in 2m19s
Build Backend / build-docker (push) Successful in 1m12s
2026-05-14 01:27:42 +08:00
lafay
d3e5d6d76b chore(config): increase log rotation max age from 30 to 180 days 2026-05-14 01:27:18 +08:00
dfca33da1c 更新 .gitea/workflows/deploy.yml
All checks were successful
Build Backend / build (push) Successful in 1m56s
Build Backend / build-docker (push) Successful in 1m20s
2026-05-13 17:50:27 +08:00
lafay
f0abb624ee 321
Some checks failed
Build Backend / build-docker (push) Has been cancelled
Build Backend / build (push) Has been cancelled
2026-05-13 17:34:51 +08:00
lafay
aa23314ab8 123
Some checks failed
Build Backend / build (push) Successful in 2m8s
Build Backend / build-docker (push) Has been cancelled
2026-05-13 17:31:38 +08:00
lafay
9632117ad1 refactor(workflows): verify Android keystore via container environment variable
Some checks failed
Build Backend / build-docker (push) Has been cancelled
Build Backend / build (push) Has been cancelled
2026-05-13 17:29:13 +08:00
lafay
b872831c64 ci(workflows): add echo of ANDROID_KEYSTORE_BASE64 secret in deploy workflow
Some checks failed
Build Backend / build-docker (push) Has been cancelled
Build Backend / build (push) Has been cancelled
2026-05-13 17:27:17 +08:00
a74aa8fdc9 更新 .gitea/workflows/deploy.yml
All checks were successful
Build Backend / build (push) Successful in 2m25s
Build Backend / build-docker (push) Successful in 1m19s
2026-05-13 17:22:43 +08:00
lan
d0852f0e78 feat(push): include group information in chat message push notifications
All checks were successful
Build Backend / build (push) Successful in 2m15s
Build Backend / build-docker (push) Successful in 1m32s
Update the chat message push mechanism to include group ID and group avatar
when sending messages in group conversations. This ensures that push
notifications for group chats display the group's identity and icon
rather than the sender's personal avatar.

- Update `PushChatMessage` interface and implementation to accept `groupID` and `groupAvatar`.
- Modify `chat_service.go` to extract group metadata and pass it to the push service.
- Update `push_service.go` to include group details in the push payload extras and set the notification icon to the group avatar for group conversations.
2026-05-13 00:31:21 +08:00
lan
06db8e36e4 refactor(cache): remove deprecated unread count methods and keys
All checks were successful
Build Backend / build (push) Successful in 2m5s
Build Backend / build-docker (push) Successful in 1m19s
Clean up the codebase by removing unused and deprecated unread count
functionality in `ConversationCache` and associated Redis key
generators. This follows the transition to sequence-based unread
calculation.

- Remove `IncrementUnread`, `ClearUnread`, `GetUnreadCountFromHash`,
  `GetAllUnreadCounts`, and `GetTotalUnread` from `ConversationCache`.
- Remove `PrefixUnreadHash` and `PrefixUnreadTotal` constants and
  `UnreadHashKey`/`UnreadTotalKey` helper functions.
- Remove unused `isValidContentType` helper in `MessageHandler`.
2026-05-12 18:26:31 +08:00
lan
8d7e8c427b feat(chat): implement batch mark-as-read and message sync data
All checks were successful
Build Backend / build (push) Successful in 2m2s
Build Backend / build-docker (push) Successful in 1m41s
Refactor unread count logic to use arithmetic calculation (maxSeq - readSeq)
instead of manual incrementing/decrementing. This simplifies the cache
management and improves consistency.

New features:
- Batch mark-as-read functionality for multiple conversations.
- Lightweight sync data endpoint to retrieve conversation metadata
  (maxSeq and last message timestamp) for client synchronization.
- Optimized batch retrieval of conversation max sequences using Redis MGet.

Technical changes:
- Deprecated `IncrementUnread` and `ClearUnread` in `ConversationCache`.
- Added `GetConvMaxSeqBatch` to reduce network round-trips.
- Added `HandleMarkReadAll` and `HandleGetSyncData` handlers.
- Updated `ChatService` to support batch operations and sync data retrieval.
2026-05-12 18:04:59 +08:00
lan
2f2bbc646e feat(api): implement exam and grade synchronization modules
All checks were successful
Build Backend / build (push) Successful in 2m18s
Build Backend / build-docker (push) Successful in 1m20s
Add new functionality for managing and synchronizing academic grades and exam schedules. This includes new models, repositories, services, and HTTP handlers, along with updated gRPC definitions and dependency injection configuration.

Additionally, improve the reliability of unread message count tracking by implementing an atomic Lua script for Redis operations in the conversation cache.
2026-05-12 01:28:18 +08:00
lan
628a6acbe9 feat(chat): implement sequence-based unread count tracking
All checks were successful
Build Backend / build (push) Successful in 1m56s
Build Backend / build-docker (push) Successful in 1m9s
Introduce a new mechanism for tracking read positions using message
sequences (hasReadSeq), similar to OpenIM, to allow for O(1) unread
count calculations.

- Implement `UserReadSeq` caching in Redis with Lua scripts to prevent
  sequence regression (ensuring updates only occur if the new sequence
  is greater than the current one).
- Update `ConversationCache` to support sequence-based unread count
  computation (`maxSeq - hasReadSeq`).
- Refactor `MessageRepository` to use conditional updates for
  `last_read_seq` in the database.
- Update `ChatService` to automatically update the sender's read
  sequence when sending a message.
- Optimize `MarkAsRead` logic to update both database and Redis
  sequence caches and refine notification targets for private vs
  group chats.
- Update `jpush` client to use pointer types for boolean fields to
  properly handle optional values in JSON.
2026-05-10 13:36:58 +08:00
lan
43348615c0 chore(jpush): upgrade push request logging level and detail
All checks were successful
Build Backend / build (push) Successful in 1m59s
Build Backend / build-docker (push) Successful in 1m20s
Change the log level from `Debug` to `Info` and include the full request body string to improve observability of outgoing push payloads.
2026-05-09 19:59:02 +08:00
lan
0b7faf08c0 refactor(jpush): update IOS notification alert format to use map
All checks were successful
Build Backend / build (push) Successful in 2m14s
Build Backend / build-docker (push) Successful in 1m11s
Change the `Alert` field in `IOSNotif` from a string to a map containing `title` and `body` to support structured notification content.
2026-05-09 19:48:45 +08:00
lan
9f3215d4eb fix(server): improve message handling and data privacy
All checks were successful
Build Backend / build (push) Successful in 2m0s
Build Backend / build-docker (push) Successful in 7m38s
- fix unread count calculation logic in message repository
- implement logic to retrieve other participant's last read sequence in conversation handlers
- mask real names in verification record responses for privacy
- improve error reporting in main entry point by using stderr instead of zap logger during initialization
2026-05-09 17:25:09 +08:00
lan
51fe517cb2 chore(docker): create logs and data directories in Dockerfile
All checks were successful
Build Backend / build (push) Successful in 2m8s
Build Backend / build-docker (push) Successful in 1m14s
2026-05-07 11:50:59 +08:00
lan
8aa85ca4b2 feat(runner): implement cluster mode with Redis-based task dispatching and registry
All checks were successful
Build Backend / build (push) Successful in 2m22s
Build Backend / build-docker (push) Successful in 1m20s
Introduce a distributed task execution system for gRPC runners. This includes:
- A `TaskBus` for cluster-mode task dispatching via Redis Pub/Sub.
- A `RunnerRegistry` to track active runner instances in Redis.
- Support for both standalone and cluster modes via configuration.
- Refactored `RunnerHub` and `TaskManager` to use a `TaskDispatcher` interface, decoupling task submission from local execution.
- Added distributed locking to `HotRankWorker` to ensure only one instance performs periodic hot rank recalculations in a multi-instance deployment.
- Updated dependency injection (Wire) to support the new runner components and registry.
2026-05-07 01:08:39 +08:00
lan
dc27eb5cde feat(server): add trade handler to dependency injection container
All checks were successful
Build Backend / build (push) Successful in 1m55s
Build Backend / build-docker (push) Successful in 1m24s
Inject TradeRepository, TradeService, and TradeHandler into the application router using wire to enable trade-related functionality.
2026-05-06 15:50:47 +08:00
lan
5f1b8bb451 ci(deploy): remove unused WebSocket cluster environment variables
Some checks failed
Build Backend / build-docker (push) Has been cancelled
Build Backend / build (push) Has been cancelled
Remove `APP_WEBSOCKET_CLUSTER_INSTANCE_ID` from the deployment workflow as it is no longer required for the current configuration.
2026-05-06 12:46:04 +08:00
lan
c630cbf4d0 feat(ws): implement WebSocket cluster mode with Redis Pub/Sub
All checks were successful
Build Backend / build (push) Successful in 2m0s
Build Backend / build-docker (push) Successful in 1m26s
Introduce a new WebSocket messaging architecture that supports both standalone and cluster modes. This allows for horizontal scaling of WebSocket servers by using Redis Pub/Sub to synchronize messages across multiple instances.

Key changes:
- Added `ws.MessagePublisher` interface to abstract message distribution.
- Implemented `ws.Bus` to handle cluster-mode messaging via Redis.
- Added `ws.OnlineTracker` to manage user online status across the cluster.
- Refactored multiple services (Chat, Group, Push, Call, etc.) to use the new `MessagePublisher` instead of a concrete `ws.Hub`.
- Added WebSocket configuration options (mode, instance ID, channel, TTL, heartbeat) to `config.yaml` and `config.go`.
- Updated dependency injection with Wire to support the new publisher and Redis client.
- Improved logging by replacing standard `log` with `zap` in several service components.
2026-05-06 12:39:11 +08:00
lan
d481742790 fix(service): skip SSRF checks for local S3 resources
All checks were successful
Build Backend / build (push) Successful in 2m21s
Build Backend / build-docker (push) Successful in 1m37s
Allow image segments with URLs matching the local S3 prefix to bypass
SSRF validation, as internal S3 endpoints may resolve to private IP
addresses that would otherwise trigger security errors.
2026-05-05 20:51:35 +08:00
lan
af1ecaf71e fix(message): add missing BatchDecryptMessagesParallel in GetLastMessagesBatch
All checks were successful
Build Backend / build (push) Successful in 2m16s
Build Backend / build-docker (push) Successful in 1m8s
GetLastMessagesBatch was returning empty segments because it skipped
the decryption step that all other message query methods perform.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 02:00:40 +08:00
lan
90c57f1a1c feat(core): optimize performance and reliability through batching and redis-backed unread counts
All checks were successful
Build Backend / build (push) Successful in 2m7s
Build Backend / build-docker (push) Successful in 1m51s
This commit introduces several significant architectural improvements to enhance system performance, scalability, and reliability:

- **Performance Optimization (N+1 Problem Resolution)**: Implemented batching mechanisms across `MessageHandler`, `ChatService`, `GroupService`, `MessageService`, and `UserService`. This replaces multiple individual database/cache queries with single batch operations for fetching unread counts, last messages, participants, and user information.
- **Enhanced Unread Count Management**: Migrated unread count tracking to a Redis Hash-based approach (`unread#️⃣{userID}`). This allows for atomic increments/decrements and efficient retrieval of both individual conversation unread counts and total unread counts for a user.
- **Improved Message Sequencing**: Integrated Redis-based sequence (`seq`) pre-allocation for messages to ensure strict ordering and reduce database contention during high-concurrency message creation.
- **Push Service Reliability**: Refactored the push notification system to include persistent `PushRecord` tracking. Added a recovery mechanism (`recoverPendingPushes`) to reload pending notifications from the database upon service startup, ensuring better delivery guarantees.
- **WebSocket Reliability**: Updated the WebSocket hub to move away from in-memory history replay in favor of a client-driven synchronization model (`sync_required` event and `ack` handling), reducing memory overhead and improving connection stability.
- **Cache Layer Enhancements**: Added `HIncrBy` and `IncrBySeq` to the `Cache` interface and its implementations (`RedisCache`, `LayeredCache`) to support the new unread and sequence management logic.
2026-05-04 18:31:03 +08:00
lan
ee78071d4d refactor: improve system stability, performance, and code structure
All checks were successful
Build Backend / build (push) Successful in 3m2s
Build Backend / build-docker (push) Successful in 2m44s
This commit introduces several architectural improvements and optimizations across the codebase:

- **Performance & Reliability**:
  - Implemented Redis pipelining in `ConversationCache.CacheMessage` to reduce network round-trips.
  - Added a circuit breaker to the JPush client to prevent cascading failures.
  - Introduced batch deletion and batch member addition capabilities in repositories.
  - Added message idempotency support using `client_msg_id` and a Redis-based cache.
  - Optimized WebSocket handling with connection limits (total and per-user) and improved error logging.

- **Code Refactoring**:
  - Refactored `Router` to use a `RouterDeps` struct, simplifying the constructor and improving maintainability.
  - Unified model ID generation logic using new `id_helper.go` (supporting UUID and Snowflake).
  - Standardized JSON serialization/deserialization in models using `json_helper.go`.
  - Refactored DTO conversion logic, specifically for `UserResponse` (using functional options) and `Report` responses.
  - Removed redundant/deprecated DTOs like `PostDetailResponse` and `TradeItemDetailResponse`.

- **Cache Improvements**:
  - Enhanced `LayeredCache` with `SetRaw` to avoid double-encoding when promoting values from Redis to local cache.
  - Added `DeleteBatch` support to the cache interface.

- **Other Changes**:
  - Cleaned up `config.go` by removing redundant default values and explicit environment variable overrides.
  - Improved WebSocket registration flow to handle connection limits gracefully.
2026-05-04 13:07:03 +08:00
lafay
b2b55ea52d feat: enhance security with IP banning, ownership checks, and SSRF protection
All checks were successful
Build Backend / build (push) Successful in 1m56s
Build Backend / build-docker (push) Successful in 1m15s
Add comprehensive security improvements across the application:

- **IP-based login protection**: Implement IP ban system tracking login failures, auto-banning after threshold exceeded
- **Ownership verification**: Add userID parameter to Delete/Update operations for posts and comments to prevent unauthorized modifications
- **SSRF protection**: Add URL and resolved host validation for image URLs in chat and OpenAI client
- **SQL injection prevention**: Add EscapeLikeWildcard utility to escape special characters in LIKE queries
- **HTTP security**: Configure server timeouts and add security headers middleware
- **Rate limiting**: Refactor to support configurable duration and per-endpoint rate limits for auth routes
- **Error handling**: Standardize error responses using HandleError and proper error types
2026-04-30 12:26:25 +08:00
lafay
67a5660952 refactor: unify code formatting and improve push/search implementations
All checks were successful
Build Backend / build (push) Successful in 3m54s
Build Backend / build-docker (push) Successful in 1m9s
- Remove BOM from all Go source files
- Standardize import ordering and whitespace across handlers and services
- Replace PostgreSQL full-text search with ILIKE pattern matching in post and user repositories
- Enhance JPush client with TLS transport, rate limit monitoring, and simplified API
- Refactor PushService to include userID in device management methods
- Add MaxDevicesPerUser limit and extract helper functions for push payload construction
2026-04-28 14:53:04 +08:00
lafay
179e468131 fix(jpush): correct SendNo field type from int64 to string in PushResponse
All checks were successful
Build Backend / build (push) Successful in 1m56s
Build Backend / build-docker (push) Successful in 1m33s
The JPush API returns sendno as a string value in the JSON response, but the
struct field was incorrectly typed as int64. This fix updates both the struct
field type and the corresponding zap logger call to use string type, ensuring
proper JSON parsing and logging.
2026-04-28 00:18:51 +08:00
lafay
56c61f1895 refactor(jpush): add structured logging to JPush client methods
All checks were successful
Build Backend / build (push) Successful in 2m6s
Build Backend / build-docker (push) Successful in 1m23s
Add zap logger calls to Track, PushByRegistrationIDs, PushByAliases, PushAll,
and GetDeviceInfo methods for improved observability. Logs include request
debug info, response parsing status, success confirmations, and error
tracking with relevant context like message IDs, counts, and registration IDs.
2026-04-28 00:08:37 +08:00
lafay
8b1e411c89 ci: support both apt sources formats for mirror switching
All checks were successful
Build Backend / build (push) Successful in 2m5s
Build Backend / build-docker (push) Successful in 2m19s
Update the CI workflow to handle both the legacy sources.list format
and the new ubuntu.sources format when switching to Tsinghua mirrors
2026-04-27 23:28:54 +08:00
lafay
496a0103ea ci: switch to Tsinghua mirror for apt-get in CI workflow
Some checks failed
Build Backend / build-docker (push) Has been cancelled
Build Backend / build (push) Has been cancelled
Use Tsinghua University mirror for faster package downloads in China region
2026-04-27 23:26:57 +08:00
lafay
fb85c9c20a feat(push): add JPush integration for offline message push
Some checks failed
Build Backend / build-docker (push) Has been cancelled
Build Backend / build (push) Has been cancelled
Add JPush (极光推送) integration to enable push notifications for offline users. This includes:
- New jpush client package with API for batch push notifications
- Configuration for jpush (enabled, app_key, master_secret, production mode)
- Updated push service to send messages via JPush when users are offline
- Added PushChatMessage method with do-not-disturb checking
- Integrated push service with chat service to notify offline users of new messages
- Updated deployment workflow with JPush and related environment variables
2026-04-27 23:20:24 +08:00
f03bbf6faa Merge pull request 'feature/flea-market' (#4) from feature/flea-market into master
All checks were successful
Build Backend / build (push) Successful in 2m16s
Build Backend / build-docker (push) Successful in 1m14s
Reviewed-on: #4
2026-04-26 19:44:32 +08:00
lafay
5d12397e29 fix(user): add VerificationStatusNone for users without verification
Some checks failed
Deploy / deploy (pull_request) Has been cancelled
Build Backend / build-docker (pull_request) Has been cancelled
Build Backend / build (pull_request) Has been cancelled
Add VerificationStatusNone constant and update converter to return
general identity with none status instead of empty strings when user
has not applied for verification.
2026-04-26 19:42:10 +08:00
lafay
f99687c405 feat(user): add identity and verification status to user DTOs
Add Identity and VerificationStatus fields to UserResponse, UserSelfResponse,
and UserDetailResponse. Only expose identity information when verification
status is approved to protect privacy of pending/rejected applicants.
2026-04-26 19:38:16 +08:00
280 changed files with 20732 additions and 6174 deletions

View File

@@ -35,7 +35,15 @@ jobs:
go env GOMODCACHE go env GOMODCACHE
- name: Install build tools - name: Install build tools
run: sudo apt-get update && sudo apt-get install -y gcc run: |
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
sudo sed -i 's|http://archive.ubuntu.com/ubuntu|https://mirrors.tuna.tsinghua.edu.cn/ubuntu|g' /etc/apt/sources.list.d/ubuntu.sources
sudo sed -i 's|http://security.ubuntu.com/ubuntu|https://mirrors.tuna.tsinghua.edu.cn/ubuntu|g' /etc/apt/sources.list.d/ubuntu.sources
else
sudo sed -i 's|http://archive.ubuntu.com|https://mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list
sudo sed -i 's|http://security.ubuntu.com|https://mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list
fi
sudo apt-get update && sudo apt-get install -y gcc
- name: Download dependencies - name: Download dependencies
run: go mod download run: go mod download

View File

@@ -110,6 +110,22 @@ jobs:
-e APP_GRPC_ENABLED=true \ -e APP_GRPC_ENABLED=true \
-e APP_GRPC_PORT=50051 \ -e APP_GRPC_PORT=50051 \
-e APP_SERVER_MODE=release \ -e APP_SERVER_MODE=release \
-e APP_TENCENT_TMS_ENABLED=false \
-e "APP_TENCENT_TMS_SECRET_ID=${{ secrets.TENCENT_TMS_SECRET_ID }}" \
-e "APP_TENCENT_TMS_SECRET_KEY=${{ secrets.TENCENT_TMS_SECRET_KEY }}" \
-e APP_TENCENT_TMS_REGION=ap-guangzhou \
-e APP_TENCENT_TMS_TIMEOUT=30 \
-e APP_JPUSH_ENABLED=true \
-e "APP_JPUSH_APP_KEY=${{ secrets.JPUSH_APP_KEY }}" \
-e "APP_JPUSH_MASTER_SECRET=${{ secrets.JPUSH_MASTER_SECRET }}" \
-e APP_JPUSH_PRODUCTION=true \
-e APP_SENSITIVE_ENABLED=true \
-e APP_REPORT_AUTO_HIDE_THRESHOLD=3 \
-e APP_WEBSOCKET_MODE=standalone \
-e APP_WEBSOCKET_CLUSTER_MSG_CHANNEL=ws:msg \
-e APP_WEBSOCKET_CLUSTER_ONLINE_TTL=60 \
-e APP_WEBSOCKET_CLUSTER_HEARTBEAT_INTERVAL=20 \
-e APP_LOG_ROTATION_MAX_AGE=180 \
-e "APP_SETUP_SECRET=${{ secrets.SETUP_SECRET }}" \ -e "APP_SETUP_SECRET=${{ secrets.SETUP_SECRET }}" \
${IMAGE} ${IMAGE}
@@ -130,3 +146,4 @@ jobs:
sudo docker logs ${CONTAINER} --tail 20 sudo docker logs ${CONTAINER} --tail 20
echo "=== Deployment completed ===" echo "=== Deployment completed ==="

1
.gitignore vendored
View File

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

281
.kilo/package-lock.json generated
View File

@@ -5,21 +5,22 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"@kilocode/plugin": "7.2.10" "@kilocode/plugin": "7.2.31"
} }
}, },
"node_modules/@kilocode/plugin": { "node_modules/@kilocode/plugin": {
"version": "7.2.10", "version": "7.2.31",
"resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.10.tgz", "resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.31.tgz",
"integrity": "sha512-VJPhJC+E5WWu7XgEJzrVOxKJlwJ+OATwxEzgjqEPj8KN5N38YxUPBY/rzUTjv90x7nkzyk1rFGfCVqXdA/Koug==", "integrity": "sha512-KmKTTIly7hRlJdXKhqZ/j/brvTPh0z0UTjWSjJWq5fqf4pATgYGn7G0g3ZjILnN7MUkkZXuljgqExTEeQJHGkQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@kilocode/sdk": "7.2.10", "@kilocode/sdk": "7.2.31",
"effect": "4.0.0-beta.48",
"zod": "4.1.8" "zod": "4.1.8"
}, },
"peerDependencies": { "peerDependencies": {
"@opentui/core": ">=0.1.97", "@opentui/core": ">=0.1.100",
"@opentui/solid": ">=0.1.97" "@opentui/solid": ">=0.1.100"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@opentui/core": { "@opentui/core": {
@@ -31,14 +32,98 @@
} }
}, },
"node_modules/@kilocode/sdk": { "node_modules/@kilocode/sdk": {
"version": "7.2.10", "version": "7.2.31",
"resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.10.tgz", "resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.31.tgz",
"integrity": "sha512-H6jGXYAhN/yjOGX3MRZ0OxyEAuRGY3VOwDbLTh4O6ljpgutFHaLvomDZ82qNVy7gl7AjJgi3SAQAt9UQpeGl/w==", "integrity": "sha512-Sx05yv+3TIlc6M4Ze+YGgKCLoIg8B0WRE15JXDriVncT+wz7M6+e+4mjNWkwfsuywdeOTYPfeFViqX8iO7ckKA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cross-spawn": "7.0.6" "cross-spawn": "7.0.6"
} }
}, },
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -53,12 +138,135 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/effect": {
"version": "4.0.0-beta.48",
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz",
"integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"fast-check": "^4.6.0",
"find-my-way-ts": "^0.1.6",
"ini": "^6.0.0",
"kubernetes-types": "^1.30.0",
"msgpackr": "^1.11.9",
"multipasta": "^0.2.7",
"toml": "^4.1.1",
"uuid": "^13.0.0",
"yaml": "^2.8.3"
}
},
"node_modules/fast-check": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz",
"integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"dependencies": {
"pure-rand": "^8.0.0"
},
"engines": {
"node": ">=12.17.0"
}
},
"node_modules/find-my-way-ts": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
"license": "MIT"
},
"node_modules/ini": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
"integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
"license": "ISC",
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/isexe": { "node_modules/isexe": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/kubernetes-types": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
"license": "Apache-2.0"
},
"node_modules/msgpackr": {
"version": "1.11.12",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz",
"integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==",
"license": "MIT",
"optionalDependencies": {
"msgpackr-extract": "^3.0.2"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
}
},
"node_modules/multipasta": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
"integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
"license": "MIT"
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/path-key": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -68,6 +276,22 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/pure-rand": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
"integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -89,6 +313,28 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/toml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/uuid": {
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.1.tgz",
"integrity": "sha512-9ezox2roIft6ExBVTVqibSd5dc5/47Sw/uY6b4SjQUT2TzQ0tltNquWA46y4xPQmdZYqvnio22SgWd41M86+jw==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -104,6 +350,21 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/yaml": {
"version": "2.8.4",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
"integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zod": { "node_modules/zod": {
"version": "4.1.8", "version": "4.1.8",
"license": "MIT", "license": "MIT",

View File

@@ -18,8 +18,9 @@ COPY server ./carrot_bbs
# 复制配置文件 # 复制配置文件
COPY configs ./configs COPY configs ./configs
# 给可执行文件添加执行权限 # 给可执行文件添加执行权限,并创建运行时所需目录
RUN chmod +x ./carrot_bbs RUN chmod +x ./carrot_bbs \
&& mkdir -p logs data
# 暴露端口 # 暴露端口
EXPOSE 8080 EXPOSE 8080

View File

@@ -4,9 +4,11 @@ import (
"context" "context"
"fmt" "fmt"
"net/http" "net/http"
"time"
"with_you/internal/config" "with_you/internal/config"
"with_you/internal/grpc/runner" "with_you/internal/grpc/runner"
"with_you/internal/pkg/ws"
"with_you/internal/router" "with_you/internal/router"
"with_you/internal/service" "with_you/internal/service"
@@ -16,14 +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
Logger *zap.Logger GRPCServer *runner.Server
Server *http.Server
Publisher ws.MessagePublisher
TaskDispatcher runner.TaskDispatcher
Logger *zap.Logger
PushWorker *service.PushWorker
} }
// NewApp 创建应用程序 // NewApp 创建应用程序
@@ -33,17 +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,
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,
Logger: logger, DeviceCleanupWorker: deviceCleanupWorker,
GRPCServer: grpcServer,
Publisher: publisher,
TaskDispatcher: taskDispatcher,
Logger: logger,
PushWorker: pushWorker,
} }
} }
@@ -65,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",
@@ -72,8 +104,12 @@ func (a *App) Start(ctx context.Context) error {
) )
a.Server = &http.Server{ a.Server = &http.Server{
Addr: addr, Addr: addr,
Handler: a.Router.Engine(), Handler: a.Router.Engine(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
} }
// 4. 启动 HTTP 服务器 // 4. 启动 HTTP 服务器
@@ -92,7 +128,18 @@ func (a *App) Start(ctx context.Context) error {
func (a *App) Stop(ctx context.Context) error { func (a *App) Stop(ctx context.Context) error {
a.Logger.Info("Shutting down server") a.Logger.Info("Shutting down server")
// 1. 停止 HTTP 服务器 // 1. 关闭所有 WebSocket 连接
if a.Publisher != nil {
a.Logger.Info("Closing WebSocket connections")
switch p := a.Publisher.(type) {
case *ws.Bus:
p.Close()
case *ws.Hub:
p.CloseAllConnections()
}
}
// 3. 停止 HTTP 服务器
if a.Server != nil { if a.Server != nil {
a.Logger.Info("Stopping HTTP server") a.Logger.Info("Stopping HTTP server")
if err := a.Server.Shutdown(ctx); err != nil { if err := a.Server.Shutdown(ctx); err != nil {
@@ -102,13 +149,21 @@ func (a *App) Stop(ctx context.Context) error {
} }
} }
// 2. 停止 gRPC 服务器 // 4. 停止 gRPC 服务器
if a.GRPCServer != nil { if a.GRPCServer != nil {
a.Logger.Info("Stopping gRPC server") a.Logger.Info("Stopping gRPC server")
a.GRPCServer.Stop() a.GRPCServer.Stop()
} }
// 3. 停止推送 Worker // 4.1 停止 TaskBus集群模式
if a.TaskDispatcher != nil {
if bus, ok := a.TaskDispatcher.(*runner.TaskBus); ok {
a.Logger.Info("Stopping TaskBus")
bus.Stop()
}
}
// 5. 停止推送 Worker
a.Logger.Info("Stopping push worker") a.Logger.Info("Stopping push worker")
a.PushService.StopPushWorker() a.PushService.StopPushWorker()
@@ -117,7 +172,23 @@ func (a *App) Stop(ctx context.Context) error {
a.HotRankWorker.Stop() a.HotRankWorker.Stop()
} }
// 4. 关闭数据库连接 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. 关闭数据库连接
a.Logger.Info("Closing database connection") a.Logger.Info("Closing database connection")
sqlDB, err := a.DB.DB() sqlDB, err := a.DB.DB()
if err == nil { if err == nil {

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"fmt"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
@@ -14,9 +15,8 @@ func main() {
// 初始化应用程序Wire 自动生成) // 初始化应用程序Wire 自动生成)
app, err := InitializeApp() app, err := InitializeApp()
if err != nil { if err != nil {
zap.L().Fatal("failed to initialize app", fmt.Fprintf(os.Stderr, "failed to initialize app: %v\n", err)
zap.Error(err), os.Exit(1)
)
} }
// 启动应用程序 // 启动应用程序

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,
@@ -40,6 +45,9 @@ func ProvideRouter(
voteHandler *handler.VoteHandler, voteHandler *handler.VoteHandler,
channelHandler *handler.ChannelHandler, channelHandler *handler.ChannelHandler,
scheduleHandler *handler.ScheduleHandler, scheduleHandler *handler.ScheduleHandler,
gradeHandler *handler.GradeHandler,
examHandler *handler.ExamHandler,
emptyClassroomHandler *handler.EmptyClassroomHandler,
roleHandler *handler.RoleHandler, roleHandler *handler.RoleHandler,
adminUserHandler *handler.AdminUserHandler, adminUserHandler *handler.AdminUserHandler,
adminPostHandler *handler.AdminPostHandler, adminPostHandler *handler.AdminPostHandler,
@@ -55,47 +63,59 @@ func ProvideRouter(
verificationHandler *handler.VerificationHandler, verificationHandler *handler.VerificationHandler,
adminVerificationHandler *handler.AdminVerificationHandler, adminVerificationHandler *handler.AdminVerificationHandler,
adminProfileAuditHandler *handler.AdminProfileAuditHandler, adminProfileAuditHandler *handler.AdminProfileAuditHandler,
setupHandler *handler.SetupHandler, setupHandler *handler.SetupHandler,
tradeHandler *handler.TradeHandler,
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( return router.New(router.RouterDeps{
userRepo, UserRepo: userRepo,
userHandler, UserHandler: userHandler,
postHandler, PostHandler: postHandler,
commentHandler, CommentHandler: commentHandler,
messageHandler, MessageHandler: messageHandler,
notificationHandler, NotificationHandler: notificationHandler,
uploadHandler, UploadHandler: uploadHandler,
jwtService, JWTService: jwtService,
pushHandler, SessionService: sessionService,
systemMessageHandler, IdentityProvider: identityProvider,
groupHandler, Cache: cache,
stickerHandler, PushHandler: pushHandler,
voteHandler, SystemMessageHandler: systemMessageHandler,
channelHandler, GroupHandler: groupHandler,
scheduleHandler, StickerHandler: stickerHandler,
roleHandler, VoteHandler: voteHandler,
adminUserHandler, ChannelHandler: channelHandler,
adminPostHandler, ScheduleHandler: scheduleHandler,
adminCommentHandler, GradeHandler: gradeHandler,
adminGroupHandler, ExamHandler: examHandler,
adminDashboardHandler, EmptyClassroomHandler: emptyClassroomHandler,
adminLogHandler, RoleHandler: roleHandler,
qrcodeHandler, AdminUserHandler: adminUserHandler,
materialHandler, AdminPostHandler: adminPostHandler,
callHandler, AdminCommentHandler: adminCommentHandler,
reportHandler, AdminGroupHandler: adminGroupHandler,
adminReportHandler, AdminDashboardHandler: adminDashboardHandler,
verificationHandler, AdminLogHandler: adminLogHandler,
adminVerificationHandler, QRCodeHandler: qrcodeHandler,
adminProfileAuditHandler, MaterialHandler: materialHandler,
setupHandler, CallHandler: callHandler,
logService, LiveKitHandler: liveKitHandler,
activityService, ReportHandler: reportHandler,
casbinService, AdminReportHandler: adminReportHandler,
wsHandler, VerificationHandler: verificationHandler,
) AdminVerificationHandler: adminVerificationHandler,
AdminProfileAuditHandler: adminProfileAuditHandler,
SetupHandler: setupHandler,
TradeHandler: tradeHandler,
LogService: logService,
ActivityService: activityService,
CasbinService: casbinService,
UserService: userService,
WSHandler: wsHandler,
})
} }

View File

@@ -1,4 +1,4 @@
// Code generated by Wire. DO NOT EDIT. // Code generated by Wire. DO NOT EDIT.
//go:generate go run -mod=mod github.com/google/wire/cmd/wire //go:generate go run -mod=mod github.com/google/wire/cmd/wire
//go:build !wireinject //go:build !wireinject
@@ -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"
@@ -31,10 +33,12 @@ func InitializeApp() (*App, error) {
pushRecordRepository := repository.NewPushRecordRepository(db) pushRecordRepository := repository.NewPushRecordRepository(db)
deviceTokenRepository := repository.NewDeviceTokenRepository(db) deviceTokenRepository := repository.NewDeviceTokenRepository(db)
messageRepository := repository.NewMessageRepository(db) messageRepository := repository.NewMessageRepository(db)
hub := wire.ProvideWSHub()
pushService := wire.ProvidePushService(pushRecordRepository, deviceTokenRepository, messageRepository, hub)
postRepository := repository.NewPostRepository(db)
client := wire.ProvideRedisClient(config) client := wire.ProvideRedisClient(config)
messagePublisher := wire.ProvideWSMessagePublisher(config, client)
logger := wire.ProvideLogger()
jpushClient := wire.ProvideJPushClient(config, logger)
pushService := wire.ProvidePushService(pushRecordRepository, deviceTokenRepository, messageRepository, messagePublisher, jpushClient, config)
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)
emailClient := wire.ProvideEmailClient(config) emailClient := wire.ProvideEmailClient(config)
@@ -42,13 +46,15 @@ func InitializeApp() (*App, error) {
operationLogRepository := repository.NewOperationLogRepository(db) operationLogRepository := repository.NewOperationLogRepository(db)
loginLogRepository := repository.NewLoginLogRepository(db) loginLogRepository := repository.NewLoginLogRepository(db)
dataChangeLogRepository := repository.NewDataChangeLogRepository(db) dataChangeLogRepository := repository.NewDataChangeLogRepository(db)
logger := wire.ProvideLogger()
asyncLogManager := wire.ProvideAsyncLogManager(operationLogRepository, loginLogRepository, dataChangeLogRepository, logger, config) asyncLogManager := wire.ProvideAsyncLogManager(operationLogRepository, loginLogRepository, dataChangeLogRepository, logger, config)
operationLogService := wire.ProvideOperationLogService(asyncLogManager, operationLogRepository) operationLogService := wire.ProvideOperationLogService(asyncLogManager, operationLogRepository)
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)
@@ -65,10 +71,10 @@ func InitializeApp() (*App, error) {
transactionManager := wire.ProvideTransactionManager(db) transactionManager := wire.ProvideTransactionManager(db)
builtinHooks := wire.ProvideBuiltinHooks(manager) builtinHooks := wire.ProvideBuiltinHooks(manager)
postService := wire.ProvidePostService(postRepository, systemMessageService, postAIService, cache, transactionManager, manager, moderationHooks, builtinHooks, logService) postService := wire.ProvidePostService(postRepository, systemMessageService, postAIService, cache, transactionManager, manager, moderationHooks, builtinHooks, logService)
channelRepository := repository.NewChannelRepository(db)
channelService := wire.ProvideChannelService(channelRepository, cache)
postRefRepository := repository.NewPostRefRepository(db) postRefRepository := repository.NewPostRefRepository(db)
postRefService := wire.ProvidePostRefService(postRefRepository, postRepository) postRefService := wire.ProvidePostRefService(postRefRepository, postRepository)
channelRepository := repository.NewChannelRepository(db)
channelService := wire.ProvideChannelService(channelRepository, cache)
postHandler := handler.NewPostHandler(postService, postRefService, userService, channelService) postHandler := handler.NewPostHandler(postService, postRefService, userService, channelService)
commentService := wire.ProvideCommentService(commentRepository, postRepository, systemMessageService, postAIService, cache, manager, logService) commentService := wire.ProvideCommentService(commentRepository, postRepository, systemMessageService, postAIService, cache, manager, logService)
commentHandler := handler.NewCommentHandler(commentService) commentHandler := handler.NewCommentHandler(commentService)
@@ -76,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, hub, cache, uploadService) 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, hub, cache) groupService := wire.ProvideGroupService(groupRepository, userRepository, messageRepository, groupJoinRequestRepository, systemNotificationRepository, messagePublisher, cache)
messageHandler := wire.ProvideMessageHandler(chatService, messageService, userService, groupService, hub) 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)
@@ -100,34 +113,40 @@ func InitializeApp() (*App, error) {
channelHandler := handler.NewChannelHandler(channelService) channelHandler := handler.NewChannelHandler(channelService)
scheduleRepository := repository.NewScheduleRepository(db) scheduleRepository := repository.NewScheduleRepository(db)
scheduleService := wire.ProvideScheduleService(scheduleRepository) scheduleService := wire.ProvideScheduleService(scheduleRepository)
server := wire.ProvideGRPCServer(config, logger) runnerHub := wire.ProvideRunnerHub(logger)
taskManager := wire.ProvideTaskManager(server) taskManager := wire.ProvideTaskManager(runnerHub, logger)
scheduleSyncService := wire.ProvideScheduleSyncService(taskManager, scheduleRepository, logger) scheduleSyncService := wire.ProvideScheduleSyncService(taskManager, scheduleRepository, logger)
scheduleHandler := wire.ProvideScheduleHandler(scheduleService, scheduleSyncService) scheduleHandler := wire.ProvideScheduleHandler(scheduleService, scheduleSyncService)
roleRepository := wire.ProvideRoleRepository(db) gradeRepository := repository.NewGradeRepository(db)
enforcer := wire.ProvideCasbinEnforcer(config, db) gradeSyncService := wire.ProvideGradeSyncService(taskManager, gradeRepository, logger)
casbinService := wire.ProvideCasbinService(enforcer, cache, roleRepository, config) gradeHandler := handler.NewGradeHandler(gradeSyncService)
examRepository := repository.NewExamRepository(db)
examSyncService := wire.ProvideExamSyncService(taskManager, examRepository, logger)
examHandler := handler.NewExamHandler(examSyncService)
emptyClassroomRepository := repository.NewEmptyClassroomRepository(db)
emptyClassroomSyncService := wire.ProvideEmptyClassroomSyncService(taskManager, emptyClassroomRepository, logger)
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)
adminDashboardService := wire.ProvideAdminDashboardService(db, userRepository, postRepository, commentRepository, groupRepository, userActivityRepository) adminDashboardService := wire.ProvideAdminDashboardService(db, userRepository, postRepository, commentRepository, groupRepository, userActivityRepository)
adminDashboardHandler := handler.NewAdminDashboardHandler(adminDashboardService) adminDashboardHandler := handler.NewAdminDashboardHandler(adminDashboardService)
adminLogHandler := handler.NewAdminLogHandler(operationLogService, loginLogService, dataChangeLogService) adminLogHandler := handler.NewAdminLogHandler(operationLogService, loginLogService, dataChangeLogService)
qrCodeLoginService := wire.ProvideQRCodeLoginService(client, hub, jwtService, userService, userActivityService, logService) qrCodeLoginService := wire.ProvideQRCodeLoginService(client, messagePublisher, jwtService, userService, userActivityService, logService)
qrCodeHandler := handler.NewQRCodeHandler(qrCodeLoginService) qrCodeHandler := handler.NewQRCodeHandler(qrCodeLoginService)
materialSubjectRepository := repository.NewMaterialSubjectRepository(db) materialSubjectRepository := repository.NewMaterialSubjectRepository(db)
materialFileRepository := repository.NewMaterialFileRepository(db) materialFileRepository := repository.NewMaterialFileRepository(db)
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, hub, config, db) 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)
@@ -138,17 +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)
wsHandler := wire.ProvideWSHandler(hub, chatService, groupService, jwtService, callService, userRepository)
tradeRepository := repository.NewTradeRepository(db) tradeRepository := repository.NewTradeRepository(db)
tradeService := wire.ProvideTradeService(tradeRepository) tradeService := wire.ProvideTradeService(tradeRepository)
tradeHandler := handler.NewTradeHandler(tradeService) tradeHandler := handler.NewTradeHandler(tradeService)
router := ProvideRouter(userRepository, userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, pushHandler, systemMessageHandler, groupHandler, stickerHandler, voteHandler, channelHandler, scheduleHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, qrCodeHandler, materialHandler, callHandler, reportHandler, adminReportHandler, verificationHandler, adminVerificationHandler, adminProfileAuditHandler, setupHandler, tradeHandler, logService, userActivityService, casbinService, wsHandler) wsHandler := wire.ProvideWSHandler(messagePublisher, chatService, groupService, jwtService, callService, userService)
hotRankWorker := wire.ProvideHotRankWorker(config, postRepository, channelRepository, cache) liveKitService := wire.ProvideLiveKitService(config, logger)
app := NewApp(config, db, router, pushService, hotRankWorker, server, 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)
fileCleanupWorker := wire.ProvideFileCleanupWorker(config, uploadedFileRepository, s3Client, client)
deviceCleanupWorker := wire.ProvideDeviceCleanupWorker(config, deviceTokenRepository, client)
server := wire.ProvideGRPCServer(config, logger, runnerHub)
runnerRegistry := wire.ProvideRunnerRegistry(config, client)
taskDispatcher := wire.ProvideTaskDispatcher(runnerHub, runnerRegistry, config, client, logger)
app := NewApp(config, db, router, pushService, hotRankWorker, fileCleanupWorker, deviceCleanupWorker, server, messagePublisher, taskDispatcher, logger, pushWorker)
return app, nil return app, nil
} }
@@ -163,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,
@@ -171,6 +200,9 @@ func ProvideRouter(
voteHandler *handler.VoteHandler, voteHandler *handler.VoteHandler,
channelHandler *handler.ChannelHandler, channelHandler *handler.ChannelHandler,
scheduleHandler *handler.ScheduleHandler, scheduleHandler *handler.ScheduleHandler,
gradeHandler *handler.GradeHandler,
examHandler *handler.ExamHandler,
emptyClassroomHandler *handler.EmptyClassroomHandler,
roleHandler *handler.RoleHandler, roleHandler *handler.RoleHandler,
adminUserHandler *handler.AdminUserHandler, adminUserHandler *handler.AdminUserHandler,
adminPostHandler *handler.AdminPostHandler, adminPostHandler *handler.AdminPostHandler,
@@ -191,44 +223,54 @@ 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( return router.New(router.RouterDeps{
userRepo, UserRepo: userRepo,
userHandler, UserHandler: userHandler,
postHandler, PostHandler: postHandler,
commentHandler, CommentHandler: commentHandler,
messageHandler, MessageHandler: messageHandler,
notificationHandler, NotificationHandler: notificationHandler,
uploadHandler, UploadHandler: uploadHandler,
jwtService, JWTService: jwtService,
pushHandler, SessionService: sessionService,
systemMessageHandler, IdentityProvider: identityProvider,
groupHandler, Cache: cache2,
stickerHandler, PushHandler: pushHandler,
voteHandler, SystemMessageHandler: systemMessageHandler,
channelHandler, GroupHandler: groupHandler,
scheduleHandler, StickerHandler: stickerHandler,
roleHandler, VoteHandler: voteHandler,
adminUserHandler, ChannelHandler: channelHandler,
adminPostHandler, ScheduleHandler: scheduleHandler,
adminCommentHandler, GradeHandler: gradeHandler,
adminGroupHandler, ExamHandler: examHandler,
adminDashboardHandler, EmptyClassroomHandler: emptyClassroomHandler,
adminLogHandler, RoleHandler: roleHandler,
qrcodeHandler, AdminUserHandler: adminUserHandler,
materialHandler, AdminPostHandler: adminPostHandler,
callHandler, AdminCommentHandler: adminCommentHandler,
reportHandler, AdminGroupHandler: adminGroupHandler,
adminReportHandler, AdminDashboardHandler: adminDashboardHandler,
verificationHandler, AdminLogHandler: adminLogHandler,
adminVerificationHandler, QRCodeHandler: qrcodeHandler,
adminProfileAuditHandler, MaterialHandler: materialHandler,
setupHandler, CallHandler: callHandler,
tradeHandler, LiveKitHandler: liveKitHandler,
logService, ReportHandler: reportHandler,
activityService, AdminReportHandler: adminReportHandler,
casbinService, VerificationHandler: verificationHandler,
wsHandler, AdminVerificationHandler: adminVerificationHandler,
) AdminProfileAuditHandler: adminProfileAuditHandler,
SetupHandler: setupHandler,
TradeHandler: tradeHandler,
LogService: logService,
ActivityService: activityService,
CasbinService: casbinService,
UserService: userService,
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
@@ -119,7 +143,7 @@ log:
- bank_card - bank_card
rotation: rotation:
max_size: 100 max_size: 100
max_age: 30 max_age: 180
max_backups: 10 max_backups: 10
compress: true compress: true
@@ -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
@@ -263,8 +295,118 @@ casbin:
report: report:
auto_hide_threshold: 3 # 自动隐藏阈值,举报次数达到此数值时自动隐藏内容 auto_hide_threshold: 3 # 自动隐藏阈值,举报次数达到此数值时自动隐藏内容
# 极光推送配置
# 环境变量:
# 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:
enabled: false
app_key: ""
master_secret: ""
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
# 设置后可使用 POST /api/v1/admin/setup-super-admin 接口初始化第一位超级管理员 # 设置后可使用 POST /api/v1/admin/setup-super-admin 接口初始化第一位超级管理员
# 该接口只能使用一次,一旦系统中存在超级管理员,此接口将永久拒绝请求 # 该接口只能使用一次,一旦系统中存在超级管理员,此接口将永久拒绝请求
setup_secret: "" setup_secret: ""
# WebSocket 配置
# 环境变量:
# APP_WEBSOCKET_MODE, APP_WEBSOCKET_CLUSTER_INSTANCE_ID
# APP_WEBSOCKET_CLUSTER_MSG_CHANNEL, APP_WEBSOCKET_CLUSTER_ONLINE_TTL
# APP_WEBSOCKET_CLUSTER_HEARTBEAT_INTERVAL
websocket:
mode: standalone # standalone 或 cluster
cluster:
instance_id: "" # 留空自动生成 UUID
msg_channel: "ws:msg" # Redis Pub/Sub 频道
online_ttl: 60 # 在线状态 TTL
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

@@ -32,6 +32,8 @@ type Cache interface {
Increment(key string) int64 Increment(key string) int64
// IncrementBy 增加指定值 // IncrementBy 增加指定值
IncrementBy(key string, value int64) int64 IncrementBy(key string, value int64) int64
// DeleteBatch 批量删除多个 key使用 Pipeline 减少网络往返)
DeleteBatch(keys []string)
// ==================== Hash 操作 ==================== // ==================== Hash 操作 ====================
// HSet 设置 Hash 字段 // HSet 设置 Hash 字段
@@ -46,6 +48,8 @@ type Cache interface {
HGetAll(ctx context.Context, key string) (map[string]string, error) HGetAll(ctx context.Context, key string) (map[string]string, error)
// HDel 删除 Hash 字段 // HDel 删除 Hash 字段
HDel(ctx context.Context, key string, fields ...string) error HDel(ctx context.Context, key string, fields ...string) error
// HIncrBy 原子递增 Hash 字段的值
HIncrBy(ctx context.Context, key string, field string, incr int64) (int64, error)
// ==================== Sorted Set 操作 ==================== // ==================== Sorted Set 操作 ====================
// ZAdd 添加 Sorted Set 成员 // ZAdd 添加 Sorted Set 成员
@@ -66,6 +70,8 @@ type Cache interface {
// ==================== 计数器操作 ==================== // ==================== 计数器操作 ====================
// Incr 原子递增(返回新值) // Incr 原子递增(返回新值)
Incr(ctx context.Context, key string) (int64, error) Incr(ctx context.Context, key string) (int64, error)
// IncrBySeq 原子递增指定值(用于 seq 对齐)
IncrBySeq(ctx context.Context, key string, delta int64) (int64, error)
// Expire 设置过期时间 // Expire 设置过期时间
Expire(ctx context.Context, key string, ttl time.Duration) error Expire(ctx context.Context, key string, ttl time.Duration) error
} }
@@ -93,7 +99,7 @@ var settings = Settings{
NullTTL: 5 * time.Second, NullTTL: 5 * time.Second,
JitterRatio: 0.1, JitterRatio: 0.1,
PostListTTL: 30 * time.Second, PostListTTL: 30 * time.Second,
ConversationTTL: 60 * time.Second, ConversationTTL: 120 * time.Second,
UnreadCountTTL: 30 * time.Second, UnreadCountTTL: 30 * time.Second,
GroupMembersTTL: 120 * time.Second, GroupMembersTTL: 120 * time.Second,
DisableFlushDB: true, DisableFlushDB: true,

View File

@@ -6,11 +6,40 @@ import (
"fmt" "fmt"
"time" "time"
"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 // 实际数据
@@ -72,19 +101,23 @@ type ConversationCacheSettings struct {
MessageListTTL time.Duration // 消息分页列表缓存 (5min) MessageListTTL time.Duration // 消息分页列表缓存 (5min)
MessageIndexTTL time.Duration // 消息索引缓存 (30min) MessageIndexTTL time.Duration // 消息索引缓存 (30min)
MessageCountTTL time.Duration // 消息计数缓存 (30min) MessageCountTTL time.Duration // 消息计数缓存 (30min)
// Seq 缓存配置
SeqTTL time.Duration // Seq 计数器缓存 (24h)
} }
// DefaultConversationCacheSettings 返回默认配置 // DefaultConversationCacheSettings 返回默认配置
func DefaultConversationCacheSettings() *ConversationCacheSettings { func DefaultConversationCacheSettings() *ConversationCacheSettings {
return &ConversationCacheSettings{ return &ConversationCacheSettings{
DetailTTL: 5 * time.Minute, DetailTTL: 15 * time.Minute,
ListTTL: 60 * time.Second, ListTTL: 120 * time.Second,
ParticipantTTL: 5 * time.Minute, ParticipantTTL: 15 * time.Minute,
UnreadTTL: 30 * time.Second, UnreadTTL: 30 * time.Second,
MessageDetailTTL: 30 * time.Minute, MessageDetailTTL: 30 * time.Minute,
MessageListTTL: 5 * time.Minute, MessageListTTL: 5 * time.Minute,
MessageIndexTTL: 30 * time.Minute, MessageIndexTTL: 30 * time.Minute,
MessageCountTTL: 30 * time.Minute, MessageCountTTL: 30 * time.Minute,
SeqTTL: 24 * time.Hour,
} }
} }
@@ -215,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)
} }
// ============================================================ // ============================================================
@@ -223,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,
} }
} }
@@ -478,6 +514,289 @@ func (c *ConversationCache) InvalidateUnreadCount(userID, convID string) {
c.cache.Delete(UnreadDetailKey(userID, convID)) c.cache.Delete(UnreadDetailKey(userID, convID))
} }
// ============================================================
// 已读位置缓存OpenIM 风格 hasReadSeq
// ============================================================
// readSeqExpire 已读位置缓存 TTL
const readSeqExpire = 30 * 24 * time.Hour
// SetUserReadSeq 设置用户在某会话的已读位置(防回退:仅当新值大于当前值时更新)
func (c *ConversationCache) SetUserReadSeq(ctx context.Context, convID, userID string, seq int64) error {
key := UserReadSeqKey(convID, userID)
redisCache, ok := c.cache.(*RedisCache)
if !ok {
// 非 Redis 模式,直接设置
c.cache.Set(key, seq, readSeqExpire)
return nil
}
rdb := redisCache.GetRedisClient().GetClient()
nKey := normalizeKey(key)
// Lua 脚本:仅当新 seq > 当前值时才更新,防回退
script := redis.NewScript(`
local current = tonumber(redis.call('GET', KEYS[1]))
if current and current >= tonumber(ARGV[1]) then
return 0
end
redis.call('SET', KEYS[1], ARGV[1])
redis.call('EXPIRE', KEYS[1], ARGV[2])
return 1
`)
_, err := script.Run(ctx, rdb, []string{nKey}, seq, int64(readSeqExpire.Seconds())).Result()
return err
}
// GetUserReadSeq 获取用户在某会话的已读位置
func (c *ConversationCache) GetUserReadSeq(ctx context.Context, convID, userID string) (int64, error) {
key := UserReadSeqKey(convID, userID)
redisCache, ok := c.cache.(*RedisCache)
if !ok {
raw, ok := c.cache.Get(key)
if !ok {
return 0, ErrKeyNotFound
}
switch v := raw.(type) {
case int64:
return v, nil
case string:
var n int64
fmt.Sscanf(v, "%d", &n)
return n, nil
}
return 0, ErrKeyNotFound
}
rdb := redisCache.GetRedisClient().GetClient()
nKey := normalizeKey(key)
val, err := rdb.Get(ctx, nKey).Int64()
if err != nil {
return 0, ErrKeyNotFound
}
return val, nil
}
// GetUserReadSeqs 批量获取用户在多个会话的已读位置
func (c *ConversationCache) GetUserReadSeqs(ctx context.Context, userID string, convIDs []string) (map[string]int64, error) {
if len(convIDs) == 0 {
return nil, nil
}
redisCache, ok := c.cache.(*RedisCache)
if !ok {
result := make(map[string]int64, len(convIDs))
for _, convID := range convIDs {
if seq, err := c.GetUserReadSeq(ctx, convID, userID); err == nil {
result[convID] = seq
}
}
return result, nil
}
rdb := redisCache.GetRedisClient().GetClient()
keys := make([]string, len(convIDs))
for i, convID := range convIDs {
keys[i] = normalizeKey(UserReadSeqKey(convID, userID))
}
vals, err := rdb.MGet(ctx, keys...).Result()
if err != nil {
return nil, err
}
result := make(map[string]int64, len(convIDs))
for i, val := range vals {
if val == nil {
continue
}
var seq int64
fmt.Sscanf(fmt.Sprint(val), "%d", &seq)
result[convIDs[i]] = seq
}
return result, nil
}
// GetConvMaxSeq 获取会话的最大 seq复用 msg_seq 计数器,读取当前值)
func (c *ConversationCache) GetConvMaxSeq(ctx context.Context, convID string) (int64, error) {
seqKey := MessageSeqKey(convID)
redisCache, ok := c.cache.(*RedisCache)
if !ok {
return 0, ErrKeyNotFound
}
rdb := redisCache.GetRedisClient().GetClient()
nKey := normalizeKey(seqKey)
val, err := rdb.Get(ctx, nKey).Int64()
if err != nil {
return 0, ErrKeyNotFound
}
return val, nil
}
// GetConvMaxSeqBatch 批量获取多个会话的 maxSeqMGet 减少网络往返)
func (c *ConversationCache) GetConvMaxSeqBatch(ctx context.Context, convIDs []string) (map[string]int64, error) {
if len(convIDs) == 0 {
return nil, nil
}
redisCache, ok := c.cache.(*RedisCache)
if !ok {
result := make(map[string]int64, len(convIDs))
for _, convID := range convIDs {
if seq, err := c.GetConvMaxSeq(ctx, convID); err == nil {
result[convID] = seq
}
}
return result, nil
}
rdb := redisCache.GetRedisClient().GetClient()
keys := make([]string, len(convIDs))
for i, convID := range convIDs {
keys[i] = normalizeKey(MessageSeqKey(convID))
}
vals, err := rdb.MGet(ctx, keys...).Result()
if err != nil {
return nil, err
}
result := make(map[string]int64, len(convIDs))
for i, val := range vals {
if val == nil {
continue
}
var seq int64
fmt.Sscanf(fmt.Sprint(val), "%d", &seq)
if seq > 0 {
result[convIDs[i]] = seq
}
}
return result, nil
}
// ComputeUnreadCount 计算单个会话未读数maxSeq - hasReadSeq
func (c *ConversationCache) ComputeUnreadCount(ctx context.Context, convID, userID string) (int64, error) {
maxSeq, err := c.GetConvMaxSeq(ctx, convID)
if err != nil {
return 0, err
}
readSeq, err := c.GetUserReadSeq(ctx, convID, userID)
if err != nil {
// 未读到已读位置,视为 0返回 maxSeq
return maxSeq, nil
}
unread := maxSeq - readSeq
if unread < 0 {
return 0, nil
}
return unread, nil
}
// ComputeAllUnreadCount 计算所有会话未读总数sum(maxSeq - hasReadSeq)
func (c *ConversationCache) ComputeAllUnreadCount(ctx context.Context, userID string, convIDs []string, maxSeqs map[string]int64) (int64, error) {
readSeqs, err := c.GetUserReadSeqs(ctx, userID, convIDs)
if err != nil {
return 0, err
}
var total int64
for _, convID := range convIDs {
maxSeq, ok := maxSeqs[convID]
if !ok {
continue
}
readSeq, hasRead := readSeqs[convID]
if !hasRead {
total += maxSeq
continue
}
unread := maxSeq - readSeq
if unread > 0 {
total += unread
}
}
return total, nil
}
// GetNextSeq 获取会话的下一个 seq 值(单次 Redis INCR原子无竞态
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)
// 尝试 Redis INCRkey 存在时递增,不存在时返回 0 触发冷启动)
if lc, ok := c.cache.(*LayeredCache); ok && lc.IsEnabled() {
rdb := lc.GetRedisClient()
if rdb != nil {
result, err := nextSeqLua.Run(ctx, rdb.GetClient(), []string{seqKey}).Int64()
if err == nil {
if result > 0 {
_ = c.cache.Expire(ctx, seqKey, c.settings.SeqTTL)
return result, nil
}
// 冷启动:从 DB 加载 last_seqSETNX+INCR 原子初始化
return c.initSeqFromDB(ctx, convID, seqKey, rdb)
}
}
}
// 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()
}
}
// ============================================================ // ============================================================
// 消息缓存方法 // 消息缓存方法
// ============================================================ // ============================================================
@@ -618,11 +937,11 @@ func (c *ConversationCache) GetMessagesBeforeSeq(ctx context.Context, convID str
return messages, nil return messages, nil
} }
// CacheMessage 缓存单条消息(立即写入缓存 // CacheMessage 缓存单条消息(使用 Redis Pipeline 减少网络往返
// 写入 Hash、Sorted Set、更新计数
func (c *ConversationCache) CacheMessage(ctx context.Context, convID string, msg *model.Message) error { func (c *ConversationCache) CacheMessage(ctx context.Context, convID string, msg *model.Message) error {
hashKey := MessageHashKey(convID) hashKey := MessageHashKey(convID)
indexKey := MessageIndexKey(convID) indexKey := MessageIndexKey(convID)
countKey := MessageCountKey(convID)
msgData := MessageCacheDataFromModel(msg) msgData := MessageCacheDataFromModel(msg)
data, err := json.Marshal(msgData) data, err := json.Marshal(msgData)
@@ -630,23 +949,34 @@ func (c *ConversationCache) CacheMessage(ctx context.Context, convID string, msg
return fmt.Errorf("failed to marshal message: %w", err) return fmt.Errorf("failed to marshal message: %w", err)
} }
// HSET 消息详情 hashKey = ResolveKey(hashKey)
if err := c.cache.HSet(ctx, hashKey, fmt.Sprintf("%d", msg.Seq), string(data)); err != nil { indexKey = ResolveKey(indexKey)
countKey = ResolveKey(countKey)
redisCache, ok := c.cache.(*RedisCache)
if ok {
rdb := redisCache.GetRedisClient().GetClient()
pipe := rdb.Pipeline()
pipe.HSet(ctx, hashKey, fmt.Sprintf("%d", msg.Seq), string(data))
pipe.ZAdd(ctx, indexKey, redis.Z{Score: float64(msg.Seq), Member: fmt.Sprintf("%d", msg.Seq)})
pipe.Expire(ctx, hashKey, c.settings.MessageDetailTTL)
pipe.Expire(ctx, indexKey, c.settings.MessageIndexTTL)
pipe.Incr(ctx, countKey)
if _, err := pipe.Exec(ctx); err != nil {
return fmt.Errorf("failed to pipeline cache message: %w", err)
}
return nil
}
if err := c.cache.HSet(ctx, MessageHashKey(convID), fmt.Sprintf("%d", msg.Seq), string(data)); err != nil {
return fmt.Errorf("failed to set hash: %w", err) return fmt.Errorf("failed to set hash: %w", err)
} }
if err := c.cache.ZAdd(ctx, MessageIndexKey(convID), float64(msg.Seq), fmt.Sprintf("%d", msg.Seq)); err != nil {
// ZADD 消息索引
if err := c.cache.ZAdd(ctx, indexKey, float64(msg.Seq), fmt.Sprintf("%d", msg.Seq)); err != nil {
return fmt.Errorf("failed to add to sorted set: %w", err) return fmt.Errorf("failed to add to sorted set: %w", err)
} }
c.cache.Expire(ctx, MessageHashKey(convID), c.settings.MessageDetailTTL)
// 设置 TTL c.cache.Expire(ctx, MessageIndexKey(convID), c.settings.MessageIndexTTL)
c.cache.Expire(ctx, hashKey, c.settings.MessageDetailTTL)
c.cache.Expire(ctx, indexKey, c.settings.MessageIndexTTL)
// INCR 消息计数
c.cache.Incr(ctx, MessageCountKey(convID)) c.cache.Incr(ctx, MessageCountKey(convID))
return nil return nil
} }

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

@@ -26,6 +26,9 @@ const (
PrefixUnreadSystem = "unread:system" PrefixUnreadSystem = "unread:system"
PrefixUnreadConversation = "unread:conversation" PrefixUnreadConversation = "unread:conversation"
PrefixUnreadDetail = "unread:detail" PrefixUnreadDetail = "unread:detail"
// 已读位置相关OpenIM 风格:缓存 hasReadSeq用于 O(1) 未读数计算)
PrefixUserReadSeq = "user_read_seq"
// 用户相关 // 用户相关
PrefixUserInfo = "users:info" PrefixUserInfo = "users:info"
@@ -35,11 +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" // 消息幂等键
// 帖子创建幂等键前缀clientRequestID -> 已创建的 postID
keyPrefixPostIdempotent = "post_idem"
) )
// PostListKey 生成帖子列表缓存键 // PostListKey 生成帖子列表缓存键
@@ -82,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 {
@@ -117,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 {
@@ -152,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) {
@@ -182,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 生成函数
@@ -221,3 +194,20 @@ func MessagePageKey(convID string, page, pageSize int) string {
func InvalidateMessagePages(cache Cache, conversationID string) { func InvalidateMessagePages(cache Cache, conversationID string) {
cache.DeleteByPrefix(fmt.Sprintf("%s:%s:", keyPrefixMsgPage, conversationID)) cache.DeleteByPrefix(fmt.Sprintf("%s:%s:", keyPrefixMsgPage, conversationID))
} }
// MessageIdempotentKey 消息幂等键 (clientMsgID -> server MsgID)
func MessageIdempotentKey(senderID, clientMsgID string) string {
return fmt.Sprintf("%s:%s:%s", keyPrefixMsgIdempotent, senderID, clientMsgID)
}
// PostCreateIdempotentKey 帖子创建幂等键 (userID + clientRequestID -> postID)
// 用于在 SetNX 原子抢占期间识别同一客户端请求的重试,避免重复发帖。
func PostCreateIdempotentKey(userID, clientRequestID string) string {
return fmt.Sprintf("%s:%s:%s", keyPrefixPostIdempotent, userID, clientRequestID)
}
// UserReadSeqKey 用户已读位置缓存键OpenIM 风格 SEQ_USER_READ:{convID}:{userID}
func UserReadSeqKey(convID, userID string) string {
return fmt.Sprintf("%s:%s:%s", PrefixUserReadSeq, convID, userID)
}

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"
) )
@@ -111,7 +109,11 @@ func (c *LayeredCache) Get(key string) (any, bool) {
c.mu.Unlock() c.mu.Unlock()
if ttl := c.getLocalTTL(); ttl > 0 { if ttl := c.getLocalTTL(); ttl > 0 {
c.local.Set(key, val, ttl) if str, ok := val.(string); ok {
c.local.SetRaw(key, []byte(str), ttl)
} else {
c.local.Set(key, val, ttl)
}
} }
return val, true return val, true
} }
@@ -157,6 +159,23 @@ func (c *LayeredCache) DeleteByPrefix(prefix string) {
} }
} }
func (c *LayeredCache) DeleteBatch(keys []string) {
if !c.enabled || len(keys) == 0 {
return
}
c.mu.Lock()
c.stats.Invalidates += int64(len(keys))
c.mu.Unlock()
for _, k := range keys {
c.local.Delete(k)
}
if c.redis != nil {
c.redis.DeleteBatch(keys)
}
}
func (c *LayeredCache) Clear() { func (c *LayeredCache) Clear() {
c.local.Clear() c.local.Clear()
if c.redis != nil { if c.redis != nil {
@@ -273,6 +292,14 @@ func (c *LayeredCache) HDel(ctx context.Context, key string, fields ...string) e
return c.redis.HDel(ctx, key, fields...) return c.redis.HDel(ctx, key, fields...)
} }
func (c *LayeredCache) HIncrBy(ctx context.Context, key string, field string, incr int64) (int64, error) {
if !c.enabled || c.redis == nil {
return 0, ErrKeyNotFound
}
c.local.Delete(key)
return c.redis.HIncrBy(ctx, key, field, incr)
}
func (c *LayeredCache) ZAdd(ctx context.Context, key string, score float64, member string) error { func (c *LayeredCache) ZAdd(ctx context.Context, key string, score float64, member string) error {
if !c.enabled || c.redis == nil { if !c.enabled || c.redis == nil {
return nil return nil
@@ -325,14 +352,27 @@ 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)
return c.redis.Incr(ctx, key) return c.redis.Incr(ctx, key)
} }
func (c *LayeredCache) IncrBySeq(ctx context.Context, key string, delta int64) (int64, error) {
if !c.enabled || c.redis == nil {
return 0, ErrKeyNotFound
}
c.local.Delete(key)
return c.redis.IncrBySeq(ctx, key, delta)
}
func (c *LayeredCache) Expire(ctx context.Context, key string, ttl time.Duration) error { func (c *LayeredCache) Expire(ctx context.Context, key string, ttl time.Duration) error {
if !c.enabled || c.redis == nil { if !c.enabled || c.redis == nil {
return nil return nil
@@ -375,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 + ":")
@@ -472,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()),
)
}

33
internal/cache/lru.go vendored
View File

@@ -87,6 +87,39 @@ func (l *LRU) set(key string, value any, ttl time.Duration) {
} }
} }
// SetRaw stores raw bytes directly without re-serializing.
// This avoids double-encoding when promoting values from Redis to local cache.
func (l *LRU) SetRaw(key string, raw []byte, ttl time.Duration) {
var expires time.Time
if ttl > 0 {
expires = time.Now().Add(ttl)
}
l.lock.Lock()
defer l.lock.Unlock()
if ent, ok := l.items[key]; ok {
l.evictList.MoveToFront(ent)
e := ent.Value.(*lruEntry)
e.value = raw
e.expires = expires
if e.generation != l.currentGeneration {
e.generation = l.currentGeneration
l.len++
}
return
}
ent := &lruEntry{key, raw, expires, l.currentGeneration}
entry := l.evictList.PushFront(ent)
l.items[key] = entry
l.len++
if l.evictList.Len() > l.size {
l.removeElement(l.evictList.Back())
}
}
func (l *LRU) Get(key string) (any, bool) { func (l *LRU) Get(key string) (any, bool) {
val, err := l.getItem(key) val, err := l.getItem(key)
if err != nil { if err != nil {

View File

@@ -106,11 +106,20 @@ func (l *LRUStriped) Name() string {
} }
func (l *LRUStriped) DeleteMulti(keys []string) error { func (l *LRUStriped) DeleteMulti(keys []string) error {
var err error
for _, key := range keys { for _, key := range keys {
l.Delete(key) l.Delete(key)
} }
return err return nil
}
func (l *LRUStriped) DeleteBatch(keys []string) {
for _, key := range keys {
l.Delete(key)
}
}
func (l *LRUStriped) SetRaw(key string, raw []byte, ttl time.Duration) {
l.keyBucket(key).SetRaw(key, raw, ttl)
} }
func (l *LRUStriped) GetMulti(keys []string, values []any) []error { func (l *LRUStriped) GetMulti(keys []string, values []any) []error {

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

@@ -82,7 +82,6 @@ func (c *RedisCache) Delete(key string) {
// DeleteByPrefix 根据前缀删除缓存 // DeleteByPrefix 根据前缀删除缓存
func (c *RedisCache) DeleteByPrefix(prefix string) { func (c *RedisCache) DeleteByPrefix(prefix string) {
prefix = normalizePrefix(prefix) prefix = normalizePrefix(prefix)
// 使用原生客户端执行SCAN命令
rdb := c.client.GetClient() rdb := c.client.GetClient()
var cursor uint64 var cursor uint64
for { for {
@@ -112,6 +111,30 @@ func (c *RedisCache) DeleteByPrefix(prefix string) {
} }
} }
// DeleteBatch 批量删除多个 key使用 Redis Pipeline 减少网络往返)
func (c *RedisCache) DeleteBatch(keys []string) {
if len(keys) == 0 {
return
}
normalizedKeys := make([]string, len(keys))
for i, k := range keys {
normalizedKeys[i] = normalizeKey(k)
}
recordInvalidateMultiple(int64(len(normalizedKeys)))
// 使用 pipeline 批量删除
pipe := c.client.GetClient().Pipeline()
for _, k := range normalizedKeys {
pipe.Del(c.ctx, k)
}
if _, err := pipe.Exec(c.ctx); err != nil {
zap.L().Error("Failed to batch delete keys",
zap.Int("count", len(normalizedKeys)),
zap.Error(err),
)
}
}
// Clear 清空所有缓存 // Clear 清空所有缓存
func (c *RedisCache) Clear() { func (c *RedisCache) Clear() {
if settings.DisableFlushDB { if settings.DisableFlushDB {
@@ -199,6 +222,12 @@ func (c *RedisCache) HDel(ctx context.Context, key string, fields ...string) err
return c.client.HDel(ctx, key, fields...) return c.client.HDel(ctx, key, fields...)
} }
// HIncrBy 原子递增 Hash 字段的值
func (c *RedisCache) HIncrBy(ctx context.Context, key string, field string, incr int64) (int64, error) {
key = normalizeKey(key)
return c.client.HIncrBy(ctx, key, field, incr)
}
// ==================== RedisCache Sorted Set 操作 ==================== // ==================== RedisCache Sorted Set 操作 ====================
// ZAdd 添加 Sorted Set 成员 // ZAdd 添加 Sorted Set 成员
@@ -258,6 +287,13 @@ func (c *RedisCache) Incr(ctx context.Context, key string) (int64, error) {
return c.client.Incr(ctx, key) return c.client.Incr(ctx, key)
} }
// IncrBySeq 原子递增指定值(用于 seq 对齐)
func (c *RedisCache) IncrBySeq(ctx context.Context, key string, delta int64) (int64, error) {
key = normalizeKey(key)
rdb := c.client.GetClient()
return rdb.IncrBy(ctx, key, delta).Result()
}
// Expire 设置过期时间 // Expire 设置过期时间
func (c *RedisCache) Expire(ctx context.Context, key string, ttl time.Duration) error { func (c *RedisCache) Expire(ctx context.Context, key string, ttl time.Duration) error {
key = normalizeKey(key) key = normalizeKey(key)

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

@@ -1,10 +1,7 @@
package config package config
import ( import (
"cmp"
"fmt" "fmt"
"os"
"strconv"
"strings" "strings"
"time" "time"
@@ -22,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"`
@@ -30,10 +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"`
SetupSecret string `mapstructure:"setup_secret"` SetupSecret string `mapstructure:"setup_secret"`
WebSocket WSConfig `mapstructure:"websocket"`
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", "")
@@ -148,7 +177,6 @@ func Load(configPath string) (*Config, error) {
viper.SetDefault("grpc.tls_enabled", false) viper.SetDefault("grpc.tls_enabled", false)
viper.SetDefault("grpc.tls_cert_file", "") viper.SetDefault("grpc.tls_cert_file", "")
viper.SetDefault("grpc.tls_key_file", "") viper.SetDefault("grpc.tls_key_file", "")
// Casbin 默认值
viper.SetDefault("casbin.model_path", "./configs/casbin/model.conf") viper.SetDefault("casbin.model_path", "./configs/casbin/model.conf")
viper.SetDefault("casbin.auto_save", true) viper.SetDefault("casbin.auto_save", true)
viper.SetDefault("casbin.auto_load", true) viper.SetDefault("casbin.auto_load", true)
@@ -165,13 +193,136 @@ 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.app_key", "")
viper.SetDefault("jpush.master_secret", "")
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 默认值
viper.SetDefault("websocket.mode", "standalone")
viper.SetDefault("websocket.cluster.instance_id", "")
viper.SetDefault("websocket.cluster.msg_channel", "ws:msg")
viper.SetDefault("websocket.cluster.online_ttl", 60)
viper.SetDefault("websocket.cluster.heartbeat_interval", 20)
// Runner 集群默认值mode 和 instance_id 空则跟随 websocket
viper.SetDefault("runner.mode", "")
viper.SetDefault("runner.cluster.instance_id", "")
viper.SetDefault("runner.cluster.task_channel", "runner:task")
viper.SetDefault("runner.cluster.result_channel", "runner:result")
viper.SetDefault("runner.cluster.registry_prefix", "runner:reg:")
viper.SetDefault("runner.cluster.cap_prefix", "runner:cap:")
viper.SetDefault("runner.cluster.registry_ttl", 90)
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)
@@ -182,96 +333,14 @@ func Load(configPath string) (*Config, error) {
return nil, fmt.Errorf("failed to unmarshal config: %w", err) return nil, fmt.Errorf("failed to unmarshal config: %w", err)
} }
// Runner mode 和 instance_id 空则跟随 WebSocket 配置
cfg.Runner.ApplyWSDefaults(cfg.WebSocket)
// Convert seconds to duration // Convert seconds to duration
cfg.JWT.AccessTokenExpire = time.Duration(viper.GetInt("jwt.access_token_expire")) * time.Second cfg.JWT.AccessTokenExpire = time.Duration(viper.GetInt("jwt.access_token_expire")) * time.Second
cfg.JWT.RefreshTokenExpire = time.Duration(viper.GetInt("jwt.refresh_token_expire")) * time.Second cfg.JWT.RefreshTokenExpire = time.Duration(viper.GetInt("jwt.refresh_token_expire")) * time.Second
// 环境变量覆盖(显式处理敏感配置) // Viper AutomaticEnv 已自动处理所有 APP_ 前缀的环境变量
cfg.JWT.Secret = getEnvOrDefault("APP_JWT_SECRET", cfg.JWT.Secret)
cfg.Database.SQLite.Path = getEnvOrDefault("APP_DATABASE_SQLITE_PATH", cfg.Database.SQLite.Path)
cfg.Database.Postgres.Host = getEnvOrDefault("APP_DATABASE_POSTGRES_HOST", cfg.Database.Postgres.Host)
cfg.Database.Postgres.Port, _ = strconv.Atoi(getEnvOrDefault("APP_DATABASE_POSTGRES_PORT", fmt.Sprintf("%d", cfg.Database.Postgres.Port)))
cfg.Database.Postgres.User = getEnvOrDefault("APP_DATABASE_POSTGRES_USER", cfg.Database.Postgres.User)
cfg.Database.Postgres.Password = getEnvOrDefault("APP_DATABASE_POSTGRES_PASSWORD", cfg.Database.Postgres.Password)
cfg.Database.Postgres.DBName = getEnvOrDefault("APP_DATABASE_POSTGRES_DBNAME", cfg.Database.Postgres.DBName)
cfg.Database.LogLevel = getEnvOrDefault("APP_DATABASE_LOG_LEVEL", cfg.Database.LogLevel)
cfg.Database.SlowThresholdMs, _ = strconv.Atoi(getEnvOrDefault("APP_DATABASE_SLOW_THRESHOLD_MS", fmt.Sprintf("%d", cfg.Database.SlowThresholdMs)))
cfg.Database.IgnoreRecordNotFound, _ = strconv.ParseBool(getEnvOrDefault("APP_DATABASE_IGNORE_RECORD_NOT_FOUND", fmt.Sprintf("%t", cfg.Database.IgnoreRecordNotFound)))
cfg.Database.ParameterizedQueries, _ = strconv.ParseBool(getEnvOrDefault("APP_DATABASE_PARAMETERIZED_QUERIES", fmt.Sprintf("%t", cfg.Database.ParameterizedQueries)))
cfg.Redis.Redis.Host = getEnvOrDefault("APP_REDIS_REDIS_HOST", cfg.Redis.Redis.Host)
cfg.Redis.Redis.Port, _ = strconv.Atoi(getEnvOrDefault("APP_REDIS_REDIS_PORT", fmt.Sprintf("%d", cfg.Redis.Redis.Port)))
cfg.Redis.Redis.Password = getEnvOrDefault("APP_REDIS_REDIS_PASSWORD", cfg.Redis.Redis.Password)
cfg.Redis.Redis.DB, _ = strconv.Atoi(getEnvOrDefault("APP_REDIS_REDIS_DB", fmt.Sprintf("%d", cfg.Redis.Redis.DB)))
cfg.Redis.Miniredis.Host = getEnvOrDefault("APP_REDIS_MINIREDIS_HOST", cfg.Redis.Miniredis.Host)
cfg.Redis.Miniredis.Port, _ = strconv.Atoi(getEnvOrDefault("APP_REDIS_MINIREDIS_PORT", fmt.Sprintf("%d", cfg.Redis.Miniredis.Port)))
cfg.Redis.Type = getEnvOrDefault("APP_REDIS_TYPE", cfg.Redis.Type)
cfg.Cache.KeyPrefix = getEnvOrDefault("APP_CACHE_KEY_PREFIX", cfg.Cache.KeyPrefix)
cfg.Cache.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_CACHE_ENABLED", fmt.Sprintf("%t", cfg.Cache.Enabled)))
cfg.Cache.DisableFlushDB, _ = strconv.ParseBool(getEnvOrDefault("APP_CACHE_DISABLE_FLUSHDB", fmt.Sprintf("%t", cfg.Cache.DisableFlushDB)))
cfg.Cache.DefaultTTL, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_DEFAULT_TTL", fmt.Sprintf("%d", cfg.Cache.DefaultTTL)))
cfg.Cache.NullTTL, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_NULL_TTL", fmt.Sprintf("%d", cfg.Cache.NullTTL)))
cfg.Cache.JitterRatio, _ = strconv.ParseFloat(getEnvOrDefault("APP_CACHE_JITTER_RATIO", fmt.Sprintf("%.2f", cfg.Cache.JitterRatio)), 64)
cfg.Cache.Modules.PostList, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_MODULES_POST_LIST_TTL", fmt.Sprintf("%d", cfg.Cache.Modules.PostList)))
cfg.Cache.Modules.Conversation, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_MODULES_CONVERSATION_TTL", fmt.Sprintf("%d", cfg.Cache.Modules.Conversation)))
cfg.Cache.Modules.UnreadCount, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_MODULES_UNREAD_COUNT_TTL", fmt.Sprintf("%d", cfg.Cache.Modules.UnreadCount)))
cfg.Cache.Modules.GroupMembers, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_MODULES_GROUP_MEMBERS_TTL", fmt.Sprintf("%d", cfg.Cache.Modules.GroupMembers)))
cfg.S3.Endpoint = getEnvOrDefault("APP_S3_ENDPOINT", cfg.S3.Endpoint)
cfg.S3.AccessKey = getEnvOrDefault("APP_S3_ACCESS_KEY", cfg.S3.AccessKey)
cfg.S3.SecretKey = getEnvOrDefault("APP_S3_SECRET_KEY", cfg.S3.SecretKey)
cfg.S3.Bucket = getEnvOrDefault("APP_S3_BUCKET", cfg.S3.Bucket)
cfg.S3.Domain = getEnvOrDefault("APP_S3_DOMAIN", cfg.S3.Domain)
cfg.Server.Host = getEnvOrDefault("APP_SERVER_HOST", cfg.Server.Host)
cfg.Server.Port, _ = strconv.Atoi(getEnvOrDefault("APP_SERVER_PORT", fmt.Sprintf("%d", cfg.Server.Port)))
cfg.Server.Mode = getEnvOrDefault("APP_SERVER_MODE", cfg.Server.Mode)
cfg.OpenAI.BaseURL = getEnvOrDefault("APP_OPENAI_BASE_URL", cfg.OpenAI.BaseURL)
cfg.OpenAI.APIKey = getEnvOrDefault("APP_OPENAI_API_KEY", cfg.OpenAI.APIKey)
cfg.OpenAI.ModerationModel = getEnvOrDefault("APP_OPENAI_MODERATION_MODEL", cfg.OpenAI.ModerationModel)
cfg.OpenAI.ModerationMaxImagesPerRequest, _ = strconv.Atoi(getEnvOrDefault("APP_OPENAI_MODERATION_MAX_IMAGES_PER_REQUEST", fmt.Sprintf("%d", cfg.OpenAI.ModerationMaxImagesPerRequest)))
cfg.OpenAI.RequestTimeout, _ = strconv.Atoi(getEnvOrDefault("APP_OPENAI_REQUEST_TIMEOUT", fmt.Sprintf("%d", cfg.OpenAI.RequestTimeout)))
cfg.OpenAI.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_OPENAI_ENABLED", fmt.Sprintf("%t", cfg.OpenAI.Enabled)))
cfg.OpenAI.StrictModeration, _ = strconv.ParseBool(getEnvOrDefault("APP_OPENAI_STRICT_MODERATION", fmt.Sprintf("%t", cfg.OpenAI.StrictModeration)))
// TencentTMS 环境变量覆盖
cfg.TencentTMS.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_TENCENT_TMS_ENABLED", fmt.Sprintf("%t", cfg.TencentTMS.Enabled)))
cfg.TencentTMS.SecretID = getEnvOrDefault("APP_TENCENT_TMS_SECRET_ID", cfg.TencentTMS.SecretID)
cfg.TencentTMS.SecretKey = getEnvOrDefault("APP_TENCENT_TMS_SECRET_KEY", cfg.TencentTMS.SecretKey)
cfg.TencentTMS.Region = getEnvOrDefault("APP_TENCENT_TMS_REGION", cfg.TencentTMS.Region)
cfg.TencentTMS.BizType = getEnvOrDefault("APP_TENCENT_TMS_BIZ_TYPE", cfg.TencentTMS.BizType)
cfg.TencentTMS.Timeout, _ = strconv.Atoi(getEnvOrDefault("APP_TENCENT_TMS_TIMEOUT", fmt.Sprintf("%d", cfg.TencentTMS.Timeout)))
cfg.Email.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_EMAIL_ENABLED", fmt.Sprintf("%t", cfg.Email.Enabled)))
cfg.Email.Host = getEnvOrDefault("APP_EMAIL_HOST", cfg.Email.Host)
cfg.Email.Port, _ = strconv.Atoi(getEnvOrDefault("APP_EMAIL_PORT", fmt.Sprintf("%d", cfg.Email.Port)))
cfg.Email.Username = getEnvOrDefault("APP_EMAIL_USERNAME", cfg.Email.Username)
cfg.Email.Password = getEnvOrDefault("APP_EMAIL_PASSWORD", cfg.Email.Password)
cfg.Email.FromAddress = getEnvOrDefault("APP_EMAIL_FROM_ADDRESS", cfg.Email.FromAddress)
cfg.Email.FromName = getEnvOrDefault("APP_EMAIL_FROM_NAME", cfg.Email.FromName)
cfg.Email.UseTLS, _ = strconv.ParseBool(getEnvOrDefault("APP_EMAIL_USE_TLS", fmt.Sprintf("%t", cfg.Email.UseTLS)))
cfg.Email.InsecureSkipVerify, _ = strconv.ParseBool(getEnvOrDefault("APP_EMAIL_INSECURE_SKIP_VERIFY", fmt.Sprintf("%t", cfg.Email.InsecureSkipVerify)))
cfg.Email.Timeout, _ = strconv.Atoi(getEnvOrDefault("APP_EMAIL_TIMEOUT", fmt.Sprintf("%d", cfg.Email.Timeout)))
// GRPC 环境变量覆盖
cfg.GRPC.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_GRPC_ENABLED", fmt.Sprintf("%t", cfg.GRPC.Enabled)))
cfg.GRPC.Port, _ = strconv.Atoi(getEnvOrDefault("APP_GRPC_PORT", fmt.Sprintf("%d", cfg.GRPC.Port)))
cfg.GRPC.TLSEnabled, _ = strconv.ParseBool(getEnvOrDefault("APP_GRPC_TLS_ENABLED", fmt.Sprintf("%t", cfg.GRPC.TLSEnabled)))
cfg.GRPC.TLSCertFile = getEnvOrDefault("APP_GRPC_TLS_CERT_FILE", cfg.GRPC.TLSCertFile)
cfg.GRPC.TLSKeyFile = getEnvOrDefault("APP_GRPC_TLS_KEY_FILE", cfg.GRPC.TLSKeyFile)
// Casbin 环境变量覆盖
cfg.Casbin.ModelPath = getEnvOrDefault("APP_CASBIN_MODEL_PATH", cfg.Casbin.ModelPath)
cfg.Casbin.AutoSave, _ = strconv.ParseBool(getEnvOrDefault("APP_CASBIN_AUTO_SAVE", fmt.Sprintf("%t", cfg.Casbin.AutoSave)))
cfg.Casbin.AutoLoad, _ = strconv.ParseBool(getEnvOrDefault("APP_CASBIN_AUTO_LOAD", fmt.Sprintf("%t", cfg.Casbin.AutoLoad)))
cfg.Casbin.EnableCache, _ = strconv.ParseBool(getEnvOrDefault("APP_CASBIN_ENABLE_CACHE", fmt.Sprintf("%t", cfg.Casbin.EnableCache)))
cfg.Casbin.CacheTTL, _ = strconv.Atoi(getEnvOrDefault("APP_CASBIN_CACHE_TTL", fmt.Sprintf("%d", cfg.Casbin.CacheTTL)))
// Encryption 环境变量覆盖
cfg.Encryption.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_ENCRYPTION_ENABLED", fmt.Sprintf("%t", cfg.Encryption.Enabled)))
cfg.Encryption.Key = getEnvOrDefault("APP_ENCRYPTION_KEY", cfg.Encryption.Key)
cfg.Encryption.KeyVersion, _ = strconv.Atoi(getEnvOrDefault("APP_ENCRYPTION_KEY_VERSION", fmt.Sprintf("%d", cfg.Encryption.KeyVersion)))
// Sensitive 环境变量覆盖
cfg.Sensitive.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_SENSITIVE_ENABLED", fmt.Sprintf("%t", cfg.Sensitive.Enabled)))
cfg.Sensitive.ReplaceStr = getEnvOrDefault("APP_SENSITIVE_REPLACE_STR", cfg.Sensitive.ReplaceStr)
cfg.Sensitive.MinMatchLen, _ = strconv.Atoi(getEnvOrDefault("APP_SENSITIVE_MIN_MATCH_LEN", fmt.Sprintf("%d", cfg.Sensitive.MinMatchLen)))
cfg.Sensitive.LoadFromDB, _ = strconv.ParseBool(getEnvOrDefault("APP_SENSITIVE_LOAD_FROM_DB", fmt.Sprintf("%t", cfg.Sensitive.LoadFromDB)))
cfg.Sensitive.LoadFromRedis, _ = strconv.ParseBool(getEnvOrDefault("APP_SENSITIVE_LOAD_FROM_REDIS", fmt.Sprintf("%t", cfg.Sensitive.LoadFromRedis)))
cfg.Sensitive.RedisKeyPrefix = getEnvOrDefault("APP_SENSITIVE_REDIS_KEY_PREFIX", cfg.Sensitive.RedisKeyPrefix)
cfg.SetupSecret = getEnvOrDefault("APP_SETUP_SECRET", cfg.SetupSecret)
// 安全检查生产模式必须设置JWT密钥 // 安全检查生产模式必须设置JWT密钥
if cfg.Server.Mode != "debug" { if cfg.Server.Mode != "debug" {
@@ -286,7 +355,4 @@ func Load(configPath string) (*Config, error) {
return &cfg, nil return &cfg, nil
} }
// getEnvOrDefault 获取环境变量值,如果未设置则返回默认值
func getEnvOrDefault(key, defaultValue string) string {
return cmp.Or(os.Getenv(key), defaultValue)
}

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
}

83
internal/config/jpush.go Normal file
View File

@@ -0,0 +1,83 @@
package config
type JPushConfig struct {
Enabled bool `mapstructure:"enabled"`
AppKey string `mapstructure:"app_key"`
MasterSecret string `mapstructure:"master_secret"`
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
}

28
internal/config/runner.go Normal file
View File

@@ -0,0 +1,28 @@
package config
// RunnerConfig Runner 集群配置
type RunnerConfig struct {
Mode string `mapstructure:"mode"` // "standalone" | "cluster",空则跟随 websocket.mode
Cluster RunnerClusterConfig `mapstructure:"cluster"`
}
// RunnerClusterConfig Runner 集群模式配置
type RunnerClusterConfig struct {
InstanceID string `mapstructure:"instance_id"` // 空则跟随 websocket.cluster.instance_id
TaskChannel string `mapstructure:"task_channel"`
ResultChannel string `mapstructure:"result_channel"`
RegistryPrefix string `mapstructure:"registry_prefix"`
CapPrefix string `mapstructure:"cap_prefix"`
RegistryTTL int `mapstructure:"registry_ttl"`
HeartbeatInterval int `mapstructure:"heartbeat_interval"`
}
// ApplyWSDefaults 用 WebSocket 配置填充 Runner 中未设置的共享字段
func (r *RunnerConfig) ApplyWSDefaults(ws WSConfig) {
if r.Mode == "" {
r.Mode = ws.Mode
}
if r.Cluster.InstanceID == "" {
r.Cluster.InstanceID = ws.Cluster.InstanceID
}
}

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,15 @@
package config
// WSConfig WebSocket 配置
type WSConfig struct {
Mode string `mapstructure:"mode"` // standalone | cluster
Cluster WSClusterConfig `mapstructure:"cluster"`
}
// WSClusterConfig WebSocket 集群配置
type WSClusterConfig struct {
InstanceID string `mapstructure:"instance_id"`
MsgChannel string `mapstructure:"msg_channel"`
OnlineTTL int `mapstructure:"online_ttl"`
HeartbeatInterval int `mapstructure:"heartbeat_interval"`
}

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

@@ -24,6 +24,9 @@ type UserResponse struct {
IsFollowing bool `json:"is_following"` IsFollowing bool `json:"is_following"`
IsFollowingMe bool `json:"is_following_me"` IsFollowingMe bool `json:"is_following_me"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
// 认证信息
Identity model.UserIdentity `json:"identity,omitempty"`
VerificationStatus model.VerificationStatus `json:"verification_status,omitempty"`
} }
// UserSelfResponse 用户自身信息响应(包含敏感信息,仅本人可见) // UserSelfResponse 用户自身信息响应(包含敏感信息,仅本人可见)
@@ -46,6 +49,9 @@ type UserSelfResponse struct {
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
// 隐私设置 // 隐私设置
PrivacySettings *PrivacySettingsResponse `json:"privacy_settings,omitempty"` PrivacySettings *PrivacySettingsResponse `json:"privacy_settings,omitempty"`
// 认证信息
Identity model.UserIdentity `json:"identity,omitempty"`
VerificationStatus model.VerificationStatus `json:"verification_status,omitempty"`
} }
// UserDetailResponse 用户详情响应(仅本人可见,包含敏感信息) // UserDetailResponse 用户详情响应(仅本人可见,包含敏感信息)
@@ -70,6 +76,9 @@ type UserDetailResponse struct {
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
// 隐私设置 // 隐私设置
PrivacySettings *PrivacySettingsResponse `json:"privacy_settings,omitempty"` PrivacySettings *PrivacySettingsResponse `json:"privacy_settings,omitempty"`
// 认证信息
Identity model.UserIdentity `json:"identity,omitempty"`
VerificationStatus model.VerificationStatus `json:"verification_status,omitempty"`
} }
// ==================== Admin User DTOs ==================== // ==================== Admin User DTOs ====================
@@ -122,79 +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"`
}
// PostDetailResponse 帖子详情响应
type PostDetailResponse 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"`
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 评论图片响应
@@ -242,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"
) )
@@ -445,8 +381,9 @@ 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幂等性去重
} }
// MarkReadRequest 标记已读请求 // MarkReadRequest 标记已读请求
@@ -454,6 +391,23 @@ type MarkReadRequest struct {
LastReadSeq int64 `json:"last_read_seq" binding:"required"` // 已读到的seq位置 LastReadSeq int64 `json:"last_read_seq" binding:"required"` // 已读到的seq位置
} }
// BatchMarkReadItem 批量标记已读单项
type BatchMarkReadItem struct {
ConversationID string `json:"conversation_id" binding:"required"`
LastReadSeq int64 `json:"last_read_seq" binding:"required"`
}
// BatchMarkReadRequest 批量标记已读请求
type BatchMarkReadRequest struct {
Conversations []BatchMarkReadItem `json:"conversations" binding:"required"`
}
// BatchMarkReadResponse 批量标记已读响应
type BatchMarkReadResponse struct {
SuccessCount int `json:"success_count"`
TotalCount int `json:"total_count"`
}
// SetConversationPinnedRequest 设置会话置顶请求 // SetConversationPinnedRequest 设置会话置顶请求
type SetConversationPinnedRequest struct { type SetConversationPinnedRequest struct {
ConversationID string `json:"conversation_id" binding:"required"` ConversationID string `json:"conversation_id" binding:"required"`
@@ -498,6 +452,18 @@ type ConversationUnreadCountResponse struct {
UnreadCount int64 `json:"unread_count"` UnreadCount int64 `json:"unread_count"`
} }
// SyncDataItem 同步数据单项(轻量级,仅包含 seq 和时间)
type SyncDataItem struct {
ConversationID string `json:"id"`
MaxSeq int64 `json:"max_seq"`
LastMessageAt string `json:"last_message_at"`
}
// SyncDataResponse 同步数据响应
type SyncDataResponse struct {
Conversations []SyncDataItem `json:"conversations"`
}
// MessageListResponse 消息列表响应 // MessageListResponse 消息列表响应
type MessageListResponse struct { type MessageListResponse struct {
Messages []*MessageResponse `json:"messages"` Messages []*MessageResponse `json:"messages"`
@@ -533,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 推送记录响应
@@ -607,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事件响应结构体
@@ -733,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
} }
@@ -742,10 +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幂等性去重
} }
// DeleteMsgParams 撤回消息参数 // DeleteMsgParams 撤回消息参数
@@ -794,6 +661,12 @@ type SetGroupAvatarParams struct {
Avatar string `json:"avatar"` // 头像URL Avatar string `json:"avatar"` // 头像URL
} }
// SetGroupDescriptionParams 设置群描述参数
type SetGroupDescriptionParams struct {
GroupID string `json:"group_id"` // 群组ID
Description string `json:"description"` // 群描述
}
// SetGroupLeaveParams 退出群组参数 // SetGroupLeaveParams 退出群组参数
type SetGroupLeaveParams struct { type SetGroupLeaveParams struct {
GroupID string `json:"group_id"` // 群组ID GroupID string `json:"group_id"` // 群组ID
@@ -916,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

@@ -124,50 +124,7 @@ func ConvertPostToResponse(post *model.Post, channelByID map[string]*model.Chann
} }
} }
// ConvertPostToDetailResponse 将Post转换为PostDetailResponsechannelByID 可为 nil // ConvertPostsToResponse 将Post列表转换为响应列表channelByID 可为 nil
func ConvertPostToDetailResponse(post *model.Post, channelByID map[string]*model.Channel, isLiked, isFavorited bool) *PostDetailResponse {
if post == nil {
return nil
}
images := make([]PostImageResponse, 0)
for _, img := range post.Images {
images = append(images, ConvertPostImageToResponse(&img))
}
var author *UserResponse
if post.User != nil {
author = ConvertUserToResponse(post.User)
}
return &PostDetailResponse{
ID: post.ID,
UserID: post.UserID,
ChannelID: post.ChannelID,
Title: post.Title,
Content: post.Content,
Segments: SegmentsOrDefault(post.Segments, post.Content),
Images: images,
Status: string(post.Status),
LikesCount: post.LikesCount,
CommentsCount: post.CommentsCount,
FavoritesCount: post.FavoritesCount,
SharesCount: post.SharesCount,
ViewsCount: post.ViewsCount,
IsPinned: post.IsPinned,
IsLocked: post.IsLocked,
IsVote: post.IsVote,
CreatedAt: FormatTime(post.CreatedAt),
UpdatedAt: FormatTime(post.UpdatedAt),
ContentEditedAt: FormatTimePointer(post.ContentEditedAt),
Author: author,
IsLiked: isLiked,
IsFavorited: isFavorited,
Channel: postChannelBrief(post, channelByID),
}
}
// ConvertPostsToResponse 将Post列表转换为响应列表每个帖子独立检查点赞/收藏状态channelByID 可为 nil
func ConvertPostsToResponse(posts []*model.Post, channelByID map[string]*model.Channel, isLikedMap, isFavoritedMap map[string]bool) []*PostResponse { func ConvertPostsToResponse(posts []*model.Post, channelByID map[string]*model.Channel, isLikedMap, isFavoritedMap map[string]bool) []*PostResponse {
result := make([]*PostResponse, 0, len(posts)) result := make([]*PostResponse, 0, len(posts))
for _, post := range posts { for _, post := range posts {
@@ -255,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,84 +26,73 @@ type ReportResponse struct {
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
} }
// AdminReportListQuery 管理端举报列表查询参数
type AdminReportListQuery struct {
Page int `form:"page" binding:"min=1"`
PageSize int `form:"page_size" binding:"min=1,max=100"`
TargetType string `form:"target_type" binding:"omitempty,oneof=post comment message"`
Status string `form:"status" binding:"omitempty,oneof=pending processing resolved rejected"`
StartDate string `form:"start_date" binding:"omitempty"`
EndDate string `form:"end_date" binding:"omitempty"`
Keyword string `form:"keyword" binding:"omitempty"`
}
// AdminReportListResponse 管理端举报列表响应 // AdminReportListResponse 管理端举报列表响应
type AdminReportListResponse struct { type AdminReportListResponse struct {
ID string `json:"id"` ID string `json:"id"`
ReporterID string `json:"reporter_id"` ReporterID string `json:"reporter_id"`
Reporter *UserResponse `json:"reporter,omitempty"` Reporter *UserResponse `json:"reporter,omitempty"`
TargetType string `json:"target_type"` TargetType string `json:"target_type"`
TargetID string `json:"target_id"` TargetID string `json:"target_id"`
Reason string `json:"reason"` Reason string `json:"reason"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Status string `json:"status"` Status string `json:"status"`
HandledBy *string `json:"handled_by,omitempty"` HandledBy *string `json:"handled_by,omitempty"`
Handler *UserResponse `json:"handler,omitempty"` Handler *UserResponse `json:"handler,omitempty"`
HandledAt *string `json:"handled_at,omitempty"` HandledAt *string `json:"handled_at,omitempty"`
Result *string `json:"result,omitempty"` Result *string `json:"result,omitempty"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
} }
// AdminReportDetailResponse 管理端举报详情响应 // AdminReportDetailResponse 管理端举报详情响应
type AdminReportDetailResponse struct { type AdminReportDetailResponse struct {
ID string `json:"id"` ID string `json:"id"`
ReporterID string `json:"reporter_id"` ReporterID string `json:"reporter_id"`
Reporter *UserResponse `json:"reporter"` Reporter *UserResponse `json:"reporter"`
TargetType string `json:"target_type"` TargetType string `json:"target_type"`
TargetID string `json:"target_id"` TargetID string `json:"target_id"`
Reason string `json:"reason"` Reason string `json:"reason"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Status string `json:"status"` Status string `json:"status"`
HandledBy *string `json:"handled_by,omitempty"` HandledBy *string `json:"handled_by,omitempty"`
Handler *UserResponse `json:"handler,omitempty"` Handler *UserResponse `json:"handler,omitempty"`
HandledAt *string `json:"handled_at,omitempty"` HandledAt *string `json:"handled_at,omitempty"`
Result *string `json:"result,omitempty"` Result *string `json:"result,omitempty"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
// 被举报内容详情 // 被举报内容详情
TargetContent *ReportTargetContent `json:"target_content,omitempty"` TargetContent *ReportTargetContent `json:"target_content,omitempty"`
} }
// ReportTargetContent 被举报内容详情 // ReportTargetContent 被举报内容详情
type ReportTargetContent struct { type ReportTargetContent struct {
Type string `json:"type"` // post, comment, message Type string `json:"type"` // post, comment, message
ID string `json:"id"` ID string `json:"id"`
Author *UserResponse `json:"author,omitempty"` Author *UserResponse `json:"author,omitempty"`
Content string `json:"content,omitempty"` Content string `json:"content,omitempty"`
Title string `json:"title,omitempty"` // 帖子标题 Title string `json:"title,omitempty"` // 帖子标题
Images []string `json:"images,omitempty"` // 图片列表 Images []string `json:"images,omitempty"` // 图片列表
Status string `json:"status,omitempty"` // 内容状态 Status string `json:"status,omitempty"` // 内容状态
CreatedAt string `json:"created_at,omitempty"` CreatedAt string `json:"created_at,omitempty"`
} }
// HandleReportRequest 处理举报请求 // HandleReportRequest 处理举报请求
type HandleReportRequest struct { type HandleReportRequest struct {
Action string `json:"action" binding:"required,oneof=approve reject"` // approve: 确认违规, reject: 驳回 Action string `json:"action" binding:"required,oneof=approve reject"` // approve: 确认违规, reject: 驳回
Result string `json:"result" binding:"max=500"` // 处理结果说明 Result string `json:"result" binding:"max=500"` // 处理结果说明
} }
// BatchHandleReportRequest 批量处理举报请求 // BatchHandleReportRequest 批量处理举报请求
type BatchHandleReportRequest struct { type BatchHandleReportRequest struct {
IDs []string `json:"ids" binding:"required,min=1,max=100"` IDs []string `json:"ids" binding:"required,min=1,max=100"`
Action string `json:"action" binding:"required,oneof=approve reject"` Action string `json:"action" binding:"required,oneof=approve reject"`
Result string `json:"result" binding:"max=500"` Result string `json:"result" binding:"max=500"`
} }
// AdminBatchHandleResponse 批量处理响应 // AdminBatchHandleResponse 批量处理响应
type AdminBatchHandleResponse struct { type AdminBatchHandleResponse struct {
SuccessCount int `json:"success_count"` SuccessCount int `json:"success_count"`
FailedCount int `json:"failed_count"` FailedCount int `json:"failed_count"`
FailedIDs []string `json:"failed_ids,omitempty"` FailedIDs []string `json:"failed_ids,omitempty"`
} }
// ==================== 转换函数 ==================== // ==================== 转换函数 ====================
@@ -122,6 +111,19 @@ func ConvertReportToResponse(report *model.Report) *ReportResponse {
} }
} }
func fillAdminReportCommon(resp *AdminReportListResponse, report *model.Report, reporter *model.User, handler *model.User) {
if reporter != nil {
resp.Reporter = ConvertUserToResponse(reporter)
}
if handler != nil {
resp.Handler = ConvertUserToResponse(handler)
}
if report.HandledAt != nil {
handledAt := FormatTime(*report.HandledAt)
resp.HandledAt = &handledAt
}
}
// ConvertReportToAdminListResponse 将 Report 转换为管理端列表响应 // ConvertReportToAdminListResponse 将 Report 转换为管理端列表响应
func ConvertReportToAdminListResponse(report *model.Report, reporter *model.User, handler *model.User) *AdminReportListResponse { func ConvertReportToAdminListResponse(report *model.Report, reporter *model.User, handler *model.User) *AdminReportListResponse {
resp := &AdminReportListResponse{ resp := &AdminReportListResponse{
@@ -136,51 +138,34 @@ func ConvertReportToAdminListResponse(report *model.Report, reporter *model.User
Result: report.Result, Result: report.Result,
CreatedAt: FormatTime(report.CreatedAt), CreatedAt: FormatTime(report.CreatedAt),
} }
fillAdminReportCommon(resp, report, reporter, handler)
if reporter != nil {
resp.Reporter = ConvertUserToResponse(reporter)
}
if handler != nil {
resp.Handler = ConvertUserToResponse(handler)
}
if report.HandledAt != nil {
handledAt := FormatTime(*report.HandledAt)
resp.HandledAt = &handledAt
}
return resp return resp
} }
// ConvertReportToAdminDetailResponse 将 Report 转换为管理端详情响应 // ConvertReportToAdminDetailResponse 将 Report 转换为管理端详情响应
func ConvertReportToAdminDetailResponse(report *model.Report, reporter *model.User, handler *model.User, targetContent *ReportTargetContent) *AdminReportDetailResponse { func ConvertReportToAdminDetailResponse(report *model.Report, reporter *model.User, handler *model.User, targetContent *ReportTargetContent) *AdminReportDetailResponse {
resp := &AdminReportDetailResponse{ resp := &AdminReportDetailResponse{
ID: report.ID, ID: report.ID,
ReporterID: report.ReporterID, ReporterID: report.ReporterID,
TargetType: string(report.TargetType), TargetType: string(report.TargetType),
TargetID: report.TargetID, TargetID: report.TargetID,
Reason: string(report.Reason), Reason: string(report.Reason),
Description: report.Description, Description: report.Description,
Status: string(report.Status), Status: string(report.Status),
HandledBy: report.HandledBy, HandledBy: report.HandledBy,
Result: report.Result, Result: report.Result,
CreatedAt: FormatTime(report.CreatedAt), CreatedAt: FormatTime(report.CreatedAt),
TargetContent: targetContent, TargetContent: targetContent,
} }
if reporter != nil { if reporter != nil {
resp.Reporter = ConvertUserToResponse(reporter) resp.Reporter = ConvertUserToResponse(reporter)
} }
if handler != nil { if handler != nil {
resp.Handler = ConvertUserToResponse(handler) resp.Handler = ConvertUserToResponse(handler)
} }
if report.HandledAt != nil { if report.HandledAt != nil {
handledAt := FormatTime(*report.HandledAt) handledAt := FormatTime(*report.HandledAt)
resp.HandledAt = &handledAt resp.HandledAt = &handledAt
} }
return resp return resp
} }

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

@@ -45,8 +45,8 @@ func ConvertTradeItemToResponse(item *model.TradeItem, isFavorited bool) *TradeI
FavoritesCount: item.FavoritesCount, FavoritesCount: item.FavoritesCount,
IsFavorited: isFavorited, IsFavorited: isFavorited,
Images: ConvertTradeImagesToResponse(item.Images), Images: ConvertTradeImagesToResponse(item.Images),
CreatedAt: item.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), CreatedAt: FormatTime(item.CreatedAt),
UpdatedAt: item.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), UpdatedAt: FormatTime(item.UpdatedAt),
} }
if len(item.Segments) > 0 { if len(item.Segments) > 0 {
resp.Segments = item.Segments resp.Segments = item.Segments
@@ -66,32 +66,3 @@ func ConvertTradeItemsToResponse(items []*model.TradeItem, favoriteMap map[strin
return result return result
} }
func ConvertTradeItemToDetailResponse(item *model.TradeItem, isFavorited bool) *TradeItemDetailResponse {
if item == nil {
return nil
}
resp := &TradeItemDetailResponse{
ID: item.ID,
UserID: item.UserID,
TradeType: string(item.TradeType),
Category: string(item.Category),
Title: item.Title,
Content: item.Content,
Price: item.Price,
Condition: item.Condition,
Status: string(item.Status),
ViewsCount: item.ViewsCount,
FavoritesCount: item.FavoritesCount,
IsFavorited: isFavorited,
Images: ConvertTradeImagesToResponse(item.Images),
CreatedAt: item.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: item.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
if len(item.Segments) > 0 {
resp.Segments = item.Segments
}
if item.User != nil {
resp.Author = ConvertUserToResponse(item.User)
}
return resp
}

View File

@@ -34,26 +34,6 @@ type TradeItemResponse struct {
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
} }
type TradeItemDetailResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
TradeType string `json:"trade_type"`
Category string `json:"category"`
Title string `json:"title"`
Content string `json:"content"`
Segments model.MessageSegments `json:"segments,omitempty"`
Images []TradeImageResponse `json:"images"`
Price *float64 `json:"price,omitempty"`
Condition string `json:"condition,omitempty"`
Status string `json:"status"`
ViewsCount int `json:"views_count"`
FavoritesCount int `json:"favorites_count"`
IsFavorited bool `json:"is_favorited"`
Author *UserResponse `json:"author"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type CreateTradeItemRequest struct { type CreateTradeItemRequest struct {
TradeType string `json:"trade_type" binding:"required,oneof=sell buy"` TradeType string `json:"trade_type" binding:"required,oneof=sell buy"`
Category string `json:"category" binding:"required,oneof=digital book clothing daily cosmetics sport ticket other"` Category string `json:"category" binding:"required,oneof=digital book clothing daily cosmetics sport ticket other"`

View File

@@ -5,58 +5,125 @@ import (
"with_you/internal/pkg/utils" "with_you/internal/pkg/utils"
) )
// ==================== User 转换 ==================== type UserResponseOption func(*UserResponse)
func WithFollowing(isFollowing, isFollowingMe bool) UserResponseOption {
return func(r *UserResponse) {
r.IsFollowing = isFollowing
r.IsFollowingMe = isFollowingMe
}
}
func WithPostsCount(count int) UserResponseOption {
return func(r *UserResponse) {
r.PostsCount = count
}
}
// getAvatarOrDefault 获取头像URL如果为空则返回在线头像生成服务的URL // getAvatarOrDefault 获取头像URL如果为空则返回在线头像生成服务的URL
func getAvatarOrDefault(user *model.User) string { func getAvatarOrDefault(user *model.User) string {
return utils.GetAvatarOrDefault(user.Username, user.Nickname, user.Avatar) return utils.GetAvatarOrDefault(user.Username, user.Nickname, user.Avatar)
} }
// ConvertUserToResponse 将User转换为UserResponse公开信息不包含敏感数据 // publicVerificationFields 返回认证信息:
func ConvertUserToResponse(user *model.User) *UserResponse { // - approved 时返回真实身份和状态
// - 其他状态统一返回 general + none不暴露具体审核状态
func publicVerificationFields(user *model.User) (model.UserIdentity, model.VerificationStatus) {
if user.VerificationStatus == model.VerificationStatusApproved {
return user.Identity, user.VerificationStatus
}
return model.UserIdentityGeneral, model.VerificationStatusNone
}
func baseUserResponse(user *model.User) *UserResponse {
if user == nil { if user == nil {
return nil return nil
} }
identity, vs := publicVerificationFields(user)
return &UserResponse{ return &UserResponse{
ID: user.ID, ID: user.ID,
Username: user.Username, Username: user.Username,
Nickname: user.Nickname, Nickname: user.Nickname,
EmailVerified: user.EmailVerified, EmailVerified: user.EmailVerified,
Avatar: getAvatarOrDefault(user), Avatar: getAvatarOrDefault(user),
CoverURL: user.CoverURL, CoverURL: user.CoverURL,
Bio: user.Bio, Bio: user.Bio,
Website: user.Website, Website: user.Website,
Location: user.Location, Location: user.Location,
PostsCount: user.PostsCount, PostsCount: user.PostsCount,
FollowersCount: user.FollowersCount, FollowersCount: user.FollowersCount,
FollowingCount: user.FollowingCount, FollowingCount: user.FollowingCount,
CreatedAt: FormatTime(user.CreatedAt), CreatedAt: FormatTime(user.CreatedAt),
Identity: identity,
VerificationStatus: vs,
} }
} }
// ConvertUserToResponse 将User转换为UserResponse公开信息不包含敏感数据
func ConvertUserToResponse(user *model.User, opts ...UserResponseOption) *UserResponse {
r := baseUserResponse(user)
if r == nil {
return nil
}
for _, opt := range opts {
opt(r)
}
return r
}
// ConvertUserToSelfResponse 将User转换为UserSelfResponse本人信息包含敏感数据 // ConvertUserToSelfResponse 将User转换为UserSelfResponse本人信息包含敏感数据
func ConvertUserToSelfResponse(user *model.User, postsCount int) *UserSelfResponse { func ConvertUserToSelfResponse(user *model.User, postsCount int) *UserSelfResponse {
if user == nil { if user == nil {
return nil return nil
} }
return &UserSelfResponse{ return &UserSelfResponse{
ID: user.ID, ID: user.ID,
Username: user.Username, Username: user.Username,
Nickname: user.Nickname, Nickname: user.Nickname,
Email: user.Email, Email: user.Email,
Phone: user.Phone, Phone: user.Phone,
EmailVerified: user.EmailVerified, EmailVerified: user.EmailVerified,
Avatar: getAvatarOrDefault(user), Avatar: getAvatarOrDefault(user),
CoverURL: user.CoverURL, CoverURL: user.CoverURL,
Bio: user.Bio, Bio: user.Bio,
Website: user.Website, Website: user.Website,
Location: user.Location, Location: user.Location,
PostsCount: postsCount, PostsCount: postsCount,
FollowersCount: user.FollowersCount, FollowersCount: user.FollowersCount,
FollowingCount: user.FollowingCount, FollowingCount: user.FollowingCount,
IsVerified: user.IsVerified, IsVerified: user.IsVerified,
CreatedAt: FormatTime(user.CreatedAt), CreatedAt: FormatTime(user.CreatedAt),
PrivacySettings: convertPrivacySettingsToResponse(&user.PrivacySettings), PrivacySettings: convertPrivacySettingsToResponse(&user.PrivacySettings),
Identity: user.Identity,
VerificationStatus: user.VerificationStatus,
}
}
// ConvertUserToDetailResponseWithPostsCount 将User转换为UserDetailResponse使用实时计算的帖子数量仅本人可见
func ConvertUserToDetailResponseWithPostsCount(user *model.User, postsCount int) *UserDetailResponse {
if user == nil {
return nil
}
return &UserDetailResponse{
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
Email: user.Email,
EmailVerified: user.EmailVerified,
Phone: user.Phone,
Avatar: getAvatarOrDefault(user),
CoverURL: user.CoverURL,
Bio: user.Bio,
Website: user.Website,
Location: user.Location,
PostsCount: postsCount,
FollowersCount: user.FollowersCount,
FollowingCount: user.FollowingCount,
IsVerified: user.IsVerified,
CreatedAt: FormatTime(user.CreatedAt),
PrivacySettings: convertPrivacySettingsToResponse(&user.PrivacySettings),
Identity: user.Identity,
VerificationStatus: user.VerificationStatus,
} }
} }
@@ -72,176 +139,16 @@ func convertPrivacySettingsToResponse(ps *model.PrivacySettings) *PrivacySetting
} }
} }
// ConvertUserToResponseWithFollowing 将User转换为UserResponse包含关注状态公开信息
func ConvertUserToResponseWithFollowing(user *model.User, isFollowing bool) *UserResponse {
if user == nil {
return nil
}
return &UserResponse{
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
EmailVerified: user.EmailVerified,
Avatar: getAvatarOrDefault(user),
CoverURL: user.CoverURL,
Bio: user.Bio,
Website: user.Website,
Location: user.Location,
PostsCount: user.PostsCount,
FollowersCount: user.FollowersCount,
FollowingCount: user.FollowingCount,
IsFollowing: isFollowing,
IsFollowingMe: false,
CreatedAt: FormatTime(user.CreatedAt),
}
}
// ConvertUserToResponseWithPostsCount 将User转换为UserResponse使用实时计算的帖子数量公开信息
func ConvertUserToResponseWithPostsCount(user *model.User, postsCount int) *UserResponse {
if user == nil {
return nil
}
return &UserResponse{
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
EmailVerified: user.EmailVerified,
Avatar: getAvatarOrDefault(user),
CoverURL: user.CoverURL,
Bio: user.Bio,
Website: user.Website,
Location: user.Location,
PostsCount: postsCount,
FollowersCount: user.FollowersCount,
FollowingCount: user.FollowingCount,
CreatedAt: FormatTime(user.CreatedAt),
}
}
// ConvertUserToResponseWithMutualFollow 将User转换为UserResponse包含双向关注状态公开信息
func ConvertUserToResponseWithMutualFollow(user *model.User, isFollowing, isFollowingMe bool) *UserResponse {
if user == nil {
return nil
}
return &UserResponse{
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
EmailVerified: user.EmailVerified,
Avatar: getAvatarOrDefault(user),
CoverURL: user.CoverURL,
Bio: user.Bio,
Website: user.Website,
Location: user.Location,
PostsCount: user.PostsCount,
FollowersCount: user.FollowersCount,
FollowingCount: user.FollowingCount,
IsFollowing: isFollowing,
IsFollowingMe: isFollowingMe,
CreatedAt: FormatTime(user.CreatedAt),
}
}
// ConvertUserToResponseWithMutualFollowAndPostsCount 将User转换为UserResponse包含双向关注状态和实时计算的帖子数量公开信息
func ConvertUserToResponseWithMutualFollowAndPostsCount(user *model.User, isFollowing, isFollowingMe bool, postsCount int) *UserResponse {
if user == nil {
return nil
}
return &UserResponse{
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
EmailVerified: user.EmailVerified,
Avatar: getAvatarOrDefault(user),
CoverURL: user.CoverURL,
Bio: user.Bio,
Website: user.Website,
Location: user.Location,
PostsCount: postsCount,
FollowersCount: user.FollowersCount,
FollowingCount: user.FollowingCount,
IsFollowing: isFollowing,
IsFollowingMe: isFollowingMe,
CreatedAt: FormatTime(user.CreatedAt),
}
}
// ConvertUserToDetailResponse 将User转换为UserDetailResponse仅本人可见包含敏感信息
func ConvertUserToDetailResponse(user *model.User) *UserDetailResponse {
if user == nil {
return nil
}
return &UserDetailResponse{
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
Email: user.Email,
EmailVerified: user.EmailVerified,
Avatar: getAvatarOrDefault(user),
CoverURL: user.CoverURL,
Bio: user.Bio,
Website: user.Website,
Location: user.Location,
PostsCount: user.PostsCount,
FollowersCount: user.FollowersCount,
FollowingCount: user.FollowingCount,
IsVerified: user.IsVerified,
CreatedAt: FormatTime(user.CreatedAt),
PrivacySettings: convertPrivacySettingsToResponse(&user.PrivacySettings),
}
}
// ConvertUserToDetailResponseWithPostsCount 将User转换为UserDetailResponse使用实时计算的帖子数量仅本人可见
func ConvertUserToDetailResponseWithPostsCount(user *model.User, postsCount int) *UserDetailResponse {
if user == nil {
return nil
}
return &UserDetailResponse{
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
Email: user.Email,
EmailVerified: user.EmailVerified,
Phone: user.Phone,
Avatar: getAvatarOrDefault(user),
CoverURL: user.CoverURL,
Bio: user.Bio,
Website: user.Website,
Location: user.Location,
PostsCount: postsCount,
FollowersCount: user.FollowersCount,
FollowingCount: user.FollowingCount,
IsVerified: user.IsVerified,
CreatedAt: FormatTime(user.CreatedAt),
PrivacySettings: convertPrivacySettingsToResponse(&user.PrivacySettings),
}
}
// ConvertUsersToResponse 将User列表转换为响应列表 // ConvertUsersToResponse 将User列表转换为响应列表
func ConvertUsersToResponse(users []*model.User) []*UserResponse { func ConvertUsersToResponse(users []*model.User, opts ...UserResponseOption) []*UserResponse {
result := make([]*UserResponse, 0, len(users)) result := make([]*UserResponse, 0, len(users))
for _, user := range users { for _, user := range users {
result = append(result, ConvertUserToResponse(user)) result = append(result, ConvertUserToResponse(user, opts...))
} }
return result return result
} }
// ConvertUsersToResponseWithMutualFollow 将User列表转换为响应列表包含双向关注状态 // ConvertUsersToResponseWithMutualFollowAndPostsCount 将User列表转换为响应列表包含双向关注状态和帖子数量
// followingStatusMap: key是用户IDvalue是[isFollowing, isFollowingMe]
func ConvertUsersToResponseWithMutualFollow(users []*model.User, followingStatusMap map[string][2]bool) []*UserResponse {
result := make([]*UserResponse, 0, len(users))
for _, user := range users {
status, ok := followingStatusMap[user.ID]
if ok {
result = append(result, ConvertUserToResponseWithMutualFollow(user, status[0], status[1]))
} else {
result = append(result, ConvertUserToResponse(user))
}
}
return result
}
// ConvertUsersToResponseWithMutualFollowAndPostsCount 将User列表转换为响应列表包含双向关注状态和实时计算的帖子数量
// followingStatusMap: key是用户IDvalue是[isFollowing, isFollowingMe] // followingStatusMap: key是用户IDvalue是[isFollowing, isFollowingMe]
// postsCountMap: key是用户IDvalue是帖子数量 // postsCountMap: key是用户IDvalue是帖子数量
func ConvertUsersToResponseWithMutualFollowAndPostsCount(users []*model.User, followingStatusMap map[string][2]bool, postsCountMap map[string]int64) []*UserResponse { func ConvertUsersToResponseWithMutualFollowAndPostsCount(users []*model.User, followingStatusMap map[string][2]bool, postsCountMap map[string]int64) []*UserResponse {
@@ -249,17 +156,15 @@ func ConvertUsersToResponseWithMutualFollowAndPostsCount(users []*model.User, fo
for _, user := range users { for _, user := range users {
status, hasStatus := followingStatusMap[user.ID] status, hasStatus := followingStatusMap[user.ID]
postsCount, hasPostsCount := postsCountMap[user.ID] postsCount, hasPostsCount := postsCountMap[user.ID]
// 如果没有帖子数量,使用数据库中的值
if !hasPostsCount { if !hasPostsCount {
postsCount = int64(user.PostsCount) postsCount = int64(user.PostsCount)
} }
var opts []UserResponseOption
if hasStatus { if hasStatus {
result = append(result, ConvertUserToResponseWithMutualFollowAndPostsCount(user, status[0], status[1], int(postsCount))) opts = append(opts, WithFollowing(status[0], status[1]))
} else {
result = append(result, ConvertUserToResponseWithPostsCount(user, int(postsCount)))
} }
opts = append(opts, WithPostsCount(int(postsCount)))
result = append(result, ConvertUserToResponse(user, opts...))
} }
return result return result
} }

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

365
internal/grpc/runner/bus.go Normal file
View File

@@ -0,0 +1,365 @@
package runner
import (
"context"
"encoding/json"
"fmt"
"sync"
"with_you/proto/runner"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
// taskBusMsg Redis 任务频道消息格式
type taskBusMsg struct {
OriginInstanceID string `json:"origin_instance_id"`
TargetRunnerID string `json:"target_runner_id"`
TaskID string `json:"task_id"`
TaskType string `json:"task_type"`
TaskPayload json.RawMessage `json:"task_payload"`
CreatedAt int64 `json:"created_at"`
TimeoutSeconds int32 `json:"timeout_seconds"`
}
// resultBusMsg Redis 结果频道消息格式
type resultBusMsg struct {
OriginInstanceID string `json:"origin_instance_id"`
TaskID string `json:"task_id"`
Status string `json:"status"`
CompletedAt int64 `json:"completed_at"`
Data json.RawMessage `json:"data"`
ErrorCode string `json:"error_code"`
ErrorMessage string `json:"error_message"`
}
// TaskBus 组合 RunnerHub + Redis Pub/Sub实现跨实例任务分发和结果路由
type TaskBus struct {
hub *RunnerHub
registry *RunnerRegistry
rdb *redis.Client
instanceID string
taskChan string
resultChan string
logger *zap.Logger
remoteTasks sync.Map // taskID → originInstanceID从其他实例接收的任务
sub *redis.PubSub
cancel context.CancelFunc
mu sync.Mutex
closed bool
}
// NewTaskBus 创建 TaskBus 实例
func NewTaskBus(hub *RunnerHub, rdb *redis.Client, registry *RunnerRegistry, instanceID, taskChan, resultChan string, logger *zap.Logger) *TaskBus {
return &TaskBus{
hub: hub,
registry: registry,
rdb: rdb,
instanceID: instanceID,
taskChan: taskChan,
resultChan: resultChan,
logger: logger,
}
}
// Start 启动 Redis 订阅
func (b *TaskBus) Start() error {
if b.rdb == nil {
b.logger.Info("TaskBus: no Redis client, running in standalone mode")
return nil
}
ctx, cancel := context.WithCancel(context.Background())
b.cancel = cancel
sub := b.rdb.Subscribe(ctx, b.taskChan, b.resultChan)
b.sub = sub
if _, err := sub.Receive(ctx); err != nil {
cancel()
return err
}
go b.subscribeLoop(ctx, sub)
b.logger.Info("TaskBus started",
zap.String("instance_id", b.instanceID),
zap.String("task_channel", b.taskChan),
zap.String("result_channel", b.resultChan),
)
return nil
}
// Stop 关闭 TaskBus
func (b *TaskBus) Stop() error {
b.mu.Lock()
defer b.mu.Unlock()
if b.closed {
return nil
}
b.closed = true
if b.cancel != nil {
b.cancel()
}
if b.sub != nil {
b.sub.Close()
}
if b.registry != nil {
b.registry.Stop()
}
b.logger.Info("TaskBus stopped", zap.String("instance_id", b.instanceID))
return nil
}
// InstanceID 返回实例 ID
func (b *TaskBus) InstanceID() string {
return b.instanceID
}
// DispatchTask 实现 TaskDispatcher 接口
// 本地优先 → Redis 注册中心查找 → 远程发布
func (b *TaskBus) DispatchTask(task *runner.Task) error {
taskTypeStr := task.Type.String()
// 1. 尝试本地查找
conn, err := b.hub.GetAvailableRunner(task.Type)
if err == nil && conn != nil {
return b.hub.SendTask(conn.ID, task)
}
// 2. 查询 Redis 注册中心
if b.registry != nil {
runnerID, instanceID, err := b.registry.GetAvailableRunner(taskTypeStr)
if err != nil {
return fmt.Errorf("no available runner: %w", err)
}
// 如果 runner 在本实例(可能本地 map 还没更新),直接发送
if instanceID == b.instanceID {
return b.hub.SendTask(runnerID, task)
}
// 发布到远程实例
return b.publishTask(task, runnerID)
}
return fmt.Errorf("no available runner for task type: %s", taskTypeStr)
}
// HandleResult 处理任务结果
// 如果任务来自远程实例,转发结果;否则交给 TaskManager
func (b *TaskBus) HandleResult(result *runner.TaskResult) {
originInstance, ok := b.remoteTasks.Load(result.TaskId)
if ok {
b.remoteTasks.Delete(result.TaskId)
// 任务来自远程实例,转发结果
if originStr, ok := originInstance.(string); ok && originStr != b.instanceID {
b.publishResult(result, originStr)
return
}
}
// 本地任务,交给 TaskManager
if b.hub.taskManager != nil {
b.hub.taskManager.HandleResult(result)
}
}
// publishTask 发布任务到 Redis 频道
func (b *TaskBus) publishTask(task *runner.Task, targetRunnerID string) error {
msg := taskBusMsg{
OriginInstanceID: b.instanceID,
TargetRunnerID: targetRunnerID,
TaskID: task.TaskId,
TaskType: task.Type.String(),
TaskPayload: task.Payload,
CreatedAt: task.CreatedAt,
TimeoutSeconds: task.TimeoutSeconds,
}
data, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("failed to marshal task message: %w", err)
}
if err := b.rdb.Publish(context.Background(), b.taskChan, data).Err(); err != nil {
return fmt.Errorf("failed to publish task to Redis: %w", err)
}
return nil
}
// publishResult 发布结果到 Redis 频道
func (b *TaskBus) publishResult(result *runner.TaskResult, originInstanceID string) error {
var data json.RawMessage
if len(result.Data) > 0 {
data = result.Data
} else {
data = json.RawMessage("{}")
}
msg := resultBusMsg{
OriginInstanceID: originInstanceID,
TaskID: result.TaskId,
Status: result.Status.String(),
CompletedAt: result.CompletedAt,
Data: data,
ErrorCode: result.ErrorCode,
ErrorMessage: result.ErrorMessage,
}
raw, err := json.Marshal(msg)
if err != nil {
b.logger.Error("Failed to marshal result message", zap.Error(err))
return err
}
if err := b.rdb.Publish(context.Background(), b.resultChan, raw).Err(); err != nil {
b.logger.Error("Failed to publish result to Redis", zap.Error(err))
return err
}
return nil
}
// subscribeLoop 处理 Redis 订阅消息
func (b *TaskBus) subscribeLoop(ctx context.Context, sub *redis.PubSub) {
ch := sub.Channel()
for {
select {
case <-ctx.Done():
return
case msg, ok := <-ch:
if !ok {
return
}
switch msg.Channel {
case b.taskChan:
b.handleRemoteTask(msg)
case b.resultChan:
b.handleRemoteResult(msg)
}
}
}
}
// handleRemoteTask 处理从其他实例发来的任务
func (b *TaskBus) handleRemoteTask(msg *redis.Message) {
var taskMsg taskBusMsg
if err := json.Unmarshal([]byte(msg.Payload), &taskMsg); err != nil {
b.logger.Error("TaskBus: failed to unmarshal task message", zap.Error(err))
return
}
// 过滤自己发送的消息
if taskMsg.OriginInstanceID == b.instanceID {
return
}
// 检查目标 Runner 是否在本实例
conn, err := b.hub.GetRunnerByID(taskMsg.TargetRunnerID)
if err != nil {
b.logger.Warn("TaskBus: target runner not found locally",
zap.String("runner_id", taskMsg.TargetRunnerID),
zap.String("task_id", taskMsg.TaskID))
return
}
// 记录任务的来源实例,用于结果回传
b.remoteTasks.Store(taskMsg.TaskID, taskMsg.OriginInstanceID)
// 构造 Task 并发送
task := &runner.Task{
TaskId: taskMsg.TaskID,
Type: parseTaskType(taskMsg.TaskType),
CreatedAt: taskMsg.CreatedAt,
TimeoutSeconds: taskMsg.TimeoutSeconds,
Payload: taskMsg.TaskPayload,
}
if err := b.hub.SendTask(conn.ID, task); err != nil {
b.logger.Error("TaskBus: failed to send remote task to runner",
zap.String("runner_id", conn.ID),
zap.String("task_id", taskMsg.TaskID),
zap.Error(err))
b.remoteTasks.Delete(taskMsg.TaskID)
}
}
// handleRemoteResult 处理从其他实例发来的任务结果
func (b *TaskBus) handleRemoteResult(msg *redis.Message) {
var resultMsg resultBusMsg
if err := json.Unmarshal([]byte(msg.Payload), &resultMsg); err != nil {
b.logger.Error("TaskBus: failed to unmarshal result message", zap.Error(err))
return
}
// 过滤自己发送的消息
if resultMsg.OriginInstanceID == b.instanceID {
return
}
// 只有 origin 匹配本实例才需要处理
// originInstanceID 标识的是发起任务的实例)
// 收到其他实例转发的结果,且 origin 是我们 → 交给 TaskManager
// 但这里收到的是其他实例广播的结果,我们只是中继
// 实际上 result 是发到 runner:result channel 的,所有实例都会收到
// 我们只关心 origin 是本实例的结果
if resultMsg.OriginInstanceID != b.instanceID {
return
}
// 交给 TaskManager 处理
result := &runner.TaskResult{
TaskId: resultMsg.TaskID,
Status: parseTaskStatus(resultMsg.Status),
CompletedAt: resultMsg.CompletedAt,
Data: resultMsg.Data,
ErrorCode: resultMsg.ErrorCode,
ErrorMessage: resultMsg.ErrorMessage,
}
if b.hub.taskManager != nil {
b.hub.taskManager.HandleResult(result)
}
}
// parseTaskType 将字符串转换为 runner.TaskType 枚举
func parseTaskType(s string) runner.TaskType {
switch s {
case "TASK_TYPE_LOGIN":
return runner.TaskType_TASK_TYPE_LOGIN
case "TASK_TYPE_GET_SCHEDULE":
return runner.TaskType_TASK_TYPE_GET_SCHEDULE
case "TASK_TYPE_GET_GRADES":
return runner.TaskType_TASK_TYPE_GET_GRADES
case "TASK_TYPE_GET_EXAMS":
return runner.TaskType_TASK_TYPE_GET_EXAMS
case "TASK_TYPE_GET_USER_INFO":
return runner.TaskType_TASK_TYPE_GET_USER_INFO
default:
return runner.TaskType_TASK_TYPE_UNKNOWN
}
}
// parseTaskStatus 将字符串转换为 runner.TaskStatus 枚举
func parseTaskStatus(s string) runner.TaskStatus {
switch s {
case "TASK_STATUS_SUCCESS":
return runner.TaskStatus_TASK_STATUS_SUCCESS
case "TASK_STATUS_FAILED":
return runner.TaskStatus_TASK_STATUS_FAILED
case "TASK_STATUS_TIMEOUT":
return runner.TaskStatus_TASK_STATUS_TIMEOUT
case "TASK_STATUS_CANCELLED":
return runner.TaskStatus_TASK_STATUS_CANCELLED
default:
return runner.TaskStatus_TASK_STATUS_UNKNOWN
}
}

View File

@@ -26,22 +26,20 @@ type RunnerConnection struct {
type RunnerHub struct { type RunnerHub struct {
runner.UnimplementedRunnerHubServer // 嵌入以实现 gRPC 接口 runner.UnimplementedRunnerHubServer // 嵌入以实现 gRPC 接口
mu sync.RWMutex mu sync.RWMutex
runners map[string]*RunnerConnection runners map[string]*RunnerConnection
taskManager *TaskManager taskManager *TaskManager
registerChan chan *RunnerConnection bus *TaskBus
unregisterChan chan string registry *RunnerRegistry
logger *zap.Logger logger *zap.Logger
} }
// NewRunnerHub 创建新的 RunnerHub // NewRunnerHub 创建新的 RunnerHub
func NewRunnerHub(taskManager *TaskManager, logger *zap.Logger) *RunnerHub { func NewRunnerHub(taskManager *TaskManager, logger *zap.Logger) *RunnerHub {
return &RunnerHub{ return &RunnerHub{
runners: make(map[string]*RunnerConnection), runners: make(map[string]*RunnerConnection),
taskManager: taskManager, taskManager: taskManager,
registerChan: make(chan *RunnerConnection, 100), logger: logger,
unregisterChan: make(chan string, 100),
logger: logger,
} }
} }
@@ -137,6 +135,15 @@ func (h *RunnerHub) handleRegister(req *runner.RegisterRequest, stream runner.Ru
h.runners[req.RunnerId] = conn h.runners[req.RunnerId] = conn
h.mu.Unlock() h.mu.Unlock()
// 同步到 Redis 注册中心
if h.registry != nil {
if err := h.registry.Register(req.RunnerId, req.Version, req.Capabilities); err != nil {
h.logger.Error("Failed to register runner in registry",
zap.String("runner_id", req.RunnerId),
zap.Error(err))
}
}
// 发送注册响应 // 发送注册响应
if err := conn.send(&runner.StreamMessage{ if err := conn.send(&runner.StreamMessage{
Message: &runner.StreamMessage_RegisterResponse{ Message: &runner.StreamMessage_RegisterResponse{
@@ -161,6 +168,15 @@ func (h *RunnerHub) handleHeartbeat(conn *RunnerConnection, hb *runner.Heartbeat
} }
conn.LastSeen = time.Now() conn.LastSeen = time.Now()
// 刷新 Redis 注册中心的 TTL
if h.registry != nil {
if err := h.registry.RefreshHeartbeat(conn.ID); err != nil {
h.logger.Debug("Failed to refresh runner heartbeat in registry",
zap.String("runner_id", conn.ID),
zap.Error(err))
}
}
if err := conn.send(&runner.StreamMessage{ if err := conn.send(&runner.StreamMessage{
Message: &runner.StreamMessage_HeartbeatAck{ Message: &runner.StreamMessage_HeartbeatAck{
HeartbeatAck: &runner.HeartbeatAck{ HeartbeatAck: &runner.HeartbeatAck{
@@ -179,6 +195,12 @@ func (h *RunnerHub) handleTaskResult(result *runner.TaskResult) {
zap.String("task_id", result.TaskId), zap.String("task_id", result.TaskId),
zap.String("status", result.Status.String())) zap.String("status", result.Status.String()))
// 如果有 Bus委托给 Bus 处理(可能需要转发到远程实例)
if h.bus != nil {
h.bus.HandleResult(result)
return
}
if h.taskManager != nil { if h.taskManager != nil {
h.taskManager.HandleResult(result) h.taskManager.HandleResult(result)
} }
@@ -187,12 +209,19 @@ func (h *RunnerHub) handleTaskResult(result *runner.TaskResult) {
// unregisterRunner 注销 Runner // unregisterRunner 注销 Runner
func (h *RunnerHub) unregisterRunner(runnerID string) { func (h *RunnerHub) unregisterRunner(runnerID string) {
h.mu.Lock() h.mu.Lock()
var capabilities map[string]string
if conn, ok := h.runners[runnerID]; ok { if conn, ok := h.runners[runnerID]; ok {
capabilities = conn.Capabilities
close(conn.sendChan) close(conn.sendChan)
delete(h.runners, runnerID) delete(h.runners, runnerID)
} }
h.mu.Unlock() h.mu.Unlock()
// 从 Redis 注册中心移除
if h.registry != nil && capabilities != nil {
h.registry.Unregister(runnerID, capabilities)
}
h.logger.Info("Runner unregistered", zap.String("runner_id", runnerID)) h.logger.Info("Runner unregistered", zap.String("runner_id", runnerID))
} }
@@ -257,6 +286,37 @@ func (h *RunnerHub) SetTaskManager(tm *TaskManager) {
h.taskManager = tm h.taskManager = tm
} }
// SetBus 设置 TaskBus集群模式
func (h *RunnerHub) SetBus(bus *TaskBus) {
h.bus = bus
}
// SetRegistry 设置 RunnerRegistry集群模式
func (h *RunnerHub) SetRegistry(registry *RunnerRegistry) {
h.registry = registry
}
// DispatchTask 实现 TaskDispatcher 接口standalone 模式)
func (h *RunnerHub) DispatchTask(task *runner.Task) error {
conn, err := h.GetAvailableRunner(task.Type)
if err != nil {
return err
}
return h.SendTask(conn.ID, task)
}
// GetRunnerByID 根据 ID 获取 Runner 连接
func (h *RunnerHub) GetRunnerByID(runnerID string) (*RunnerConnection, error) {
h.mu.RLock()
defer h.mu.RUnlock()
conn, ok := h.runners[runnerID]
if !ok {
return nil, fmt.Errorf("runner not found: %s", runnerID)
}
return conn, nil
}
// send 向 RunnerConnection 发送消息 // send 向 RunnerConnection 发送消息
func (c *RunnerConnection) send(msg *runner.StreamMessage) error { func (c *RunnerConnection) send(msg *runner.StreamMessage) error {
select { select {
@@ -270,8 +330,11 @@ func (c *RunnerConnection) send(msg *runner.StreamMessage) error {
// CheckStaleRunners 检查并清理超时的 Runner 连接 // CheckStaleRunners 检查并清理超时的 Runner 连接
func (h *RunnerHub) CheckStaleRunners(timeout time.Duration) { func (h *RunnerHub) CheckStaleRunners(timeout time.Duration) {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock()
var toRemove []struct {
id string
capabilities map[string]string
}
now := time.Now() now := time.Now()
for id, conn := range h.runners { for id, conn := range h.runners {
if now.Sub(conn.LastSeen) > timeout { if now.Sub(conn.LastSeen) > timeout {
@@ -280,6 +343,18 @@ func (h *RunnerHub) CheckStaleRunners(timeout time.Duration) {
zap.Time("last_seen", conn.LastSeen)) zap.Time("last_seen", conn.LastSeen))
close(conn.sendChan) close(conn.sendChan)
delete(h.runners, id) delete(h.runners, id)
toRemove = append(toRemove, struct {
id string
capabilities map[string]string
}{id: id, capabilities: conn.Capabilities})
}
}
h.mu.Unlock()
// 从 Redis 注册中心批量移除
if h.registry != nil {
for _, r := range toRemove {
h.registry.Unregister(r.id, r.capabilities)
} }
} }
} }

View File

@@ -0,0 +1,305 @@
package runner
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
// RunnerRegistry 分布式 Runner 注册中心
// 使用 Redis Hash 存储 Runner 信息Redis Set 按能力索引
type RunnerRegistry struct {
rdb *redis.Client
instanceID string
registryPrefix string
capPrefix string
onlineTTL time.Duration
heartbeatInterval time.Duration
localRunners sync.Map // runnerID → map[string]string (capabilities)
logger *zap.Logger
cancel context.CancelFunc
}
// runnerInfo 存储在 Redis Hash 中的 Runner 信息
type runnerInfo struct {
InstanceID string `json:"instance_id"`
Version string `json:"version"`
Capabilities string `json:"capabilities"` // JSON-encoded map
}
// NewRunnerRegistry 创建 Runner 注册中心
func NewRunnerRegistry(rdb *redis.Client, instanceID, registryPrefix, capPrefix string, onlineTTL, heartbeatInterval time.Duration, logger *zap.Logger) *RunnerRegistry {
return &RunnerRegistry{
rdb: rdb,
instanceID: instanceID,
registryPrefix: registryPrefix,
capPrefix: capPrefix,
onlineTTL: onlineTTL,
heartbeatInterval: heartbeatInterval,
logger: logger,
}
}
// Register 注册 Runner 到分布式注册中心
func (r *RunnerRegistry) Register(runnerID, version string, capabilities map[string]string) error {
if r.rdb == nil {
return nil
}
ctx := context.Background()
capJSON, _ := json.Marshal(capabilities)
key := r.registryPrefix + runnerID
info := runnerInfo{
InstanceID: r.instanceID,
Version: version,
Capabilities: string(capJSON),
}
infoJSON, _ := json.Marshal(info)
pipe := r.rdb.Pipeline()
pipe.Set(ctx, key, infoJSON, r.onlineTTL)
// 为每个能力创建索引
taskTypeStr := parseTaskTypes(capabilities)
for _, tt := range taskTypeStr {
capKey := r.capPrefix + tt
pipe.SAdd(ctx, capKey, runnerID)
pipe.Expire(ctx, capKey, r.onlineTTL*2)
}
if _, err := pipe.Exec(ctx); err != nil {
return fmt.Errorf("failed to register runner in redis: %w", err)
}
r.localRunners.Store(runnerID, capabilities)
return nil
}
// Unregister 从分布式注册中心移除 Runner
func (r *RunnerRegistry) Unregister(runnerID string, capabilities map[string]string) {
if r.rdb == nil {
return
}
ctx := context.Background()
key := r.registryPrefix + runnerID
pipe := r.rdb.Pipeline()
pipe.Del(ctx, key)
taskTypeStr := parseTaskTypes(capabilities)
for _, tt := range taskTypeStr {
capKey := r.capPrefix + tt
pipe.SRem(ctx, capKey, runnerID)
}
if _, err := pipe.Exec(ctx); err != nil {
r.logger.Error("Failed to unregister runner from redis",
zap.String("runner_id", runnerID),
zap.Error(err))
}
r.localRunners.Delete(runnerID)
}
// RefreshHeartbeat 刷新 Runner 的 TTL
func (r *RunnerRegistry) RefreshHeartbeat(runnerID string) error {
if r.rdb == nil {
return nil
}
ctx := context.Background()
key := r.registryPrefix + runnerID
return r.rdb.Expire(ctx, key, r.onlineTTL).Err()
}
// GetAvailableRunner 查找支持指定任务类型的 Runner
// 返回 runnerID 和所在 instanceID
func (r *RunnerRegistry) GetAvailableRunner(taskType string) (runnerID, instanceID string, err error) {
if r.rdb == nil {
return "", "", fmt.Errorf("registry not available")
}
ctx := context.Background()
capKey := r.capPrefix + taskType
members, err := r.rdb.SMembers(ctx, capKey).Result()
if err != nil {
return "", "", fmt.Errorf("failed to query runner capabilities: %w", err)
}
if len(members) == 0 {
return "", "", fmt.Errorf("no runner available for task type: %s", taskType)
}
// 随机选一个伪随机map遍历本身随机性
for _, rid := range members {
key := r.registryPrefix + rid
val, err := r.rdb.Get(ctx, key).Bytes()
if err != nil {
continue // 已过期,跳过
}
var info runnerInfo
if err := json.Unmarshal(val, &info); err != nil {
continue
}
return rid, info.InstanceID, nil
}
return "", "", fmt.Errorf("no runner available for task type: %s", taskType)
}
// GetRunnerInstance 获取 Runner 所在的实例 ID
func (r *RunnerRegistry) GetRunnerInstance(runnerID string) (string, error) {
if r.rdb == nil {
return "", fmt.Errorf("registry not available")
}
ctx := context.Background()
key := r.registryPrefix + runnerID
val, err := r.rdb.Get(ctx, key).Bytes()
if err != nil {
return "", fmt.Errorf("runner not found: %s", runnerID)
}
var info runnerInfo
if err := json.Unmarshal(val, &info); err != nil {
return "", fmt.Errorf("failed to parse runner info: %w", err)
}
return info.InstanceID, nil
}
// GetAllRunners 获取所有注册的 Runner 信息
func (r *RunnerRegistry) GetAllRunners() ([]map[string]any, error) {
if r.rdb == nil {
return nil, nil
}
ctx := context.Background()
pattern := r.registryPrefix + "*"
keys, err := r.rdb.Keys(ctx, pattern).Result()
if err != nil {
return nil, err
}
result := make([]map[string]any, 0, len(keys))
for _, key := range keys {
val, err := r.rdb.Get(ctx, key).Bytes()
if err != nil {
continue
}
var info runnerInfo
if err := json.Unmarshal(val, &info); err != nil {
continue
}
var caps map[string]string
json.Unmarshal([]byte(info.Capabilities), &caps)
runnerID := key[len(r.registryPrefix):]
result = append(result, map[string]any{
"id": runnerID,
"instance_id": info.InstanceID,
"version": info.Version,
"capabilities": caps,
})
}
return result, nil
}
// StartHeartbeat 启动心跳续期
func (r *RunnerRegistry) StartHeartbeat() {
if r.rdb == nil {
return
}
ctx, cancel := context.WithCancel(context.Background())
r.cancel = cancel
go func() {
ticker := time.NewTicker(r.heartbeatInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
r.heartbeat()
}
}
}()
}
func (r *RunnerRegistry) heartbeat() {
ctx := context.Background()
pipe := r.rdb.Pipeline()
count := 0
r.localRunners.Range(func(key, _ any) bool {
runnerID := key.(string)
regKey := r.registryPrefix + runnerID
pipe.Expire(ctx, regKey, r.onlineTTL)
count++
return true
})
if count > 0 {
if _, err := pipe.Exec(ctx); err != nil {
r.logger.Error("Failed to heartbeat runner registry", zap.Error(err))
}
}
}
// Stop 停止心跳并清理本地 Runner 的 Redis 注册
func (r *RunnerRegistry) Stop() {
if r.cancel != nil {
r.cancel()
}
if r.rdb == nil {
return
}
r.localRunners.Range(func(key, value any) bool {
runnerID := key.(string)
capabilities, _ := value.(map[string]string)
r.Unregister(runnerID, capabilities)
return true
})
}
// parseTaskTypes 从 capabilities map 中提取支持的任务类型列表
func parseTaskTypes(capabilities map[string]string) []string {
var types []string
for capKey, capValue := range capabilities {
// key = "TASK_TYPE_GET_SCHEDULE", value = "true"
if capValue == "true" && isTaskType(capKey) {
types = append(types, capKey)
continue
}
// key = "schedule", value = "TASK_TYPE_GET_SCHEDULE"
if isTaskType(capValue) {
types = append(types, capValue)
}
}
return types
}
// isTaskType 检查字符串是否为有效的 TaskType 枚举值
func isTaskType(s string) bool {
switch s {
case "TASK_TYPE_LOGIN", "TASK_TYPE_GET_SCHEDULE", "TASK_TYPE_GET_GRADES",
"TASK_TYPE_GET_EXAMS", "TASK_TYPE_GET_USER_INFO":
return true
}
return false
}

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,13 +21,8 @@ type Server struct {
grpcSrv *grpc.Server grpcSrv *grpc.Server
} }
// NewServer 创建新的 gRPC 服务器 // NewServerWithDeps 使用外部依赖创建 gRPC 服务器Wire DI 使用)
func NewServer(cfg *config.GRPCConfig, logger *zap.Logger) *Server { func NewServerWithDeps(cfg *config.GRPCConfig, logger *zap.Logger, hub *RunnerHub) *Server {
// 创建 TaskManager 和 RunnerHub解决循环依赖
taskManager := NewTaskManager(nil, 60*time.Second, logger)
hub := NewRunnerHub(taskManager, logger)
taskManager.SetHub(hub)
return &Server{ return &Server{
cfg: cfg, cfg: cfg,
logger: logger, logger: logger,
@@ -103,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

@@ -16,6 +16,11 @@ import (
// TaskCallback 任务完成回调函数类型 // TaskCallback 任务完成回调函数类型
type TaskCallback func(result *runner.TaskResult) type TaskCallback func(result *runner.TaskResult)
// TaskDispatcher 任务分发接口
type TaskDispatcher interface {
DispatchTask(task *runner.Task) error
}
// PendingTask 表示一个等待处理的任务 // PendingTask 表示一个等待处理的任务
type PendingTask struct { type PendingTask struct {
Task *runner.Task Task *runner.Task
@@ -25,20 +30,20 @@ type PendingTask struct {
// TaskManager 管理任务的生命周期 // TaskManager 管理任务的生命周期
type TaskManager struct { type TaskManager struct {
mu sync.RWMutex mu sync.RWMutex
pending map[string]*PendingTask pending map[string]*PendingTask
hub *RunnerHub dispatcher TaskDispatcher
timeout time.Duration timeout time.Duration
logger *zap.Logger logger *zap.Logger
} }
// NewTaskManager 创建新的任务管理器 // NewTaskManager 创建新的任务管理器
func NewTaskManager(hub *RunnerHub, timeout time.Duration, logger *zap.Logger) *TaskManager { func NewTaskManager(dispatcher TaskDispatcher, timeout time.Duration, logger *zap.Logger) *TaskManager {
return &TaskManager{ return &TaskManager{
pending: make(map[string]*PendingTask), pending: make(map[string]*PendingTask),
hub: hub, dispatcher: dispatcher,
timeout: timeout, timeout: timeout,
logger: logger, logger: logger,
} }
} }
@@ -69,17 +74,10 @@ func (tm *TaskManager) SubmitTask(ctx context.Context, taskType runner.TaskType,
zap.String("task_id", task.TaskId), zap.String("task_id", task.TaskId),
zap.String("type", taskType.String())) zap.String("type", taskType.String()))
// 获取可用 Runner // 通过 dispatcher 分发任务
conn, err := tm.hub.GetAvailableRunner(taskType) if err := tm.dispatcher.DispatchTask(task); err != nil {
if err != nil {
tm.cleanupTask(task.TaskId) tm.cleanupTask(task.TaskId)
return nil, fmt.Errorf("no available runner: %w", err) return nil, fmt.Errorf("failed to dispatch task: %w", err)
}
// 发送任务
if err := tm.hub.SendTask(conn.ID, task); err != nil {
tm.cleanupTask(task.TaskId)
return nil, fmt.Errorf("failed to send task: %w", err)
} }
// 启动超时检查 // 启动超时检查
@@ -203,9 +201,9 @@ func (tm *TaskManager) GetPendingTaskCount() int {
return len(tm.pending) return len(tm.pending)
} }
// SetHub 设置 RunnerHub(用于解决循环依赖) // SetDispatcher 设置任务分发器(用于解决循环依赖)
func (tm *TaskManager) SetHub(hub *RunnerHub) { func (tm *TaskManager) SetDispatcher(dispatcher TaskDispatcher) {
tm.hub = hub tm.dispatcher = dispatcher
} }
// CleanupStaleTasks 清理超时的任务 // CleanupStaleTasks 清理超时的任务

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"strconv" "strconv"

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"strconv" "strconv"

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"strconv" "strconv"

View File

@@ -1,11 +1,11 @@
package handler package handler
import ( 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,4 +1,4 @@
package handler package handler
import ( import (
"strconv" "strconv"

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"strconv" "strconv"

View File

@@ -1,10 +1,10 @@
package handler package handler
import ( import (
"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"
"strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap" "go.uber.org/zap"
@@ -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

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"strconv" "strconv"
@@ -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

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"strconv" "strconv"
@@ -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

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"with_you/internal/model" "with_you/internal/model"
@@ -120,4 +120,3 @@ func (h *ChannelHandler) AdminDelete(c *gin.Context) {
} }
response.Success(c, gin.H{"success": true}) response.Success(c, gin.H{"success": true})
} }

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,
} }
@@ -178,9 +178,9 @@ func (h *CommentHandler) Update(c *gin.Context) {
comment.Content = req.Content comment.Content = req.Content
err = h.commentService.Update(c.Request.Context(), comment) err = h.commentService.Update(c.Request.Context(), userID, comment)
if err != nil { if err != nil {
response.InternalServerError(c, "failed to update comment") response.HandleError(c, err, "failed to update comment")
return return
} }
@@ -208,9 +208,9 @@ func (h *CommentHandler) Delete(c *gin.Context) {
return return
} }
err = h.commentService.Delete(c.Request.Context(), id) err = h.commentService.Delete(c.Request.Context(), userID, id)
if err != nil { if err != nil {
response.InternalServerError(c, "failed to delete comment") response.HandleError(c, err, "failed to delete comment")
return return
} }

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

@@ -0,0 +1,75 @@
package handler
import (
"github.com/gin-gonic/gin"
"with_you/internal/pkg/response"
"with_you/internal/service"
)
type ExamHandler struct {
examSyncService service.ExamSyncService
}
func NewExamHandler(examSyncService service.ExamSyncService) *ExamHandler {
return &ExamHandler{
examSyncService: examSyncService,
}
}
type syncExamsRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
Semester string `json:"semester"`
}
func (h *ExamHandler) SyncExams(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "")
return
}
var req syncExamsRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "invalid request: "+err.Error())
return
}
result, err := h.examSyncService.SyncUserExams(c.Request.Context(), userID, &service.SyncExamsRequest{
Username: req.Username,
Password: req.Password,
Semester: req.Semester,
})
if err != nil {
response.HandleError(c, err, "failed to sync exams")
return
}
if !result.Success {
response.Success(c, result)
return
}
response.SuccessWithMessage(c, result.Message, result)
}
func (h *ExamHandler) ListExams(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
}
exams, err := h.examSyncService.ListExams(userID, semester)
if err != nil {
response.HandleError(c, err, "failed to list exams")
return
}
response.Success(c, gin.H{"exams": exams})
}

View File

@@ -0,0 +1,73 @@
package handler
import (
"github.com/gin-gonic/gin"
"with_you/internal/pkg/response"
"with_you/internal/service"
)
type GradeHandler struct {
gradeSyncService service.GradeSyncService
}
func NewGradeHandler(gradeSyncService service.GradeSyncService) *GradeHandler {
return &GradeHandler{
gradeSyncService: gradeSyncService,
}
}
type syncGradesRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
Semester string `json:"semester"`
}
func (h *GradeHandler) SyncGrades(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "")
return
}
var req syncGradesRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "invalid request: "+err.Error())
return
}
result, err := h.gradeSyncService.SyncUserGrades(c.Request.Context(), userID, &service.SyncGradesRequest{
Username: req.Username,
Password: req.Password,
Semester: req.Semester,
})
if err != nil {
response.HandleError(c, err, "failed to sync grades")
return
}
if !result.Success {
response.Success(c, result)
return
}
response.SuccessWithMessage(c, result.Message, result)
}
func (h *GradeHandler) ListGrades(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "")
return
}
summary, err := h.gradeSyncService.ListGradesWithSummary(userID)
if err != nil {
response.HandleError(c, err, "failed to list grades")
return
}
response.Success(c, gin.H{
"grades": summary.Grades,
"gpa_summary": summary.GpaSummary,
})
}

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"errors" "errors"
@@ -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)
@@ -1764,6 +1780,50 @@ func (h *GroupHandler) HandleSetGroupAvatar(c *gin.Context) {
response.Success(c, dto.GroupToResponse(group)) response.Success(c, dto.GroupToResponse(group))
} }
// HandleSetGroupDescription 设置群描述
// PUT /api/v1/groups/:id/description
func (h *GroupHandler) HandleSetGroupDescription(c *gin.Context) {
userID := parseUserID(c)
if userID == "" {
response.Unauthorized(c, "")
return
}
groupID := parseGroupID(c)
if groupID == "" {
response.BadRequest(c, "group_id is required")
return
}
var params dto.SetGroupDescriptionParams
if err := c.ShouldBindJSON(&params); err != nil {
response.BadRequest(c, err.Error())
return
}
updates := map[string]any{
"description": params.Description,
}
err := h.groupService.UpdateGroup(userID, groupID, updates)
if err != nil {
if err == service.ErrNotGroupAdmin {
response.Forbidden(c, "没有权限修改群组信息")
return
}
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
response.InternalServerError(c, err.Error())
return
}
// 获取更新后的群组信息
group, _ := h.groupService.GetGroupByID(groupID)
response.Success(c, dto.GroupToResponse(group))
}
// HandleSetGroupLeave 退出群组 // HandleSetGroupLeave 退出群组
// POST /api/v1/groups/:id/leave // POST /api/v1/groups/:id/leave
func (h *GroupHandler) HandleSetGroupLeave(c *gin.Context) { func (h *GroupHandler) HandleSetGroupLeave(c *gin.Context) {
@@ -1893,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 == "" {
@@ -1923,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 == "" {
@@ -1944,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

@@ -1,12 +1,12 @@
package handler package handler
import ( 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

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"context" "context"
@@ -14,26 +14,113 @@ import (
"with_you/internal/service" "with_you/internal/service"
) )
// enrichConversations 批量填充会话列表响应数据(解决 N+1 问题)
func (h *MessageHandler) enrichConversations(ctx context.Context, convs []*model.Conversation, userID string) []*dto.ConversationResponse {
if len(convs) == 0 {
return nil
}
convIDs := make([]string, len(convs))
for i, c := range convs {
convIDs[i] = c.ID
}
// 批量查询4-5 次查询替代 N*5 次
unreadCounts, _ := h.chatService.GetUnreadCountBatch(ctx, userID, convIDs)
lastMessages, _ := h.chatService.GetLastMessagesBatch(ctx, convIDs)
myParticipants, _ := h.messageService.GetMyParticipantsBatch(ctx, convIDs, userID)
allParticipants, _ := h.messageService.GetParticipantsBatch(ctx, convIDs)
// 收集需要额外查询的 ID
var groupIDs []string
var userIDs []string
for _, conv := range convs {
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" {
groupIDs = append(groupIDs, *conv.GroupID)
}
}
for _, participants := range allParticipants {
for _, p := range participants {
if p.UserID != userID {
userIDs = append(userIDs, p.UserID)
}
}
}
memberCounts, _ := h.groupService.GetMemberCountBatch(ctx, groupIDs)
usersMap, _ := h.userService.GetUsersByIDs(ctx, userIDs)
// 组装响应
result := make([]*dto.ConversationResponse, len(convs))
for i, conv := range convs {
unreadCount := unreadCounts[conv.ID]
lastMessage := lastMessages[conv.ID]
myP := myParticipants[conv.ID]
isPinned := myP != nil && myP.IsPinned
notificationMuted := myP != nil && myP.NotificationMuted
var resp *dto.ConversationResponse
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" {
resp = dto.ConvertConversationToResponse(conv, nil, int(unreadCount), lastMessage, isPinned, notificationMuted)
if mc, ok := memberCounts[*conv.GroupID]; ok {
resp.MemberCount = int(mc)
}
} else {
participants := allParticipants[conv.ID]
var users []*model.User
for _, p := range participants {
if p.UserID == userID {
continue
}
if u, ok := usersMap[p.UserID]; ok {
users = append(users, u)
}
}
resp = dto.ConvertConversationToResponse(conv, users, int(unreadCount), lastMessage, isPinned, notificationMuted)
}
result[i] = resp
}
return result
}
// 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
wsHub *ws.Hub uploadService service.UploadService
wsPublisher ws.MessagePublisher
} }
// NewMessageHandler 创建消息处理器 // NewMessageHandler 创建消息处理器
func NewMessageHandler(chatService service.ChatService, messageService *service.MessageService, userService service.UserService, groupService service.GroupService, wsHub *ws.Hub) *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,
wsHub: wsHub, uploadService: uploadService,
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) {
@@ -80,37 +167,8 @@ func (h *MessageHandler) GetConversations(c *gin.Context) {
} }
} }
// 转换为响应格式 // 批量填充会话数据(解决 N+1 问题)
result := make([]*dto.ConversationResponse, len(filteredConvs)) result := h.enrichConversations(c.Request.Context(), filteredConvs, userID)
for i, conv := range filteredConvs {
// 获取未读数
unreadCount, _ := h.chatService.GetUnreadCount(c.Request.Context(), conv.ID, userID)
// 获取最后一条消息
var lastMessage *model.Message
messages, _, _ := h.chatService.GetMessages(c.Request.Context(), conv.ID, userID, 1, 1)
if len(messages) > 0 {
lastMessage = messages[0]
}
// 群聊时返回member_count私聊时返回participants
var resp *dto.ConversationResponse
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
isPinned := myParticipant != nil && myParticipant.IsPinned
notificationMuted := myParticipant != nil && myParticipant.NotificationMuted
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" {
// 群聊:实时计算群成员数量
memberCount, _ := h.groupService.GetMemberCount(*conv.GroupID)
// 创建响应并设置member_count
resp = dto.ConvertConversationToResponse(conv, nil, int(unreadCount), lastMessage, isPinned, notificationMuted)
resp.MemberCount = memberCount
} else {
// 私聊:获取参与者信息
participants, _ := h.getConversationParticipants(c.Request.Context(), conv.ID, userID)
resp = dto.ConvertConversationToResponse(conv, participants, int(unreadCount), lastMessage, isPinned, notificationMuted)
}
result[i] = resp
}
// 更新 total 为过滤后的数量 // 更新 total 为过滤后的数量
response.Paginated(c, result, int64(len(filteredConvs)), page, pageSize) response.Paginated(c, result, int64(len(filteredConvs)), page, pageSize)
@@ -203,6 +261,12 @@ func (h *MessageHandler) GetConversationByID(c *gin.Context) {
// 获取对方用户的已读位置 // 获取对方用户的已读位置
otherLastReadSeq := int64(0) otherLastReadSeq := int64(0)
for _, p := range allParticipants {
if p.UserID != userID {
otherLastReadSeq = p.LastReadSeq
break
}
}
response.Success(c, dto.ConvertConversationToDetailResponse(conv, participants, unreadCount, nil, myLastReadSeq, otherLastReadSeq, isPinned, notificationMuted)) response.Success(c, dto.ConvertConversationToDetailResponse(conv, participants, unreadCount, nil, myLastReadSeq, otherLastReadSeq, isPinned, notificationMuted))
} }
@@ -240,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,
@@ -268,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,
@@ -288,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) {
@@ -316,8 +411,7 @@ func (h *MessageHandler) SendMessage(c *gin.Context) {
return return
} }
// 直接使用 segments msg, err := h.chatService.SendMessage(c.Request.Context(), userID, conversationID, req.Segments, req.ReplyToID, req.ClientMsgID)
msg, err := h.chatService.SendMessage(c.Request.Context(), userID, conversationID, req.Segments, req.ReplyToID)
if err != nil { if err != nil {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
@@ -359,7 +453,7 @@ func (h *MessageHandler) HandleSendMessage(c *gin.Context) {
} }
// 发送消息 // 发送消息
msg, err := h.chatService.SendMessage(c.Request.Context(), userID, conversationID, params.Segments, params.ReplyToID) msg, err := h.chatService.SendMessage(c.Request.Context(), userID, conversationID, params.Segments, params.ReplyToID, params.ClientMsgID)
if err != nil { if err != nil {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
@@ -372,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,
} }
@@ -438,37 +532,8 @@ func (h *MessageHandler) HandleGetConversationList(c *gin.Context) {
} }
} }
// 转换为响应格式 // 批量填充会话数据(解决 N+1 问题)
result := make([]*dto.ConversationResponse, len(filteredConvs)) result := h.enrichConversations(c.Request.Context(), filteredConvs, userID)
for i, conv := range filteredConvs {
// 获取未读数
unreadCount, _ := h.chatService.GetUnreadCount(c.Request.Context(), conv.ID, userID)
// 获取最后一条消息
var lastMessage *model.Message
messages, _, _ := h.chatService.GetMessages(c.Request.Context(), conv.ID, userID, 1, 1)
if len(messages) > 0 {
lastMessage = messages[0]
}
// 群聊时返回member_count私聊时返回participants
var resp *dto.ConversationResponse
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
isPinned := myParticipant != nil && myParticipant.IsPinned
notificationMuted := myParticipant != nil && myParticipant.NotificationMuted
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" {
// 群聊:实时计算群成员数量
memberCount, _ := h.groupService.GetMemberCount(*conv.GroupID)
// 创建响应并设置member_count
resp = dto.ConvertConversationToResponse(conv, nil, int(unreadCount), lastMessage, isPinned, notificationMuted)
resp.MemberCount = memberCount
} else {
// 私聊:获取参与者信息
participants, _ := h.getConversationParticipants(c.Request.Context(), conv.ID, userID)
resp = dto.ConvertConversationToResponse(conv, participants, int(unreadCount), lastMessage, isPinned, notificationMuted)
}
result[i] = resp
}
response.Paginated(c, result, int64(len(filteredConvs)), page, pageSize) response.Paginated(c, result, int64(len(filteredConvs)), page, pageSize)
} }
@@ -548,6 +613,57 @@ func (h *MessageHandler) GetUnreadCount(c *gin.Context) {
}) })
} }
// HandleGetSyncData 获取同步元数据(轻量级 seq + 时间)
// GET /api/v1/conversations/sync-data
func (h *MessageHandler) HandleGetSyncData(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "")
return
}
items, err := h.chatService.GetSyncData(c.Request.Context(), userID)
if err != nil {
response.InternalServerError(c, "failed to get sync data")
return
}
response.Success(c, &dto.SyncDataResponse{
Conversations: items,
})
}
// 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) {
@@ -616,16 +732,6 @@ func (h *MessageHandler) DeleteMessage(c *gin.Context) {
response.SuccessWithMessage(c, "message deleted", nil) response.SuccessWithMessage(c, "message deleted", nil)
} }
// 辅助函数:验证内容类型
func isValidContentType(contentType model.ContentType) bool {
switch contentType {
case model.ContentTypeText, model.ContentTypeImage, model.ContentTypeVideo, model.ContentTypeAudio, model.ContentTypeFile:
return true
default:
return false
}
}
// 辅助函数:获取会话参与者信息 // 辅助函数:获取会话参与者信息
func (h *MessageHandler) getConversationParticipants(ctx context.Context, conversationID string, currentUserID string) ([]*model.User, error) { func (h *MessageHandler) getConversationParticipants(ctx context.Context, conversationID string, currentUserID string) ([]*model.User, error) {
// 从repository获取参与者列表 // 从repository获取参与者列表
@@ -741,7 +847,7 @@ func (h *MessageHandler) HandleGetConversation(c *gin.Context) {
// 获取参与者信息 // 获取参与者信息
participants, _ := h.getConversationParticipants(c.Request.Context(), conversationID, userID) participants, _ := h.getConversationParticipants(c.Request.Context(), conversationID, userID)
// 获取当前用户的已读位置 // 获取当前用户的已读位置(优先从 Redis 缓存读取)
myLastReadSeq := int64(0) myLastReadSeq := int64(0)
isPinned := false isPinned := false
notificationMuted := false notificationMuted := false
@@ -755,8 +861,14 @@ func (h *MessageHandler) HandleGetConversation(c *gin.Context) {
} }
} }
// 获取对方用户的已读位置 // 获取对方用户的已读位置(优先从 Redis 缓存读取)
otherLastReadSeq := int64(0) otherLastReadSeq := int64(0)
for _, p := range allParticipants {
if p.UserID != userID {
otherLastReadSeq = p.LastReadSeq
break
}
}
response.Success(c, dto.ConvertConversationToDetailResponse(conv, participants, unreadCount, nil, myLastReadSeq, otherLastReadSeq, isPinned, notificationMuted)) response.Success(c, dto.ConvertConversationToDetailResponse(conv, participants, unreadCount, nil, myLastReadSeq, otherLastReadSeq, isPinned, notificationMuted))
} }
@@ -793,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,
@@ -821,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,
@@ -841,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)
} }
@@ -877,6 +989,33 @@ func (h *MessageHandler) HandleMarkRead(c *gin.Context) {
response.SuccessWithMessage(c, "marked as read", nil) response.SuccessWithMessage(c, "marked as read", nil)
} }
// HandleMarkReadAll 批量标记所有会话已读
// POST /api/v1/conversations/read-all
func (h *MessageHandler) HandleMarkReadAll(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "")
return
}
var req dto.BatchMarkReadRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
successCount, err := h.chatService.MarkAsReadBatch(c.Request.Context(), userID, req.Conversations)
if err != nil {
response.InternalServerError(c, "failed to mark all as read")
return
}
response.Success(c, &dto.BatchMarkReadResponse{
SuccessCount: successCount,
TotalCount: len(req.Conversations),
})
}
// HandleSetConversationPinned 设置会话置顶 // HandleSetConversationPinned 设置会话置顶
// PUT /api/v1/conversations/:id/pinned // PUT /api/v1/conversations/:id/pinned
func (h *MessageHandler) HandleSetConversationPinned(c *gin.Context) { func (h *MessageHandler) HandleSetConversationPinned(c *gin.Context) {
@@ -940,7 +1079,7 @@ func (h *MessageHandler) HandleSetConversationNotificationMuted(c *gin.Context)
} }
response.SuccessWithMessage(c, "conversation notification_muted status updated", gin.H{ response.SuccessWithMessage(c, "conversation notification_muted status updated", gin.H{
"conversation_id": conversationID, "conversation_id": conversationID,
"notification_muted": req.NotificationMuted, "notification_muted": req.NotificationMuted,
}) })
} }
@@ -969,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,
@@ -1018,37 +1157,8 @@ func (h *MessageHandler) GetConversationsByCursor(c *gin.Context) {
} }
} }
// 转换为响应格式 // 批量填充会话数据(解决 N+1 问题)
items := make([]*dto.ConversationResponse, len(filteredItems)) items := h.enrichConversations(c.Request.Context(), filteredItems, userID)
for i, conv := range filteredItems {
// 获取未读数
unreadCount, _ := h.chatService.GetUnreadCount(c.Request.Context(), conv.ID, userID)
// 获取最后一条消息
var lastMessage *model.Message
messages, _, _ := h.chatService.GetMessages(c.Request.Context(), conv.ID, userID, 1, 1)
if len(messages) > 0 {
lastMessage = messages[0]
}
// 群聊时返回member_count私聊时返回participants
var resp *dto.ConversationResponse
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
isPinned := myParticipant != nil && myParticipant.IsPinned
notificationMuted := myParticipant != nil && myParticipant.NotificationMuted
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" {
// 群聊:实时计算群成员数量
memberCount, _ := h.groupService.GetMemberCount(*conv.GroupID)
resp = dto.ConvertConversationToResponse(conv, nil, int(unreadCount), lastMessage, isPinned, notificationMuted)
resp.MemberCount = memberCount
} else {
// 私聊:获取参与者信息
participants, _ := h.getConversationParticipants(c.Request.Context(), conv.ID, userID)
resp = dto.ConvertConversationToResponse(conv, participants, int(unreadCount), lastMessage, isPinned, notificationMuted)
}
items[i] = resp
}
response.Success(c, &dto.ConversationCursorPageResponse{ response.Success(c, &dto.ConversationCursorPageResponse{
Items: items, Items: items,
NextCursor: result.NextCursor, NextCursor: result.NextCursor,

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"strconv" "strconv"
@@ -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,
} }
@@ -56,7 +56,7 @@ func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
err := h.notificationService.MarkAsReadWithUserID(c.Request.Context(), id, userID) err := h.notificationService.MarkAsReadWithUserID(c.Request.Context(), id, userID)
if err != nil { if err != nil {
response.InternalServerError(c, "failed to mark as read") response.HandleError(c, err, "failed to mark as read")
return return
} }
@@ -109,7 +109,7 @@ func (h *NotificationHandler) DeleteNotification(c *gin.Context) {
err := h.notificationService.DeleteNotification(c.Request.Context(), id, userID) err := h.notificationService.DeleteNotification(c.Request.Context(), id, userID)
if err != nil { if err != nil {
response.InternalServerError(c, "failed to delete notification") response.HandleError(c, err, "failed to delete notification")
return return
} }

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")
@@ -164,7 +165,7 @@ func (h *PostHandler) GetByID(c *gin.Context) {
if currentUserID != "" { if currentUserID != "" {
_, isFollowing, isFollowingMe, err := h.userService.GetUserByIDWithMutualFollowStatus(c.Request.Context(), post.UserID, currentUserID) _, isFollowing, isFollowingMe, err := h.userService.GetUserByIDWithMutualFollowStatus(c.Request.Context(), post.UserID, currentUserID)
if err == nil { if err == nil {
authorWithFollowStatus = dto.ConvertUserToResponseWithMutualFollow(post.User, isFollowing, isFollowingMe) authorWithFollowStatus = dto.ConvertUserToResponse(post.User, dto.WithFollowing(isFollowing, isFollowingMe))
} else { } else {
authorWithFollowStatus = dto.ConvertUserToResponse(post.User) authorWithFollowStatus = dto.ConvertUserToResponse(post.User)
} }
@@ -496,9 +497,9 @@ func (h *PostHandler) Delete(c *gin.Context) {
return return
} }
err = h.postService.Delete(c.Request.Context(), id) err = h.postService.Delete(c.Request.Context(), userID, id)
if err != nil { if err != nil {
response.InternalServerError(c, "failed to delete post") response.HandleError(c, err, "failed to delete post")
return return
} }

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"with_you/internal/dto" "with_you/internal/dto"
@@ -67,7 +67,7 @@ func (h *PushHandler) UnregisterDevice(c *gin.Context) {
return return
} }
err := h.pushService.UnregisterDevice(c.Request.Context(), deviceID) err := h.pushService.UnregisterDevice(c.Request.Context(), userID, deviceID)
if err != nil { if err != nil {
response.InternalServerError(c, "failed to unregister device") response.InternalServerError(c, "failed to unregister device")
return return
@@ -85,12 +85,13 @@ func (h *PushHandler) GetMyDevices(c *gin.Context) {
return return
} }
// 这里需要从DeviceTokenRepository获取设备列表 devices, err := h.pushService.GetUserDevices(c.Request.Context(), userID)
// 由于PushService接口没有提供获取设备列表的方法我们暂时返回空列表 if err != nil {
// TODO: 在PushService接口中添加GetUserDevices方法 response.InternalServerError(c, "failed to get devices")
_ = userID // 避免未使用变量警告 return
}
response.Success(c, []*dto.DeviceTokenResponse{}) response.Success(c, dto.DeviceTokensToResponse(devices))
} }
// GetPushRecords 获取推送记录 // GetPushRecords 获取推送记录
@@ -140,14 +141,14 @@ func (h *PushHandler) UpdateDeviceToken(c *gin.Context) {
} }
var req struct { var req struct {
PushToken string `json:"push_token" binding:"required"` PushToken string `json:"push_token"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
} }
err := h.pushService.UpdateDeviceToken(c.Request.Context(), deviceID, req.PushToken) err := h.pushService.UpdateDeviceToken(c.Request.Context(), userID, deviceID, req.PushToken)
if err != nil { if err != nil {
response.InternalServerError(c, "failed to update device token") response.InternalServerError(c, "failed to update device token")
return return

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"fmt" "fmt"
@@ -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,
} }
@@ -53,7 +53,7 @@ func (h *QRCodeHandler) WSEvents(c *gin.Context) {
return return
} }
ch, cancel, replay := h.qrcodeService.GetWSHub().Subscribe(sessionID, 0) ch, cancel := h.qrcodeService.GetWSHub().Subscribe(sessionID, 0)
defer cancel() defer cancel()
w := c.Writer w := c.Writer
@@ -82,13 +82,6 @@ func (h *QRCodeHandler) WSEvents(c *gin.Context) {
return true return true
} }
// 发送历史事件
for _, ev := range replay {
if !writeEvent(ev) {
return
}
}
// 心跳 // 心跳
heartbeat := time.NewTicker(25 * time.Second) heartbeat := time.NewTicker(25 * time.Second)
defer heartbeat.Stop() defer heartbeat.Stop()

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"with_you/internal/dto" "with_you/internal/dto"
@@ -58,4 +58,4 @@ func (h *ReportHandler) Create(c *gin.Context) {
} }
response.Success(c, dto.ConvertReportToResponse(report)) response.Success(c, dto.ConvertReportToResponse(report))
} }

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"errors" "errors"

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"strconv" "strconv"

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"errors" "errors"

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"strconv" "strconv"

View File

@@ -217,4 +217,4 @@ func (h *TradeHandler) Unfavorite(c *gin.Context) {
return return
} }
response.Success(c, dto.TradeFavoriteResponse{Favorited: false}) response.Success(c, dto.TradeFavoriteResponse{Favorited: false})
} }

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -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,11 +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/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"
) )
@@ -35,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
} }
@@ -52,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
} }
@@ -61,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()
@@ -91,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 {
@@ -153,16 +175,22 @@ func (h *UserHandler) Login(c *gin.Context) {
user, err := h.userService.Login(c.Request.Context(), account, req.Password) user, err := h.userService.Login(c.Request.Context(), account, req.Password)
if err != nil { if err != nil {
middleware.RecordLoginFailure(c.ClientIP())
response.HandleError(c, err, "failed to login") response.HandleError(c, err, "failed to login")
return return
} }
// 生成Token middleware.ResetLoginFailures(c.ClientIP())
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 {
@@ -175,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,
@@ -303,7 +329,7 @@ func (h *UserHandler) GetUserByID(c *gin.Context) {
} }
// 转换为响应格式,包含双向关注状态和实时计算的帖子数量 // 转换为响应格式,包含双向关注状态和实时计算的帖子数量
userResponse := dto.ConvertUserToResponseWithMutualFollowAndPostsCount(user, isFollowing, isFollowingMe, int(postsCount)) userResponse := dto.ConvertUserToResponse(user, dto.WithFollowing(isFollowing, isFollowingMe), dto.WithPostsCount(int(postsCount)))
response.Success(c, userResponse) response.Success(c, userResponse)
} }
@@ -459,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"`
@@ -470,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 {
@@ -660,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
@@ -706,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
@@ -795,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
} }
@@ -805,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

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"strconv" "strconv"
@@ -6,6 +6,7 @@ import (
"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/pkg/sanitizer"
"with_you/internal/service" "with_you/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -114,7 +115,7 @@ func convertVerificationRecordToResponse(record *model.VerificationRecord) *dto.
UserID: record.UserID, UserID: record.UserID,
Identity: string(record.Identity), Identity: string(record.Identity),
Status: string(record.Status), Status: string(record.Status),
RealName: record.RealName, RealName: sanitizer.MaskValue("real_name", record.RealName),
StudentID: record.StudentID, StudentID: record.StudentID,
EmployeeID: record.EmployeeID, EmployeeID: record.EmployeeID,
Department: record.Department, Department: record.Department,

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"errors" "errors"
@@ -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"
) )
@@ -67,31 +65,43 @@ func isAllowedWebSocketOrigin(origin string) bool {
// WSHandler WebSocket处理器 // WSHandler WebSocket处理器
type WSHandler struct { type WSHandler struct {
wsHub *ws.Hub 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
} }
// hub 获取底层 Hub用于 Register/Unregister/AckMessage 等本地操作)
func (h *WSHandler) hub() *ws.Hub {
switch p := h.publisher.(type) {
case *ws.Bus:
return p.Hub()
case *ws.Hub:
return p
default:
return nil
}
}
// NewWSHandler 创建WebSocket处理器 // NewWSHandler 创建WebSocket处理器
func NewWSHandler( func NewWSHandler(
wsHub *ws.Hub, 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{
wsHub: wsHub, publisher: publisher,
chatService: chatService, chatService: chatService,
groupService: groupService, groupService: groupService,
jwtService: jwtService, jwtService: jwtService,
callService: callService, callService: callService,
userRepo: userRepo, userService: userService,
} }
} }
@@ -115,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
@@ -139,39 +150,47 @@ 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),
CompressEnabled: compressEnabled,
} }
// 4. 注册客户端 // 4. 注册客户端
replayEvents := h.wsHub.Register(client) regErr := h.hub().Register(client)
defer h.wsHub.Unregister(client) if regErr != nil {
zap.L().Warn("WebSocket registration rejected",
zap.String("user_id", userID),
zap.Error(regErr),
)
conn.WriteMessage(websocket.CloseMessage,
[]byte(`{"type":"error","payload":{"code":"connection_limit","message":"too many connections"}}`))
conn.Close()
return
}
defer h.hub().Unregister(client)
zap.L().Info("WebSocket client connected", zap.L().Info("WebSocket client connected",
zap.String("user_id", userID), zap.String("user_id", userID),
zap.Uint64("client_id", clientID), zap.Uint64("client_id", clientID),
) )
// 5. 发送历史回放消息 // 5. 提示客户端进行 seq 同步
go func() { syncMsg := ws.ResponseMessage{
for _, ev := range replayEvents { EventID: h.publisher.NextID(),
msg := ws.ResponseMessage{ Type: "sync_required",
EventID: ev.ID, TS: time.Now().UnixMilli(),
Type: ev.Type, }
TS: ev.TS, if syncData, err := json.Marshal(syncMsg); err == nil {
Payload: ev.Payload, select {
} case <-client.Quit:
data, _ := json.Marshal(msg) case client.Send <- syncData:
select {
case <-client.Quit:
return
case client.Send <- data:
}
} }
}() }
// 6. 启动读写goroutine // 6. 启动读写goroutine
go h.writePump(conn, client) go h.writePump(conn, client)
@@ -193,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",
@@ -225,10 +244,22 @@ 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.wsHub.SendError(client, "parse_error", "invalid message format") h.publisher.SendError(client, "parse_error", "invalid message format")
continue continue
} }
@@ -262,21 +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)
return buf = append(buf, message...)
} n := len(client.Send)
w.Write(message) for i := 0; i < n; i++ {
buf = append(buf, '\n')
buf = append(buf, <-client.Send...)
}
compressed, cErr := ws.DefaultCompressor.Compress(buf)
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 {
return zap.L().Warn("WebSocket write error (Close)",
zap.String("user_id", client.UserID),
zap.Uint64("client_id", client.ID),
zap.Error(err),
)
return
}
} }
case <-ticker.C: case <-ticker.C:
conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
@@ -302,32 +372,36 @@ func (h *WSHandler) handleMessage(client *ws.Client, msg *ws.Message) {
h.handleRead(ctx, client, msg.Payload) h.handleRead(ctx, client, msg.Payload)
case "recall": case "recall":
h.handleRecall(ctx, client, msg.Payload) h.handleRecall(ctx, client, msg.Payload)
case "ack":
h.handleAck(client, msg.Payload)
// 通话信令 // 通话信令
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.wsHub.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))
} }
} }
// handlePing 处理ping消息 // handlePing 处理ping消息
func (h *WSHandler) handlePing(client *ws.Client) { func (h *WSHandler) handlePing(client *ws.Client) {
pong := ws.ResponseMessage{ pong := ws.ResponseMessage{
EventID: h.wsHub.NextID(), EventID: h.publisher.NextID(),
Type: "pong", Type: "pong",
TS: time.Now().UnixMilli(), TS: time.Now().UnixMilli(),
} }
@@ -349,27 +423,28 @@ func (h *WSHandler) handleChat(ctx context.Context, client *ws.Client, payload j
DetailType string `json:"detail_type"` DetailType string `json:"detail_type"`
Segments model.MessageSegments `json:"segments"` Segments model.MessageSegments `json:"segments"`
ReplyToID *string `json:"reply_to_id,omitempty"` ReplyToID *string `json:"reply_to_id,omitempty"`
ClientMsgID string `json:"client_msg_id,omitempty"`
} }
if err := json.Unmarshal(payload, &req); err != nil { if err := json.Unmarshal(payload, &req); err != nil {
h.wsHub.SendError(client, "parse_error", "invalid chat message format") h.publisher.SendError(client, "parse_error", "invalid chat message format")
return return
} }
if req.ConversationID == "" { if req.ConversationID == "" {
h.wsHub.SendError(client, "invalid_params", "conversation_id is required") h.publisher.SendError(client, "invalid_params", "conversation_id is required")
return return
} }
if len(req.Segments) == 0 { if len(req.Segments) == 0 {
h.wsHub.SendError(client, "invalid_params", "segments is required") h.publisher.SendError(client, "invalid_params", "segments is required")
return return
} }
// 发送消息 // 发送消息
message, err := h.chatService.SendMessage(ctx, client.UserID, req.ConversationID, req.Segments, req.ReplyToID) message, err := h.chatService.SendMessage(ctx, client.UserID, req.ConversationID, req.Segments, req.ReplyToID, req.ClientMsgID)
if err != nil { if err != nil {
h.wsHub.SendError(client, "send_failed", err.Error()) h.publisher.SendError(client, "send_failed", err.Error())
return return
} }
@@ -385,13 +460,13 @@ 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,
} }
msg := ws.ResponseMessage{ msg := ws.ResponseMessage{
EventID: h.wsHub.NextID(), EventID: h.publisher.NextID(),
Type: "message_sent", Type: "message_sent",
TS: time.Now().UnixMilli(), TS: time.Now().UnixMilli(),
Payload: resp, Payload: resp,
@@ -459,24 +534,24 @@ func (h *WSHandler) handleRecall(ctx context.Context, client *ws.Client, payload
} }
if err := json.Unmarshal(payload, &req); err != nil { if err := json.Unmarshal(payload, &req); err != nil {
h.wsHub.SendError(client, "parse_error", "invalid recall message format") h.publisher.SendError(client, "parse_error", "invalid recall message format")
return return
} }
if req.MessageID == "" { if req.MessageID == "" {
h.wsHub.SendError(client, "invalid_params", "message_id is required") h.publisher.SendError(client, "invalid_params", "message_id is required")
return return
} }
err := h.chatService.RecallMessage(ctx, req.MessageID, client.UserID) err := h.chatService.RecallMessage(ctx, req.MessageID, client.UserID)
if err != nil { if err != nil {
h.wsHub.SendError(client, "recall_failed", err.Error()) h.publisher.SendError(client, "recall_failed", err.Error())
return return
} }
// 发送撤回成功响应 // 发送撤回成功响应
resp := ws.ResponseMessage{ resp := ws.ResponseMessage{
EventID: h.wsHub.NextID(), EventID: h.publisher.NextID(),
Type: "message_recalled", Type: "message_recalled",
TS: time.Now().UnixMilli(), TS: time.Now().UnixMilli(),
Payload: map[string]string{"message_id": req.MessageID}, Payload: map[string]string{"message_id": req.MessageID},
@@ -490,15 +565,26 @@ func (h *WSHandler) handleRecall(ctx context.Context, client *ws.Client, payload
const defaultUserBufferSize = 128 const defaultUserBufferSize = 128
// handleAck 处理客户端ACK确认
func (h *WSHandler) handleAck(client *ws.Client, payload json.RawMessage) {
var req struct {
MessageID string `json:"message_id"`
}
if err := json.Unmarshal(payload, &req); err != nil || req.MessageID == "" {
return
}
h.hub().AckMessage(client.UserID, req.MessageID)
}
// 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.wsHub.SendError(client, "internal_error", "获取用户信息失败") h.publisher.SendError(client, "internal_error", "获取用户信息失败")
return false return false
} }
if user.VerificationStatus != model.VerificationStatusApproved { if user.VerificationStatus != model.VerificationStatusApproved {
h.wsHub.SendError(client, "VERIFICATION_REQUIRED", "请先完成身份认证") h.publisher.SendError(client, "VERIFICATION_REQUIRED", "请先完成身份认证")
return false return false
} }
return true return true
@@ -518,11 +604,11 @@ func (h *WSHandler) handleCallInvite(ctx context.Context, client *ws.Client, pay
MediaType string `json:"call_type"` // voice 或 video MediaType string `json:"call_type"` // voice 或 video
} }
if err := json.Unmarshal(payload, &req); err != nil { if err := json.Unmarshal(payload, &req); err != nil {
h.wsHub.SendError(client, "parse_error", "invalid call_invite format") h.publisher.SendError(client, "parse_error", "invalid call_invite format")
return return
} }
if req.CalleeID == "" || req.ConversationID == "" { if req.CalleeID == "" || req.ConversationID == "" {
h.wsHub.SendError(client, "invalid_params", "callee_id and conversation_id are required") h.publisher.SendError(client, "invalid_params", "callee_id and conversation_id are required")
return return
} }
@@ -540,16 +626,16 @@ func (h *WSHandler) handleCallInvite(ctx context.Context, client *ws.Client, pay
zap.Error(err), zap.Error(err),
) )
if errors.Is(err, apperrors.ErrCallInProgress) { if errors.Is(err, apperrors.ErrCallInProgress) {
h.wsHub.SendError(client, "call_in_progress", err.Error()) h.publisher.SendError(client, "call_in_progress", err.Error())
return return
} }
h.wsHub.SendError(client, "call_invite_failed", err.Error()) h.publisher.SendError(client, "call_invite_failed", err.Error())
return return
} }
// 返回给呼叫方的响应 // 返回给呼叫方的响应
resp := ws.ResponseMessage{ resp := ws.ResponseMessage{
EventID: h.wsHub.NextID(), EventID: h.publisher.NextID(),
Type: "call_invited", Type: "call_invited",
TS: time.Now().UnixMilli(), TS: time.Now().UnixMilli(),
Payload: map[string]any{ Payload: map[string]any{
@@ -566,13 +652,68 @@ 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 {
CallID string `json:"call_id"` CallID string `json:"call_id"`
} }
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" { if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
h.wsHub.SendError(client, "invalid_params", "call_id is required") h.publisher.SendError(client, "invalid_params", "call_id is required")
return return
} }
@@ -580,15 +721,15 @@ func (h *WSHandler) handleCallAnswer(ctx context.Context, client *ws.Client, pay
if err != nil { if err != nil {
// === 区分已接听错误 === // === 区分已接听错误 ===
if errors.Is(err, apperrors.ErrCallAlreadyAnswered) { if errors.Is(err, apperrors.ErrCallAlreadyAnswered) {
h.wsHub.SendError(client, "call_already_answered", "通话已被其他设备接听") h.publisher.SendError(client, "call_already_answered", "通话已被其他设备接听")
return return
} }
h.wsHub.SendError(client, "call_accept_failed", err.Error()) h.publisher.SendError(client, "call_accept_failed", err.Error())
return return
} }
resp := ws.ResponseMessage{ resp := ws.ResponseMessage{
EventID: h.wsHub.NextID(), EventID: h.publisher.NextID(),
Type: "call_accepted", Type: "call_accepted",
TS: time.Now().UnixMilli(), TS: time.Now().UnixMilli(),
Payload: map[string]any{ Payload: map[string]any{
@@ -609,12 +750,12 @@ func (h *WSHandler) handleCallReject(ctx context.Context, client *ws.Client, pay
CallID string `json:"call_id"` CallID string `json:"call_id"`
} }
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" { if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
h.wsHub.SendError(client, "invalid_params", "call_id is required") h.publisher.SendError(client, "invalid_params", "call_id is required")
return return
} }
if err := h.callService.Reject(ctx, req.CallID, client.UserID); err != nil { if err := h.callService.Reject(ctx, req.CallID, client.UserID); err != nil {
h.wsHub.SendError(client, "call_reject_failed", err.Error()) h.publisher.SendError(client, "call_reject_failed", err.Error())
} }
} }
@@ -624,52 +765,27 @@ func (h *WSHandler) handleCallBusy(ctx context.Context, client *ws.Client, paylo
CallID string `json:"call_id"` CallID string `json:"call_id"`
} }
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" { if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
h.wsHub.SendError(client, "invalid_params", "call_id is required") h.publisher.SendError(client, "invalid_params", "call_id is required")
return return
} }
if err := h.callService.Busy(ctx, req.CallID, client.UserID); err != nil { if err := h.callService.Busy(ctx, req.CallID, client.UserID); err != nil {
h.wsHub.SendError(client, "call_busy_failed", err.Error()) h.publisher.SendError(client, "call_busy_failed", err.Error())
} }
} }
// 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.wsHub.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.wsHub.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.wsHub.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.wsHub.SendError(client, "call_ice_failed", err.Error())
} }
} }
@@ -680,18 +796,18 @@ func (h *WSHandler) handleCallEnd(ctx context.Context, client *ws.Client, payloa
Reason string `json:"reason"` Reason string `json:"reason"`
} }
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" { if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
h.wsHub.SendError(client, "invalid_params", "call_id is required") h.publisher.SendError(client, "invalid_params", "call_id is required")
return return
} }
call, err := h.callService.End(ctx, req.CallID, client.UserID, req.Reason) call, err := h.callService.End(ctx, req.CallID, client.UserID, req.Reason)
if err != nil { if err != nil {
h.wsHub.SendError(client, "call_end_failed", err.Error()) h.publisher.SendError(client, "call_end_failed", err.Error())
return return
} }
resp := ws.ResponseMessage{ resp := ws.ResponseMessage{
EventID: h.wsHub.NextID(), EventID: h.publisher.NextID(),
Type: "call_ended", Type: "call_ended",
TS: time.Now().UnixMilli(), TS: time.Now().UnixMilli(),
Payload: map[string]any{ Payload: map[string]any{
@@ -713,11 +829,26 @@ func (h *WSHandler) handleCallMute(ctx context.Context, client *ws.Client, paylo
Muted bool `json:"muted"` Muted bool `json:"muted"`
} }
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" { if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
h.wsHub.SendError(client, "invalid_params", "call_id is required") h.publisher.SendError(client, "invalid_params", "call_id is required")
return return
} }
if err := h.callService.SetMuted(ctx, req.CallID, client.UserID, req.Muted); err != nil { if err := h.callService.SetMuted(ctx, req.CallID, client.UserID, req.Muted); err != nil {
h.wsHub.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()
}

Some files were not shown because too many files have changed in this diff Show More