Compare commits

...

137 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
19fd159cdf Merge pull request 'refactor(trade): remove OriginalPrice and Location fields from trade items' (#3) from feature/flea-market into master
All checks were successful
Build Backend / build (push) Successful in 2m16s
Build Backend / build-docker (push) Successful in 1m22s
Reviewed-on: #3
2026-04-26 17:19:12 +08:00
lafay
3e32b28578 refactor(trade): remove OriginalPrice and Location fields from trade items
Some checks failed
Build Backend / build-docker (pull_request) Has been cancelled
Build Backend / build (pull_request) Has been cancelled
Deploy / deploy (pull_request) Has been cancelled
2026-04-26 17:14:05 +08:00
84683d5efa Merge pull request 'feat(trade): implement second-hand trading/flea market feature' (#2) from feature/flea-market into master
All checks were successful
Build Backend / build (push) Successful in 3m4s
Build Backend / build-docker (push) Successful in 1m19s
Reviewed-on: #2
2026-04-26 15:21:30 +08:00
lafay
0ca02f7ef3 feat(trade): implement second-hand trading/flea market feature
Some checks failed
Build Backend / build-docker (pull_request) Has been cancelled
Build Backend / build (pull_request) Has been cancelled
Deploy / deploy (pull_request) Has been cancelled
Add complete trade functionality including:
- TradeItem, TradeImage, TradeFavorite models with auto-migration
- TradeRepository with CRUD and favorite operations
- TradeService with business logic for listing, creating, updating, status management
- TradeHandler with RESTful endpoints for trade operations
- DTOs and converters for request/response handling

Routes include: list, get by id, create, update, delete, status update, view recording, favorite/unfavorite.
2026-04-26 15:20:26 +08:00
lafay
1b50d6c792 refactor(hot-rank): extract scored type to package level as hotRankScored
All checks were successful
Build Backend / build (push) Successful in 2m6s
Build Backend / build-docker (push) Successful in 1m21s
Move the scoped 'scored' struct definition to package level and rename
to hotRankScored to enable reuse across Refresh and refreshChannelRanks
methods, eliminating duplicate type definitions.
2026-04-26 11:45:39 +08:00
lafay
27ea8689f9 feat(hot-rank): add per-channel hot ranking functionality
Some checks failed
Build Backend / build (push) Failing after 1m52s
Build Backend / build-docker (push) Has been skipped
Add channel-specific hot post filtering and ranking across the posts
listing flow. Introduce refreshChannelRanks to compute per-channel hot
scores, new Redis cache keys for channel hot ranks, and update
repository/service/handler layers to accept channelID filtering
parameters. Update HotRankWorker dependency injection to include
ChannelRepository.
2026-04-26 11:39:41 +08:00
lafay
02466603f9 refactor(router): remove post suggestion endpoint
All checks were successful
Build Backend / build (push) Successful in 3m12s
Build Backend / build-docker (push) Successful in 1m23s
Remove the /posts/suggest endpoint as part of route cleanup
following the post reference functionality implementation.
2026-04-26 00:48:26 +08:00
lafay
23d7f1151e feat(posts): add post reference/internal linking functionality
All checks were successful
Build Backend / build (push) Successful in 2m19s
Build Backend / build-docker (push) Successful in 1m18s
Add support for referencing other posts within messages through a new post_ref segment type. Includes new PostReference model to track relationships, PostRefService for processing references, and new endpoints for post suggestions, related posts, and reference click tracking.
2026-04-26 00:37:20 +08:00
lafay
898c0e6d9c ci(deps): add Go module proxy configuration
Some checks failed
Build Backend / build (push) Failing after 19s
Build Backend / build-docker (push) Has been skipped
Configure GOPROXY to use https://goproxy.cn as primary proxy with direct fallback for Go module fetching in CI builds.
2026-04-25 22:49:56 +08:00
lafay
e5c8fd7860 build(deps): update Go version to 1.26 in CI workflow
All checks were successful
Build Backend / build (push) Successful in 11m20s
Build Backend / build-docker (push) Successful in 1m15s
2026-04-25 21:36:32 +08:00
lafay
d8b0825ac0 feat(conversations): add notification mute functionality for conversations
Some checks failed
Build Backend / build-docker (push) Has been cancelled
Build Backend / build (push) Has been cancelled
- Rename Muted field to NotificationMuted across model, DTOs and converters
- Add UpdateNotificationMuted repository method with upsert support
- Add SetConversationNotificationMuted service method
- Add PUT /:id/notification_muted route for setting mute status
- Improve S3 storage resilience by skipping bucket check on access denied
2026-04-25 21:22:52 +08:00
lafay
52f62ef230 refactor(push): normalize notification payload and avoid reconnection replay
All checks were successful
Build Backend / build (push) Successful in 3m30s
Build Backend / build-docker (push) Successful in 1m19s
- Change type field to always be "notification" and add system_type field
- Rename "extra" and "data" fields to "extra_data" for consistency
- Add "is_read" field to notification payload
- Use PublishToUserOnline instead of PublishToUser to prevent system
  messages from being stored in reconnection history replay
- System notifications are already persisted to database, clients can
  fetch via REST API on reconnect
2026-04-25 15:59:36 +08:00
lafay
3215039ff6 fix(conversations): always show group chats regardless of message count
All checks were successful
Build Backend / build (push) Successful in 5m0s
Build Backend / build-docker (push) Successful in 2m14s
Update conversation list queries to distinguish between private and group chats:
private conversations are only shown when they have messages (last_seq > 0), while group conversations are always displayed even without messages. This applies to both the paginated GetConversations method and the cursor-based GetConversationsByCursor method.
2026-04-25 15:03:37 +08:00
lafay
48f0c4cf2d fix(conversations): filter out empty conversations from list and count
All checks were successful
Build Backend / build (push) Successful in 3m20s
Build Backend / build-docker (push) Successful in 1m35s
Only include conversations with messages (last_seq > 0) in both the total count and paginated list queries, improving relevance of conversation lists.
2026-04-25 13:00:49 +08:00
lafay
e0f34653a2 feat(posts): filter posts by blocked users
All checks were successful
Build Backend / build (push) Successful in 2m49s
Build Backend / build-docker (push) Successful in 1m19s
Add functionality to filter out posts from blocked users in post listing endpoints. Includes a new `IsBlockedBatch` method for efficient batch checking of blocked relationships and `is_blocked` field in API responses when viewing a blocked user's posts.
2026-04-25 11:25:43 +08:00
lafay
323f872aa9 refactor(push): change created_at format from Unix milliseconds to ISO8601
All checks were successful
Build Backend / build (push) Successful in 4m32s
Build Backend / build-docker (push) Successful in 2m18s
2026-04-25 00:40:00 +08:00
lafay
3e90eefd51 chore(utils): replace UI Avatars API with static default avatar
All checks were successful
Build Backend / build (push) Successful in 2m55s
Build Backend / build-docker (push) Successful in 1m18s
Remove external UI Avatars API dependency and use a static default avatar
URL instead. Simplifies avatar utility functions by eliminating dynamic
avatar generation.
2026-04-24 16:43:12 +08:00
lafay
496c8bb1aa refactor(model): support string type in JSON scan methods
All checks were successful
Build Backend / build (push) Successful in 5m57s
Build Backend / build-docker (push) Successful in 1m16s
Enhance Scan methods in StringArray, ExtraData, MessageSegments, and
SystemNotificationExtra to accept both []byte and string types from database.
Uses type switch for more flexible type handling.
2026-04-24 15:58:28 +08:00
lafay
813e54e5b2 feat(admin): add super admin initialization endpoint
All checks were successful
Build Backend / build (push) Successful in 2m42s
Build Backend / build-docker (push) Successful in 2m52s
Add POST /api/v1/admin/setup-super-admin endpoint to initialize the first super admin.
The endpoint can only be used once - after a super admin exists, subsequent requests
are rejected. Requires valid setup_secret configured via APP_SETUP_SECRET environment
variable or setup_secret in config file.
2026-04-24 15:14:44 +08:00
lafay
05c7f8a5eb feat(api): add message segments support for posts and comments
All checks were successful
Build Backend / build (push) Successful in 19m52s
Build Backend / build-docker (push) Successful in 1m29s
Add rich message segment support enabling structured content (text, images, votes, mentions) in posts and comments. Includes segments field in models, DTOs, handlers, and services. Also adds @mention notification processing and migrates vote data to embedded segments.
2026-04-23 22:29:34 +08:00
lafay
15e7c99353 chore(config): update allowed origins from bbs to withyou domain
Some checks failed
Build Backend / build (push) Failing after 46s
Build Backend / build-docker (push) Has been skipped
Update WebSocket and CORS allowed origins to reflect the project rename from "Carrot BBS" to "WithYou", changing bbs.littlelan.cn to withyou.littlelan.cn.
2026-04-22 16:40:11 +08:00
lafay
869d603fb0 fix: add sudo command
Some checks failed
Build Backend / build-docker (push) Has been cancelled
Build Backend / build (push) Has been cancelled
2026-04-22 16:28:17 +08:00
lafay
64f5cd9ad6 fix: the username
Some checks failed
Build Backend / build-docker (push) Has been cancelled
Build Backend / build (push) Has been cancelled
2026-04-22 16:26:24 +08:00
lafay
2f584c1f2c This is a breaking change that renames the entire project from "Carrot BBS" to "WithYou".
All checks were successful
Build Backend / build (push) Successful in 5m10s
Build Backend / build-docker (push) Successful in 2m22s
The Go module name has been changed from `carrot_bbs` to `with_you`, which requires
all import paths to be updated throughout the codebase and by external consumers.

BREAKING CHANGE: Module name changed from carrot_bbs to with_you. All imports
and dependencies must be updated from carrot_bbs/* to with_you/*.
2026-04-22 16:01:59 +08:00
lafay
38d628c696 feat(moderation): add Tencent TMS as fallback for AI content moderation
Some checks failed
Build Backend / build (push) Failing after 45s
Build Backend / build-docker (push) Has been skipped
Add Tencent Cloud Text Moderation System (TMS) as a fallback moderation
service when OpenAI is unavailable or returns an error. The service
now checks OpenAI first, and falls back to Tencent TMS if it fails,
providing better reliability for content moderation.

Features:
- New Tencent TMS configuration in config.yaml with environment variable overrides
- New tencent package in internal/pkg/tencent/ with TMS client implementation
- PostAIService now uses dual moderation with OpenAI primary and Tencent fallback
- Strict mode configuration passed to PostAIService for consistent behavior

BREAKING CHANGE: NewPostAIService now requires tencentClient and strictMode
parameters - update wire configuration accordingly.
2026-04-21 22:31:30 +08:00
lafay
90495385cd feat(moderation): add user profile content moderation for avatars, covers, and bios
All checks were successful
Build Backend / build (push) Successful in 4m24s
Build Backend / build-docker (push) Successful in 1m14s
2026-04-15 11:50:47 +08:00
lafay
b6f2df87c4 refactor(handler): optimize mutual follow checks and remove unused skills
All checks were successful
Build Backend / build (push) Successful in 2m45s
Build Backend / build-docker (push) Successful in 1m13s
- remove unused .kilocode Go patterns and testing skill files
- simplify mutual follow status retrieval in post and user handlers
- restrict mutual follow status fetching to self-viewing scenarios to reduce unnecessary service calls
2026-04-14 02:13:06 +08:00
lafay
1bb82e1e2b feat(moderation): add manual content review system with three-tier AI moderation
All checks were successful
Build Backend / build (push) Successful in 2m43s
Build Backend / build-docker (push) Successful in 1m22s
Add admin comment moderation endpoints (single and batch) and introduce a three-tier moderation system (pass/review/block) for content flagged by AI. Content with unclear violations is now held for manual review instead of being auto-rejected. Deleted the legacy audit_service.go in favor of the unified hook-based moderation system.
2026-04-11 04:23:24 +08:00
lafay
6af6aaa9d0 feat(posts): add preview URL fields for post images
All checks were successful
Build Backend / build (push) Successful in 4m45s
Build Backend / build-docker (push) Successful in 1m31s
Add PreviewURL and PreviewURLLarge fields to PostImage model and responses. Image data now supports format "url|preview_url|preview_url_large" for storing multiple image variants. Also change comment cursor pagination to sort by created_at ASC (oldest first) to display earliest comments as first floor, and make AdminReviewVerificationRequest.Approve field optional.
2026-04-10 01:13:58 +08:00
lafay
539bec6f63 feat(privacy): add user privacy settings and account deletion system
All checks were successful
Build Backend / build (push) Successful in 5m16s
Build Backend / build-docker (push) Successful in 3m44s
Add privacy settings allowing users to control visibility of their followers, following, posts, and favorites lists with three visibility levels: everyone, following, and self. Also implement account deletion workflow with 30-day cooldown period.

- Add PrivacySettings model with visibility levels
- Add DTOs for privacy settings and account deletion
- Add repository methods for privacy settings and deletion
- Add service methods to check visibility permissions
- Add handlers for get/update privacy settings and account deletion
- Add new routes for privacy settings and account management
- Add account cleanup service for hard-deleting expired accounts
- Modify login to cancel deletion when user logs in

BREAKING CHANGE: Users with pending_deletion status can no longer log in; logging in now automatically cancels deletion requests.
2026-04-08 14:55:50 +08:00
lafay
9440df66ba refactor(ws): move verification checks from connection to per-operation level
All checks were successful
Build Backend / build (push) Successful in 26m25s
Build Backend / build-docker (push) Successful in 3m56s
Restructure verification enforcement to check at individual handler level (chat, recall, call invite) instead of during initial WebSocket connection. Add reusable isVerified helper method to reduce code duplication.
2026-04-08 02:38:26 +08:00
lafay
ef9a30ee54 feat(verification): enforce user verification requirement for posts, comments, and WebSocket connections
Some checks failed
Build Backend / build-docker (push) Has been cancelled
Build Backend / build (push) Has been cancelled
Add verification status checks to protected routes including post creation, updates, deletion, likes, favorites, voting, and WebSocket connections. Also rename RequireVerification middleware to RequireVerified and update error response format with string error codes.
2026-04-08 02:28:10 +08:00
lafay
98b8abd26a feat(config): add sensitive word filtering system with database support
All checks were successful
Build Backend / build (push) Successful in 12m54s
Build Backend / build-docker (push) Successful in 1m41s
Implement sensitive word filtering feature with configurable database and Redis
support. Add security enhancements including WebSocket origin validation, improved
password strength requirements, timing attack prevention, and production JWT secret
validation. Add batch operation validation limits and optimize user blocking
queries with subqueries.

BREAKING CHANGE: Password validation now requires minimum 8 characters with uppercase,
lowercase, and digit (was 6-50 characters without complexity requirements)
2026-04-07 00:07:40 +08:00
lafay
6429322217 feat(verification): add user identity verification system
Some checks failed
Build Backend / build (push) Successful in 7m12s
Build Backend / build-docker (push) Has been cancelled
Implement a complete user identity verification system with support for multiple identity types (student, teacher, staff, alumni). The system includes user-facing verification submission and status checking endpoints, admin endpoints for reviewing verification requests, middleware for protecting verification-required routes, and integration with the user model to track verification status.
2026-04-05 20:27:03 +08:00
lafay
b640c9a249 refactor: upgrade to Go 1.26 and modernize code idioms
All checks were successful
Build Backend / build (push) Successful in 3m51s
Build Backend / build-docker (push) Successful in 1m0s
- Replace `interface{}` with `any` type alias across all packages
- Use built-in `min()`/`max()` for parameter clamping
- Use `slices.SortFunc`, `slices.Min`, `slices.Max` for cleaner code
- Use `strings.Cut()` for simpler string parsing in auth middleware
- Use `errors.Is()` for proper error comparison in handlers
- Update dependencies: golang.org/x/image 0.37.0 -> 0.38.0
- Add Wire code generation guidelines to ARCHITECTURE.md
- Disable Go cache in CI build workflow
2026-03-30 04:49:35 +08:00
lafay
a69b2026f4 feat(report): integrate report handling into application
All checks were successful
Build Backend / build (push) Successful in 13m27s
Build Backend / build-docker (push) Successful in 1m29s
- Added Report and AdminReport handlers to manage report-related functionalities.
- Updated router and wire generation to include new report services and handlers.
- Enhanced error handling in report and admin report handlers to return appropriate status codes.
- Refactored notification logic in admin report service to utilize a dedicated notification repository and push service.
2026-03-30 03:44:24 +08:00
lafay
7fa49155dd refactor(report): update import paths and enhance logging functionality
Some checks failed
Build Backend / build (push) Failing after 7m21s
Build Backend / build-docker (push) Has been skipped
- Removed unused time imports from report DTO and service files.
- Updated import paths for response handling in admin and general report handlers.
- Refactored logging methods in admin and report services to use a unified operation logging approach.
- Introduced helper functions for extracting text from message segments and converting user models to brief report information.
2026-03-30 02:11:12 +08:00
lafay
5f7b02ee8e feat: 添加举报功能支持
Some checks failed
Build Backend / build (push) Failing after 5m19s
Build Backend / build-docker (push) Has been skipped
- 新增 Report 数据模型、DTO、Repository、Service 层
- 实现用户端举报 API (POST /api/v1/reports)
- 实现管理端举报管理 API (列表、详情、处理、批量处理)
- 添加举报自动隐藏机制(达到阈值自动隐藏内容)
- 集成系统通知(举报处理结果通知)
- 更新路由和 Wire 依赖注入配置

Made-with: Cursor
2026-03-29 20:18:36 +08:00
lafay
2205b8ad04 feat(call): implement active call management and memory storage
All checks were successful
Build Backend / build (push) Successful in 17m44s
Build Backend / build-docker (push) Successful in 1m3s
- Introduced ActiveCall and ActiveParticipant types to manage call state in memory.
- Updated CallService interface to return ActiveCall instances for Invite, Accept, and End methods.
- Added methods for storing, retrieving, and removing active calls from memory, enhancing call handling efficiency.
- Implemented concurrency control with sync.RWMutex to ensure thread-safe access to active call data.
2026-03-28 07:20:02 +08:00
lafay
736344f123 refactor(di): migrate from setter to constructor injection for logService
All checks were successful
Build Backend / build (push) Successful in 12m35s
Build Backend / build-docker (push) Successful in 1m2s
- Remove SetLogService methods from user, post, comment, and admin post services
- Add logService as constructor parameter to all dependent services
- Centralize call-related and message error definitions in app_errors.go
- Add ConversationID field to WSEventResponse for improved message tracking
- Simplify wire provider functions by removing manual setter calls
2026-03-28 07:03:21 +08:00
lafay
d357998321 feat(call): enhance call handling with user connection events and active call management
All checks were successful
Build Backend / build (push) Successful in 13m52s
Build Backend / build-docker (push) Successful in 1m8s
- Updated CallService to handle user connection and disconnection events, allowing for automatic management of active calls based on user status.
- Introduced methods to retrieve active calls for users and send pending call invites when users reconnect.
- Modified the Invite method to return the callee's online status, improving user experience during call invitations.
- Enhanced WSHandler to support connection and disconnection event handlers, ensuring accurate call state management.
2026-03-28 04:34:49 +08:00
lafay
63cfbad65c feat(call): add media type support to call invite functionality
All checks were successful
Build Backend / build (push) Successful in 12m57s
Build Backend / build-docker (push) Successful in 1m15s
- Updated the WSHandler to include a media type field in the call invite request, allowing differentiation between voice and video calls.
- Modified the CallService interface and implementation to accept the media type parameter during call invitations, enhancing call handling capabilities.
2026-03-28 01:01:22 +08:00
lafay
bac3cbbf60 feat(call): enhance call service with reliable message delivery and automatic cleanup
All checks were successful
Build Backend / build (push) Successful in 18m14s
Build Backend / build-docker (push) Successful in 2m29s
- Introduced a new method, PublishToUserOnlineReliable, to ensure critical signaling messages are reliably sent to online users.
- Updated CallService to include a cleanup mechanism for expired calls, improving resource management and user experience.
- Enhanced call acceptance logic to utilize optimistic locking for state updates, ensuring accurate call status handling across devices.
- Refactored wire generation to accommodate the new database dependency in CallService, streamlining service initialization.
2026-03-28 00:20:56 +08:00
lafay
20b1e04764 feat(call): enhance call handling with error differentiation and online notifications
All checks were successful
Build Backend / build (push) Successful in 16m14s
Build Backend / build-docker (push) Successful in 1m5s
- Added error handling for call invite and answer processes to differentiate between callee offline and call already answered scenarios.
- Introduced a new method to publish messages only to online users, improving real-time communication during call signaling.
- Updated call service to check callee's online status before sending call invites, ensuring better user experience.
- Enhanced call acceptance logic to notify both caller and callee about call status changes, including when a call is answered on another device.
2026-03-27 17:10:30 +08:00
lafay
7e6a65d29d feat(call): integrate call handling and WebSocket support
All checks were successful
Build Backend / build (push) Successful in 13m26s
Build Backend / build-docker (push) Successful in 1m22s
- Added CallHandler and related services for managing call sessions and participants.
- Enhanced WSHandler to support call signaling, including invite, answer, reject, and end functionalities.
- Updated router to include call-related routes for history and ICE server retrieval.
- Introduced WebRTC configuration in the application settings for call management.
- Refactored wire generation to include CallService and CallRepository for improved dependency injection.
2026-03-27 01:54:34 +08:00
lafay
9ecb29225a feat(ws_handler): enhance WebSocket logging for connection events
All checks were successful
Build Backend / build (push) Successful in 13m56s
Build Backend / build-docker (push) Successful in 1m49s
- Added logging for WebSocket connection attempts, client connections, disconnections, and message receptions to improve traceability and debugging.
- Implemented detailed logs in readPump to capture user and client IDs during various WebSocket events, enhancing visibility into client interactions.
2026-03-26 22:04:37 +08:00
lafay
ebebbbc165 fix(ws_handler): update readPump to prevent closing client.Quit prematurely
All checks were successful
Build Backend / build (push) Successful in 12m50s
Build Backend / build-docker (push) Successful in 1m12s
- Added comments to clarify that the client.Quit channel should not be closed within the readPump function, as it is managed by Hub.Unregister. This change improves code safety and maintainability.
2026-03-26 21:47:49 +08:00
lafay
359e624e7c refactor: update WebSocket handler initialization in wire generation
Some checks failed
Build Backend / build-docker (push) Has been cancelled
Build Backend / build (push) Has been cancelled
- Replaced direct WSHandler instantiation with a dependency injection approach using ProvideWSHandler.
- Improved code organization and maintainability by aligning with the updated dependency injection patterns.
2026-03-26 21:38:51 +08:00
lafay
6d335e393d feat(websocket): integrate WebSocket support and refactor SSE handling
All checks were successful
Build Backend / build (push) Successful in 18m57s
Build Backend / build-docker (push) Successful in 1m4s
- Added WebSocket support by introducing a new WSHandler and related infrastructure.
- Replaced SSE implementations with WebSocket equivalents across message and QR code handlers.
- Updated service and repository layers to utilize WebSocket for real-time messaging.
- Removed obsolete SSE hub and related code, streamlining the application for WebSocket usage.
- Enhanced router to include WebSocket endpoints for real-time communication.
2026-03-26 21:17:49 +08:00
lafay
c6848aba06 refactor: update repository interfaces and improve dependency injection
All checks were successful
Build Backend / build (push) Successful in 12m45s
Build Backend / build-docker (push) Successful in 2m40s
- Refactored repository structures to use interfaces instead of concrete types, enhancing flexibility and testability.
- Updated various repository methods to accept interfaces, allowing for better dependency management.
- Modified wire generation to accommodate new repository interfaces, ensuring proper service injection throughout the application.
- Enhanced handler methods to utilize DTOs for filtering and data handling, improving code clarity and maintainability.
2026-03-26 18:14:16 +08:00
lafay
7b41dfeb00 feat(repository): enhance repository methods with batch processing and transaction support
All checks were successful
Build Backend / build (push) Successful in 12m50s
Build Backend / build-docker (push) Successful in 2m57s
- Updated Like and Unlike methods in CommentRepository to use transactions for data consistency.
- Introduced IsLikedBatch method for batch checking if comments are liked, addressing N+1 query issues.
- Enhanced BatchDelete method in PostRepository to perform batch deletions within a single transaction, improving efficiency.
- Added BatchUpdateStatus method for posts to utilize a single SQL update for status changes, reducing database load.
- Implemented BatchUpdateSortOrder in StickerRepository using CASE WHEN for efficient sorting updates.
- Added IsFollowingBatch and IsBlockedBatch methods in UserRepository for batch checking follow and block relationships, optimizing performance.
2026-03-26 01:50:54 +08:00
lafay
56bd971e42 feat(material): integrate material handling into application routing
All checks were successful
Build Backend / build (push) Successful in 13m10s
Build Backend / build-docker (push) Successful in 2m53s
- Added MaterialHandler and repositories for managing material subjects and files.
- Updated router to include MaterialHandler for enhanced material management.
- Enhanced wire generation to inject material services into the application.
2026-03-25 20:58:55 +08:00
lafay
0ef8e5a7e1 feat(material): add material handling and routing functionality
Some checks failed
Build Backend / build (push) Failing after 12m1s
Build Backend / build-docker (push) Has been skipped
- Introduced MaterialHandler for managing learning materials and subjects.
- Updated router to include routes for public and admin material management.
- Enhanced database initialization with material subject and file models.
- Implemented seeding logic for material subjects and files.
- Updated wire generation to include MaterialHandler and related services.
2026-03-25 20:44:12 +08:00
lafay
28a45caad3 feat(upload): integrate upload service for chat image validation and S3 uploads
All checks were successful
Build Backend / build (push) Successful in 13m35s
Build Backend / build-docker (push) Successful in 1m11s
- Added UploadService to handle image uploads and validation for chat messages.
- Updated ChatService and MessageService to utilize UploadService for validating image segments in messages.
- Enhanced wire generation to inject UploadService into relevant services.
- Introduced UploadImageBytes method in UploadService for uploading images directly from memory.
- Added TrustedPublicURLPrefix method in S3 Client for generating public URLs for uploaded images.
2026-03-25 03:57:40 +08:00
lafay
a887e8ea23 feat(channel): enhance channel service with caching and repository updates
All checks were successful
Build Backend / build (push) Successful in 13m4s
Build Backend / build-docker (push) Successful in 1m22s
- Updated ChannelService to include caching for channel lists, improving performance and reducing database load.
- Introduced cache invalidation methods to ensure channel list consistency after modifications.
- Modified ChannelRepository to remove unused ListByIDs method, streamlining the repository interface.
- Updated wire generation to inject cache into ChannelService for enhanced functionality.
- Added new cache key constants for channel-related data management.
2026-03-25 01:03:34 +08:00
lafay
7c14cf5bab feat(post): enhance post handling with channel integration
- Updated PostHandler to include channel information in post responses.
- Introduced PostChannelBrief DTO to represent channel details associated with posts.
- Modified post conversion functions to support channel data, ensuring proper mapping of channel IDs to channel names.
- Enhanced ChannelRepository and ChannelService to facilitate batch retrieval of channels by IDs.
- Updated wire generation to inject channel service into post handler for improved functionality.
2026-03-25 00:58:41 +08:00
lafay
fc0d6ff19d feat(channel): integrate channel functionality into post management
All checks were successful
Build Backend / build (push) Successful in 19m14s
Build Backend / build-docker (push) Successful in 1m28s
- Added Channel support in Post model, including ChannelID field in PostRequest and PostResponse DTOs.
- Updated PostService and PostRepository to handle channel-specific logic for creating and listing posts.
- Introduced ChannelRepository and ChannelService, along with corresponding handlers for managing channels.
- Enhanced router to include routes for channel management and public channel listing.
- Seeded default channels in the database during initialization.
2026-03-24 22:27:53 +08:00
lafay
15f0f1605e feat(post): implement share recording functionality
All checks were successful
Build Backend / build (push) Successful in 13m16s
Build Backend / build-docker (push) Successful in 1m4s
- Added RecordShare method in PostHandler to handle sharing posts and increment share count.
- Introduced IncrementShares method in PostRepository to update shares_count for published posts.
- Updated PostService to include RecordShare, ensuring cache invalidation for post details and lists.
- Integrated new share route in the router for handling share requests.
2026-03-24 05:21:36 +08:00
lafay
176cd20847 refactor: remove Gorse integration and implement HotRank feature
Some checks failed
Build Backend / build-docker (push) Has been cancelled
Build Backend / build (push) Has been cancelled
- Removed Gorse-related configurations, handlers, and dependencies from the codebase.
- Introduced HotRank feature with configuration options for ranking posts based on recent activity.
- Updated application structure to support HotRank processing, including new caching mechanisms and database interactions.
- Cleaned up related DTOs and repository methods to reflect the removal of Gorse and the addition of HotRank functionality.
2026-03-24 05:18:30 +08:00
lafay
b41567a39a fix(handler): adjust SSE heartbeat interval and cache control header
All checks were successful
Build Backend / build (push) Successful in 14m55s
Build Backend / build-docker (push) Successful in 1m0s
- Updated Cache-Control header to include 'no-transform'.
- Reduced heartbeat interval from 25 seconds to 10 seconds to accommodate common proxy idle timeouts.
- Added comments for clarity on keepalive handling for SSE intermediaries and proxies.
2026-03-24 04:23:23 +08:00
lafay
ab4ee30690 chore(ci): downgrade actions/upload-artifact and actions/download-artifact to version 3 in build workflow
All checks were successful
Build Backend / build (push) Successful in 19m32s
Build Backend / build-docker (push) Successful in 1m0s
2026-03-23 14:38:39 +08:00
lafay
081e44c4ff chore(ci, dto): update build workflow and enhance post DTOs
Some checks failed
Build Backend / build (push) Failing after 13m9s
Build Backend / build-docker (push) Has been skipped
- Added NODE_NO_WARNINGS environment variable to build and deploy workflows.
- Upgraded actions/upload-artifact and actions/download-artifact to version 4 in the build workflow.
- Introduced ContentEditedAt field in PostResponse and PostDetailResponse DTOs to track content modification timestamps.
- Updated post conversion functions to include ContentEditedAt in responses.
- Ensured ContentEditedAt is set during post updates in the repository.
2026-03-23 14:08:36 +08:00
lafay
d2969a32de ci: update Docker build cache configuration to use registry
All checks were successful
Build Backend / build (push) Successful in 14m14s
Build Backend / build-docker (push) Successful in 1m12s
2026-03-23 03:08:21 +08:00
lafay
9d546b9989 refactor(dto, repository): standardize pagination field names and update moderation status handling
Some checks failed
Build Backend / build (push) Successful in 12m39s
Build Backend / build-docker (push) Failing after 2m0s
- Changed JSON field names from "items" to "list" in various cursor pagination response DTOs for consistency.
- Updated moderation status update methods in CommentRepository and PostRepository to use UpdateColumn for better clarity and to ensure that moderation actions do not affect content update timestamps.
2026-03-23 01:06:41 +08:00
lafay
fa68f101de ci: 添加Go模块缓存和Docker构建缓存
Some checks failed
Build Backend / build (push) Successful in 12m35s
Build Backend / build-docker (push) Failing after 1m55s
2026-03-21 14:15:27 +08:00
lafay
d7c7a51f77 refactor(notification): migrate cursor pagination to use unified cursor package
All checks were successful
Build Backend / build (push) Successful in 3m58s
Build Backend / build-docker (push) Successful in 2m52s
- Add new GetByReceiverIDCursor method with context support and generic cursor types
- Rename old implementation to GetByReceiverIDCursorLegacy for backward compatibility
- Update handler to use legacy interface temporarily during migration
- Use CursorBuilder pattern for consistent pagination across services
2026-03-21 03:21:07 +08:00
lafay
639ee55696 feat(wire): add moderation and builtin hooks to post service wiring
All checks were successful
Build Backend / build (push) Successful in 3m57s
Build Backend / build-docker (push) Successful in 4m43s
Add wire providers for ModerationHooks and BuiltinHooks, injecting them
into PostService via dependency injection. Hooks are passed as blank
identifiers to trigger their initialization and registration side effects.
2026-03-21 02:53:38 +08:00
lafay
1a48bb440c feat(api): add cursor-based pagination for system messages
Some checks failed
Build Backend / build-docker (push) Has been cancelled
Build Backend / build (push) Has been cancelled
- Add SystemMessageCursorPageResponse DTO for cursor pagination
- Implement GetSystemMessagesByCursor handler method
- Add GetByReceiverIDCursor repository method with cursor decoding
- Maintain backward compatibility with existing offset-based pagination
2026-03-21 02:48:06 +08:00
lafay
687ac92aea feat(api): add tab-based post feed endpoints with cursor pagination
Some checks failed
Build Backend / build (push) Successful in 3m58s
Build Backend / build-docker (push) Failing after 4m49s
Add support for different post feed types via tab parameter:
- follow: posts from followed users (requires authentication)
- hot: trending posts using Gorse most_liked_weekly or database fallback
- recommend: personalized recommendations with random offset for diversity
- latest: default chronological feed

The cursor pagination detection is also improved to check for parameter
existence rather than non-empty string value.
2026-03-21 02:24:11 +08:00
lafay
57058b04da fix(response): correct error status mapping and response structure
All checks were successful
Build Backend / build (push) Successful in 4m46s
Build Backend / build-docker (push) Successful in 5m33s
Map INVALID_CREDENTIALS to http.StatusBadRequest (400) instead of
http.StatusUnauthorized (401) to better align with client error semantics.

Update ErrorWithStringCode to return the HTTP status code in the 'code'
field and set 'data' to nil, removing the legacy compatibility workaround
that set 'code' to 0.

BREAKING CHANGE: Response structure for ErrorWithStringCode changed (Code
field now returns status code, Data is nil) and INVALID_CREDENTIALS status
changed from 401 to 400.
2026-03-21 01:35:57 +08:00
lafay
92babe509f feat(api): add cursor-based pagination for posts, comments, messages, groups, and notifications
All checks were successful
Build Backend / build (push) Successful in 4m42s
Build Backend / build-docker (push) Successful in 3m53s
Implement cursor-based pagination across multiple API endpoints to improve performance and enable efficient infinite scrolling. This replaces traditional offset-based pagination for high-volume data retrieval.

Changes:
- Add cursor pagination DTOs for all entity types (Post, Comment, Message, Conversation, Group, Notification, GroupMember, GroupAnnouncement)
- Implement cursor pagination methods in repositories with support for various sort orders (created_at, updated_at, join_time, seq)
- Add cursor pagination handlers that maintain backward compatibility with existing offset pagination
- Add new API routes for cursor-based endpoints (/cursor suffix)
- Add helper converter functions for pointer slice types in DTO conversions
2026-03-20 23:03:23 +08:00
lafay
98f0c9f2b6 feat: add hook system, QR code login, and layered cache
All checks were successful
Build Backend / build (push) Successful in 5m54s
Build Backend / build-docker (push) Successful in 4m7s
- Implement extensible hook system for content moderation with
  builtin and AI-powered moderation hooks
- Add QR code login feature with SSE for real-time status updates
  and scan/confirm/cancel workflow
- Introduce layered cache with local LRU + Redis backend for
  improved read performance
- Refactor post and comment services to use hook-based moderation
  instead of direct AI service calls
- Add local cache configuration options (size, buckets, ttl)
2026-03-20 12:23:28 +08:00
lafay
bdfcbdadea fix(cors): add admin.littlelan.cn and browser.littlelan.cn, support *.littlelan.cn subdomains
All checks were successful
Build Backend / build (push) Successful in 12m24s
Build Backend / build-docker (push) Successful in 13m8s
2026-03-19 22:46:21 +08:00
lafay
2c1f2f47ec Merge branch 'master' of https://code.littlelan.cn/carrot_bbs/backend
All checks were successful
Build Backend / build (push) Successful in 3m58s
Build Backend / build-docker (push) Successful in 2m29s
2026-03-19 19:43:40 +08:00
lafay
4c2d605411 fix: 修复了未自动迁移的表 2026-03-19 19:43:37 +08:00
317 changed files with 39122 additions and 9641 deletions

View File

@@ -10,6 +10,10 @@ on:
- master
- dev
env:
GO_VERSION: '1.26'
GOPROXY: https://goproxy.cn,direct
jobs:
build:
runs-on: ubuntu-latest
@@ -20,11 +24,26 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
go-version: ${{ env.GO_VERSION }}
cache: false
- name: Go cache info
run: |
echo "Go cache directory:"
go env GOCACHE
echo "Go module cache directory:"
go env GOMODCACHE
- 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
run: go mod download
@@ -76,3 +95,5 @@ jobs:
code.littlelan.cn/carrot_bbs/backend:latest
code.littlelan.cn/carrot_bbs/backend:${{ github.sha }}
platforms: linux/amd64
cache-from: type=registry,ref=code.littlelan.cn/carrot_bbs/backend:buildcache
cache-to: type=registry,ref=code.littlelan.cn/carrot_bbs/backend:buildcache,mode=max

View File

@@ -1,4 +1,4 @@
name: Deploy
name: Deploy
on:
pull_request:
@@ -25,16 +25,17 @@ on:
env:
REGISTRY: code.littlelan.cn
IMAGE_NAME: carrot_bbs/backend
SERVER_HOST: 111.170.19.33
SERVER_USER: root
CONTAINER_NAME: carrot_bbs
SERVER_HOST: 124.222.83.58
SERVER_USER: ubuntu
CONTAINER_NAME: with_you
CONTAINER_NETWORK: 1panel-network
jobs:
deploy:
runs-on: ubuntu-latest
if: ${{ (github.event.pull_request.merged == true) || (github.event_name == 'workflow_dispatch') }}
environment: production
# workflow_dispatch 可选 staging合并进主分支默认 production
environment: ${{ github.event.inputs.environment || 'production' }}
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
@@ -50,50 +51,50 @@ jobs:
NETWORK="${{ env.CONTAINER_NETWORK }}"
echo "=== Pulling image: ${IMAGE} ==="
docker pull ${IMAGE}
sudo docker pull ${IMAGE}
echo "=== Stopping old container ==="
docker stop ${CONTAINER} 2>/dev/null || true
docker rm ${CONTAINER} 2>/dev/null || true
sudo docker stop ${CONTAINER} 2>/dev/null || true
sudo docker rm ${CONTAINER} 2>/dev/null || true
echo "=== Starting new container ==="
docker run -d \
sudo docker run -d \
--name ${CONTAINER} \
--network ${NETWORK} \
--restart unless-stopped \
-p 8080:8080 \
-p 50051:50051 \
-e APP_DATABASE_TYPE=postgres \
-e APP_DATABASE_POSTGRES_HOST=1Panel-postgresql-t0g7 \
-e APP_DATABASE_POSTGRES_HOST=1Panel-postgresql-Mjd1 \
-e APP_DATABASE_POSTGRES_PORT=5432 \
-e APP_DATABASE_POSTGRES_USER=carrot_bbs \
-e APP_DATABASE_POSTGRES_USER=with_you \
-e "APP_DATABASE_POSTGRES_PASSWORD=${{ secrets.DB_PASSWORD }}" \
-e APP_DATABASE_POSTGRES_DBNAME=carrot_bbs \
-e APP_DATABASE_POSTGRES_DBNAME=with_you \
-e APP_REDIS_TYPE=redis \
-e APP_REDIS_REDIS_HOST=1Panel-redis-dfmM \
-e APP_REDIS_REDIS_HOST=1Panel-redis-fq2g \
-e APP_REDIS_REDIS_PORT=6379 \
-e "APP_REDIS_REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }}" \
-e APP_REDIS_REDIS_DB=0 \
-e APP_S3_ENDPOINT=files.littlelan.cn \
-e APP_S3_ENDPOINT=storage.littlelan.cn \
-e "APP_S3_ACCESS_KEY=${{ secrets.S3_ACCESS_KEY }}" \
-e "APP_S3_SECRET_KEY=${{ secrets.S3_SECRET_KEY }}" \
-e APP_S3_BUCKET=test \
-e APP_S3_DOMAIN=files.littlelan.cn \
-e APP_S3_BUCKET=withyou \
-e APP_S3_DOMAIN=storage.littlelan.cn \
-e APP_S3_USE_SSL=true \
-e "APP_JWT_SECRET=${{ secrets.JWT_SECRET }}" \
-e APP_ENCRYPTION_ENABLED=true \
-e "APP_ENCRYPTION_KEY=${{ secrets.ENCRYPTION_KEY }}" \
-e APP_ENCRYPTION_KEY_VERSION=1 \
-e APP_GORSE_ENABLED=true \
-e APP_GORSE_ADDRESS=http://111.170.19.33:8088 \
-e "APP_GORSE_IMPORT_PASSWORD=${{ secrets.GORSE_IMPORT_PASSWORD }}" \
-e "APP_GORSE_EMBEDDING_API_KEY=${{ secrets.GORSE_EMBEDDING_API_KEY }}" \
-e APP_GORSE_EMBEDDING_URL=https://api.littlelan.cn/v1/embeddings \
-e APP_GORSE_EMBEDDING_MODEL=BAAI/bge-m3 \
-e APP_HOT_RANK_ENABLED=true \
-e APP_HOT_RANK_REFRESH_INTERVAL_SECONDS=300 \
-e APP_HOT_RANK_TOP_N=100 \
-e APP_HOT_RANK_RECENT_WINDOW_HOURS=168 \
-e APP_HOT_RANK_RECENT_FETCH_LIMIT=500 \
-e APP_HOT_RANK_CANDIDATE_CAP=800 \
-e APP_OPENAI_ENABLED=true \
-e APP_OPENAI_BASE_URL=https://api.littlelan.cn/ \
-e "APP_OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}" \
-e APP_OPENAI_MODERATION_MODEL=qwen3.5-plus \
-e APP_OPENAI_MODERATION_MODEL=qwen3.6-plus \
-e APP_OPENAI_MODERATION_MAX_IMAGES_PER_REQUEST=3 \
-e APP_OPENAI_REQUEST_TIMEOUT=30 \
-e APP_OPENAI_STRICT_MODERATION=false \
@@ -103,12 +104,29 @@ jobs:
-e APP_EMAIL_USERNAME=no-reply@qczlit.cn \
-e "APP_EMAIL_PASSWORD=${{ secrets.EMAIL_PASSWORD }}" \
-e APP_EMAIL_FROM_ADDRESS=no-reply@qczlit.cn \
-e APP_EMAIL_FROM_NAME=Carrot_BBS \
-e APP_EMAIL_FROM_NAME=WithYou \
-e APP_EMAIL_USE_TLS=true \
-e APP_EMAIL_TIMEOUT=15 \
-e APP_GRPC_ENABLED=true \
-e APP_GRPC_PORT=50051 \
-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 }}" \
${IMAGE}
echo "=== Waiting for startup ==="
@@ -125,6 +143,7 @@ jobs:
done
echo "=== Container logs ==="
docker logs ${CONTAINER} --tail 20
sudo docker logs ${CONTAINER} --tail 20
echo "=== Deployment completed ==="

1
.gitignore vendored
View File

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

376
.kilo/package-lock.json generated Normal file
View File

@@ -0,0 +1,376 @@
{
"name": ".kilo",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@kilocode/plugin": "7.2.31"
}
},
"node_modules/@kilocode/plugin": {
"version": "7.2.31",
"resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.31.tgz",
"integrity": "sha512-KmKTTIly7hRlJdXKhqZ/j/brvTPh0z0UTjWSjJWq5fqf4pATgYGn7G0g3ZjILnN7MUkkZXuljgqExTEeQJHGkQ==",
"license": "MIT",
"dependencies": {
"@kilocode/sdk": "7.2.31",
"effect": "4.0.0-beta.48",
"zod": "4.1.8"
},
"peerDependencies": {
"@opentui/core": ">=0.1.100",
"@opentui/solid": ">=0.1.100"
},
"peerDependenciesMeta": {
"@opentui/core": {
"optional": true
},
"@opentui/solid": {
"optional": true
}
}
},
"node_modules/@kilocode/sdk": {
"version": "7.2.31",
"resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.31.tgz",
"integrity": "sha512-Sx05yv+3TIlc6M4Ze+YGgKCLoIg8B0WRE15JXDriVncT+wz7M6+e+4mjNWkwfsuywdeOTYPfeFViqX8iO7ckKA==",
"license": "MIT",
"dependencies": {
"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": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"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": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"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": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"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": {
"version": "4.1.8",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -1,674 +0,0 @@
---
name: golang-patterns
description: 用于构建健壮、高效且可维护的Go应用程序的惯用Go模式、最佳实践和约定。
origin: ECC
---
# Go 开发模式
用于构建健壮、高效和可维护应用程序的惯用 Go 模式与最佳实践。
## 何时激活
* 编写新的 Go 代码时
* 审查 Go 代码时
* 重构现有 Go 代码时
* 设计 Go 包/模块时
## 核心原则
### 1. 简洁与清晰
Go 推崇简洁而非精巧。代码应该显而易见且易于阅读。
```go
// Good: Clear and direct
func GetUser(id string) (*User, error) {
user, err := db.FindUser(id)
if err != nil {
return nil, fmt.Errorf("get user %s: %w", id, err)
}
return user, nil
}
// Bad: Overly clever
func GetUser(id string) (*User, error) {
return func() (*User, error) {
if u, e := db.FindUser(id); e == nil {
return u, nil
} else {
return nil, e
}
}()
}
```
### 2. 让零值变得有用
设计类型时,应使其零值无需初始化即可立即使用。
```go
// Good: Zero value is useful
type Counter struct {
mu sync.Mutex
count int // zero value is 0, ready to use
}
func (c *Counter) Inc() {
c.mu.Lock()
c.count++
c.mu.Unlock()
}
// Good: bytes.Buffer works with zero value
var buf bytes.Buffer
buf.WriteString("hello")
// Bad: Requires initialization
type BadCounter struct {
counts map[string]int // nil map will panic
}
```
### 3. 接受接口,返回结构体
函数应该接受接口参数并返回具体类型。
```go
// Good: Accepts interface, returns concrete type
func ProcessData(r io.Reader) (*Result, error) {
data, err := io.ReadAll(r)
if err != nil {
return nil, err
}
return &Result{Data: data}, nil
}
// Bad: Returns interface (hides implementation details unnecessarily)
func ProcessData(r io.Reader) (io.Reader, error) {
// ...
}
```
## 错误处理模式
### 带上下文的错误包装
```go
// Good: Wrap errors with context
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("load config %s: %w", path, err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config %s: %w", path, err)
}
return &cfg, nil
}
```
### 自定义错误类型
```go
// Define domain-specific errors
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Message)
}
// Sentinel errors for common cases
var (
ErrNotFound = errors.New("resource not found")
ErrUnauthorized = errors.New("unauthorized")
ErrInvalidInput = errors.New("invalid input")
)
```
### 使用 errors.Is 和 errors.As 检查错误
```go
func HandleError(err error) {
// Check for specific error
if errors.Is(err, sql.ErrNoRows) {
log.Println("No records found")
return
}
// Check for error type
var validationErr *ValidationError
if errors.As(err, &validationErr) {
log.Printf("Validation error on field %s: %s",
validationErr.Field, validationErr.Message)
return
}
// Unknown error
log.Printf("Unexpected error: %v", err)
}
```
### 永不忽略错误
```go
// Bad: Ignoring error with blank identifier
result, _ := doSomething()
// Good: Handle or explicitly document why it's safe to ignore
result, err := doSomething()
if err != nil {
return err
}
// Acceptable: When error truly doesn't matter (rare)
_ = writer.Close() // Best-effort cleanup, error logged elsewhere
```
## 并发模式
### 工作池
```go
func WorkerPool(jobs <-chan Job, results chan<- Result, numWorkers int) {
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
results <- process(job)
}
}()
}
wg.Wait()
close(results)
}
```
### 用于取消和超时的 Context
```go
func FetchWithTimeout(ctx context.Context, url string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch %s: %w", url, err)
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
```
### 优雅关闭
```go
func GracefulShutdown(server *http.Server) {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
log.Println("Server exited")
}
```
### 用于协调 Goroutine 的 errgroup
```go
import "golang.org/x/sync/errgroup"
func FetchAll(ctx context.Context, urls []string) ([][]byte, error) {
g, ctx := errgroup.WithContext(ctx)
results := make([][]byte, len(urls))
for i, url := range urls {
i, url := i, url // Capture loop variables
g.Go(func() error {
data, err := FetchWithTimeout(ctx, url)
if err != nil {
return err
}
results[i] = data
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return results, nil
}
```
### 避免 Goroutine 泄漏
```go
// Bad: Goroutine leak if context is cancelled
func leakyFetch(ctx context.Context, url string) <-chan []byte {
ch := make(chan []byte)
go func() {
data, _ := fetch(url)
ch <- data // Blocks forever if no receiver
}()
return ch
}
// Good: Properly handles cancellation
func safeFetch(ctx context.Context, url string) <-chan []byte {
ch := make(chan []byte, 1) // Buffered channel
go func() {
data, err := fetch(url)
if err != nil {
return
}
select {
case ch <- data:
case <-ctx.Done():
}
}()
return ch
}
```
## 接口设计
### 小而专注的接口
```go
// Good: Single-method interfaces
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
type Closer interface {
Close() error
}
// Compose interfaces as needed
type ReadWriteCloser interface {
Reader
Writer
Closer
}
```
### 在接口使用处定义接口
```go
// In the consumer package, not the provider
package service
// UserStore defines what this service needs
type UserStore interface {
GetUser(id string) (*User, error)
SaveUser(user *User) error
}
type Service struct {
store UserStore
}
// Concrete implementation can be in another package
// It doesn't need to know about this interface
```
### 使用类型断言实现可选行为
```go
type Flusher interface {
Flush() error
}
func WriteAndFlush(w io.Writer, data []byte) error {
if _, err := w.Write(data); err != nil {
return err
}
// Flush if supported
if f, ok := w.(Flusher); ok {
return f.Flush()
}
return nil
}
```
## 包组织
### 标准项目布局
```text
myproject/
├── cmd/
│ └── myapp/
│ └── main.go # Entry point
├── internal/
│ ├── handler/ # HTTP handlers
│ ├── service/ # Business logic
│ ├── repository/ # Data access
│ └── config/ # Configuration
├── pkg/
│ └── client/ # Public API client
├── api/
│ └── v1/ # API definitions (proto, OpenAPI)
├── testdata/ # Test fixtures
├── go.mod
├── go.sum
└── Makefile
```
### 包命名
```go
// Good: Short, lowercase, no underscores
package http
package json
package user
// Bad: Verbose, mixed case, or redundant
package httpHandler
package json_parser
package userService // Redundant 'Service' suffix
```
### 避免包级状态
```go
// Bad: Global mutable state
var db *sql.DB
func init() {
db, _ = sql.Open("postgres", os.Getenv("DATABASE_URL"))
}
// Good: Dependency injection
type Server struct {
db *sql.DB
}
func NewServer(db *sql.DB) *Server {
return &Server{db: db}
}
```
## 结构体设计
### 函数式选项模式
```go
type Server struct {
addr string
timeout time.Duration
logger *log.Logger
}
type Option func(*Server)
func WithTimeout(d time.Duration) Option {
return func(s *Server) {
s.timeout = d
}
}
func WithLogger(l *log.Logger) Option {
return func(s *Server) {
s.logger = l
}
}
func NewServer(addr string, opts ...Option) *Server {
s := &Server{
addr: addr,
timeout: 30 * time.Second, // default
logger: log.Default(), // default
}
for _, opt := range opts {
opt(s)
}
return s
}
// Usage
server := NewServer(":8080",
WithTimeout(60*time.Second),
WithLogger(customLogger),
)
```
### 使用嵌入实现组合
```go
type Logger struct {
prefix string
}
func (l *Logger) Log(msg string) {
fmt.Printf("[%s] %s\n", l.prefix, msg)
}
type Server struct {
*Logger // Embedding - Server gets Log method
addr string
}
func NewServer(addr string) *Server {
return &Server{
Logger: &Logger{prefix: "SERVER"},
addr: addr,
}
}
// Usage
s := NewServer(":8080")
s.Log("Starting...") // Calls embedded Logger.Log
```
## 内存与性能
### 当大小已知时预分配切片
```go
// Bad: Grows slice multiple times
func processItems(items []Item) []Result {
var results []Result
for _, item := range items {
results = append(results, process(item))
}
return results
}
// Good: Single allocation
func processItems(items []Item) []Result {
results := make([]Result, 0, len(items))
for _, item := range items {
results = append(results, process(item))
}
return results
}
```
### 为频繁分配使用 sync.Pool
```go
var bufferPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
func ProcessRequest(data []byte) []byte {
buf := bufferPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset()
bufferPool.Put(buf)
}()
buf.Write(data)
// Process...
return buf.Bytes()
}
```
### 避免在循环中进行字符串拼接
```go
// Bad: Creates many string allocations
func join(parts []string) string {
var result string
for _, p := range parts {
result += p + ","
}
return result
}
// Good: Single allocation with strings.Builder
func join(parts []string) string {
var sb strings.Builder
for i, p := range parts {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString(p)
}
return sb.String()
}
// Best: Use standard library
func join(parts []string) string {
return strings.Join(parts, ",")
}
```
## Go 工具集成
### 基本命令
```bash
# Build and run
go build ./...
go run ./cmd/myapp
# Testing
go test ./...
go test -race ./...
go test -cover ./...
# Static analysis
go vet ./...
staticcheck ./...
golangci-lint run
# Module management
go mod tidy
go mod verify
# Formatting
gofmt -w .
goimports -w .
```
### 推荐的 Linter 配置 (.golangci.yml)
```yaml
linters:
enable:
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- unused
- gofmt
- goimports
- misspell
- unconvert
- unparam
linters-settings:
errcheck:
check-type-assertions: true
govet:
check-shadowing: true
issues:
exclude-use-default: false
```
## 快速参考Go 惯用法
| 惯用法 | 描述 |
|-------|-------------|
| 接受接口,返回结构体 | 函数接受接口参数,返回具体类型 |
| 错误即值 | 将错误视为一等值,而非异常 |
| 不要通过共享内存来通信 | 使用通道在 goroutine 之间进行协调 |
| 让零值变得有用 | 类型应无需显式初始化即可工作 |
| 少量复制优于少量依赖 | 避免不必要的外部依赖 |
| 清晰优于精巧 | 优先考虑可读性而非精巧性 |
| gofmt 虽非最爱,但却是每个人的朋友 | 始终使用 gofmt/goimports 格式化代码 |
| 提前返回 | 先处理错误,保持主逻辑路径无缩进 |
## 应避免的反模式
```go
// Bad: Naked returns in long functions
func process() (result int, err error) {
// ... 50 lines ...
return // What is being returned?
}
// Bad: Using panic for control flow
func GetUser(id string) *User {
user, err := db.Find(id)
if err != nil {
panic(err) // Don't do this
}
return user
}
// Bad: Passing context in struct
type Request struct {
ctx context.Context // Context should be first param
ID string
}
// Good: Context as first parameter
func ProcessRequest(ctx context.Context, id string) error {
// ...
}
// Bad: Mixing value and pointer receivers
type Counter struct{ n int }
func (c Counter) Value() int { return c.n } // Value receiver
func (c *Counter) Increment() { c.n++ } // Pointer receiver
// Pick one style and be consistent
```
**记住**Go 代码应该以最好的方式显得“乏味”——可预测、一致且易于理解。如有疑问,保持简单。

View File

@@ -1,720 +0,0 @@
---
name: golang-testing
description: Go testing patterns including table-driven tests, subtests, benchmarks, fuzzing, and test coverage. Follows TDD methodology with idiomatic Go practices.
origin: ECC
---
# Go Testing Patterns
Comprehensive Go testing patterns for writing reliable, maintainable tests following TDD methodology.
## When to Activate
- Writing new Go functions or methods
- Adding test coverage to existing code
- Creating benchmarks for performance-critical code
- Implementing fuzz tests for input validation
- Following TDD workflow in Go projects
## TDD Workflow for Go
### The RED-GREEN-REFACTOR Cycle
```
RED → Write a failing test first
GREEN → Write minimal code to pass the test
REFACTOR → Improve code while keeping tests green
REPEAT → Continue with next requirement
```
### Step-by-Step TDD in Go
```go
// Step 1: Define the interface/signature
// calculator.go
package calculator
func Add(a, b int) int {
panic("not implemented") // Placeholder
}
// Step 2: Write failing test (RED)
// calculator_test.go
package calculator
import "testing"
func TestAdd(t *testing.T) {
got := Add(2, 3)
want := 5
if got != want {
t.Errorf("Add(2, 3) = %d; want %d", got, want)
}
}
// Step 3: Run test - verify FAIL
// $ go test
// --- FAIL: TestAdd (0.00s)
// panic: not implemented
// Step 4: Implement minimal code (GREEN)
func Add(a, b int) int {
return a + b
}
// Step 5: Run test - verify PASS
// $ go test
// PASS
// Step 6: Refactor if needed, verify tests still pass
```
## Table-Driven Tests
The standard pattern for Go tests. Enables comprehensive coverage with minimal code.
```go
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive numbers", 2, 3, 5},
{"negative numbers", -1, -2, -3},
{"zero values", 0, 0, 0},
{"mixed signs", -1, 1, 0},
{"large numbers", 1000000, 2000000, 3000000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.a, tt.b)
if got != tt.expected {
t.Errorf("Add(%d, %d) = %d; want %d",
tt.a, tt.b, got, tt.expected)
}
})
}
}
```
### Table-Driven Tests with Error Cases
```go
func TestParseConfig(t *testing.T) {
tests := []struct {
name string
input string
want *Config
wantErr bool
}{
{
name: "valid config",
input: `{"host": "localhost", "port": 8080}`,
want: &Config{Host: "localhost", Port: 8080},
},
{
name: "invalid JSON",
input: `{invalid}`,
wantErr: true,
},
{
name: "empty input",
input: "",
wantErr: true,
},
{
name: "minimal config",
input: `{}`,
want: &Config{}, // Zero value config
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseConfig(tt.input)
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("got %+v; want %+v", got, tt.want)
}
})
}
}
```
## Subtests and Sub-benchmarks
### Organizing Related Tests
```go
func TestUser(t *testing.T) {
// Setup shared by all subtests
db := setupTestDB(t)
t.Run("Create", func(t *testing.T) {
user := &User{Name: "Alice"}
err := db.CreateUser(user)
if err != nil {
t.Fatalf("CreateUser failed: %v", err)
}
if user.ID == "" {
t.Error("expected user ID to be set")
}
})
t.Run("Get", func(t *testing.T) {
user, err := db.GetUser("alice-id")
if err != nil {
t.Fatalf("GetUser failed: %v", err)
}
if user.Name != "Alice" {
t.Errorf("got name %q; want %q", user.Name, "Alice")
}
})
t.Run("Update", func(t *testing.T) {
// ...
})
t.Run("Delete", func(t *testing.T) {
// ...
})
}
```
### Parallel Subtests
```go
func TestParallel(t *testing.T) {
tests := []struct {
name string
input string
}{
{"case1", "input1"},
{"case2", "input2"},
{"case3", "input3"},
}
for _, tt := range tests {
tt := tt // Capture range variable
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // Run subtests in parallel
result := Process(tt.input)
// assertions...
_ = result
})
}
}
```
## Test Helpers
### Helper Functions
```go
func setupTestDB(t *testing.T) *sql.DB {
t.Helper() // Marks this as a helper function
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
// Cleanup when test finishes
t.Cleanup(func() {
db.Close()
})
// Run migrations
if _, err := db.Exec(schema); err != nil {
t.Fatalf("failed to create schema: %v", err)
}
return db
}
func assertNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func assertEqual[T comparable](t *testing.T, got, want T) {
t.Helper()
if got != want {
t.Errorf("got %v; want %v", got, want)
}
}
```
### Temporary Files and Directories
```go
func TestFileProcessing(t *testing.T) {
// Create temp directory - automatically cleaned up
tmpDir := t.TempDir()
// Create test file
testFile := filepath.Join(tmpDir, "test.txt")
err := os.WriteFile(testFile, []byte("test content"), 0644)
if err != nil {
t.Fatalf("failed to create test file: %v", err)
}
// Run test
result, err := ProcessFile(testFile)
if err != nil {
t.Fatalf("ProcessFile failed: %v", err)
}
// Assert...
_ = result
}
```
## Golden Files
Testing against expected output files stored in `testdata/`.
```go
var update = flag.Bool("update", false, "update golden files")
func TestRender(t *testing.T) {
tests := []struct {
name string
input Template
}{
{"simple", Template{Name: "test"}},
{"complex", Template{Name: "test", Items: []string{"a", "b"}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Render(tt.input)
golden := filepath.Join("testdata", tt.name+".golden")
if *update {
// Update golden file: go test -update
err := os.WriteFile(golden, got, 0644)
if err != nil {
t.Fatalf("failed to update golden file: %v", err)
}
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatalf("failed to read golden file: %v", err)
}
if !bytes.Equal(got, want) {
t.Errorf("output mismatch:\ngot:\n%s\nwant:\n%s", got, want)
}
})
}
}
```
## Mocking with Interfaces
### Interface-Based Mocking
```go
// Define interface for dependencies
type UserRepository interface {
GetUser(id string) (*User, error)
SaveUser(user *User) error
}
// Production implementation
type PostgresUserRepository struct {
db *sql.DB
}
func (r *PostgresUserRepository) GetUser(id string) (*User, error) {
// Real database query
}
// Mock implementation for tests
type MockUserRepository struct {
GetUserFunc func(id string) (*User, error)
SaveUserFunc func(user *User) error
}
func (m *MockUserRepository) GetUser(id string) (*User, error) {
return m.GetUserFunc(id)
}
func (m *MockUserRepository) SaveUser(user *User) error {
return m.SaveUserFunc(user)
}
// Test using mock
func TestUserService(t *testing.T) {
mock := &MockUserRepository{
GetUserFunc: func(id string) (*User, error) {
if id == "123" {
return &User{ID: "123", Name: "Alice"}, nil
}
return nil, ErrNotFound
},
}
service := NewUserService(mock)
user, err := service.GetUserProfile("123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user.Name != "Alice" {
t.Errorf("got name %q; want %q", user.Name, "Alice")
}
}
```
## Benchmarks
### Basic Benchmarks
```go
func BenchmarkProcess(b *testing.B) {
data := generateTestData(1000)
b.ResetTimer() // Don't count setup time
for i := 0; i < b.N; i++ {
Process(data)
}
}
// Run: go test -bench=BenchmarkProcess -benchmem
// Output: BenchmarkProcess-8 10000 105234 ns/op 4096 B/op 10 allocs/op
```
### Benchmark with Different Sizes
```go
func BenchmarkSort(b *testing.B) {
sizes := []int{100, 1000, 10000, 100000}
for _, size := range sizes {
b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
data := generateRandomSlice(size)
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Make a copy to avoid sorting already sorted data
tmp := make([]int, len(data))
copy(tmp, data)
sort.Ints(tmp)
}
})
}
}
```
### Memory Allocation Benchmarks
```go
func BenchmarkStringConcat(b *testing.B) {
parts := []string{"hello", "world", "foo", "bar", "baz"}
b.Run("plus", func(b *testing.B) {
for i := 0; i < b.N; i++ {
var s string
for _, p := range parts {
s += p
}
_ = s
}
})
b.Run("builder", func(b *testing.B) {
for i := 0; i < b.N; i++ {
var sb strings.Builder
for _, p := range parts {
sb.WriteString(p)
}
_ = sb.String()
}
})
b.Run("join", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = strings.Join(parts, "")
}
})
}
```
## Fuzzing (Go 1.18+)
### Basic Fuzz Test
```go
func FuzzParseJSON(f *testing.F) {
// Add seed corpus
f.Add(`{"name": "test"}`)
f.Add(`{"count": 123}`)
f.Add(`[]`)
f.Add(`""`)
f.Fuzz(func(t *testing.T, input string) {
var result map[string]interface{}
err := json.Unmarshal([]byte(input), &result)
if err != nil {
// Invalid JSON is expected for random input
return
}
// If parsing succeeded, re-encoding should work
_, err = json.Marshal(result)
if err != nil {
t.Errorf("Marshal failed after successful Unmarshal: %v", err)
}
})
}
// Run: go test -fuzz=FuzzParseJSON -fuzztime=30s
```
### Fuzz Test with Multiple Inputs
```go
func FuzzCompare(f *testing.F) {
f.Add("hello", "world")
f.Add("", "")
f.Add("abc", "abc")
f.Fuzz(func(t *testing.T, a, b string) {
result := Compare(a, b)
// Property: Compare(a, a) should always equal 0
if a == b && result != 0 {
t.Errorf("Compare(%q, %q) = %d; want 0", a, b, result)
}
// Property: Compare(a, b) and Compare(b, a) should have opposite signs
reverse := Compare(b, a)
if (result > 0 && reverse >= 0) || (result < 0 && reverse <= 0) {
if result != 0 || reverse != 0 {
t.Errorf("Compare(%q, %q) = %d, Compare(%q, %q) = %d; inconsistent",
a, b, result, b, a, reverse)
}
}
})
}
```
## Test Coverage
### Running Coverage
```bash
# Basic coverage
go test -cover ./...
# Generate coverage profile
go test -coverprofile=coverage.out ./...
# View coverage in browser
go tool cover -html=coverage.out
# View coverage by function
go tool cover -func=coverage.out
# Coverage with race detection
go test -race -coverprofile=coverage.out ./...
```
### Coverage Targets
| Code Type | Target |
|-----------|--------|
| Critical business logic | 100% |
| Public APIs | 90%+ |
| General code | 80%+ |
| Generated code | Exclude |
### Excluding Generated Code from Coverage
```go
//go:generate mockgen -source=interface.go -destination=mock_interface.go
// In coverage profile, exclude with build tags:
// go test -cover -tags=!generate ./...
```
## HTTP Handler Testing
```go
func TestHealthHandler(t *testing.T) {
// Create request
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
// Call handler
HealthHandler(w, req)
// Check response
resp := w.Result()
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("got status %d; want %d", resp.StatusCode, http.StatusOK)
}
body, _ := io.ReadAll(resp.Body)
if string(body) != "OK" {
t.Errorf("got body %q; want %q", body, "OK")
}
}
func TestAPIHandler(t *testing.T) {
tests := []struct {
name string
method string
path string
body string
wantStatus int
wantBody string
}{
{
name: "get user",
method: http.MethodGet,
path: "/users/123",
wantStatus: http.StatusOK,
wantBody: `{"id":"123","name":"Alice"}`,
},
{
name: "not found",
method: http.MethodGet,
path: "/users/999",
wantStatus: http.StatusNotFound,
},
{
name: "create user",
method: http.MethodPost,
path: "/users",
body: `{"name":"Bob"}`,
wantStatus: http.StatusCreated,
},
}
handler := NewAPIHandler()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var body io.Reader
if tt.body != "" {
body = strings.NewReader(tt.body)
}
req := httptest.NewRequest(tt.method, tt.path, body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("got status %d; want %d", w.Code, tt.wantStatus)
}
if tt.wantBody != "" && w.Body.String() != tt.wantBody {
t.Errorf("got body %q; want %q", w.Body.String(), tt.wantBody)
}
})
}
}
```
## Testing Commands
```bash
# Run all tests
go test ./...
# Run tests with verbose output
go test -v ./...
# Run specific test
go test -run TestAdd ./...
# Run tests matching pattern
go test -run "TestUser/Create" ./...
# Run tests with race detector
go test -race ./...
# Run tests with coverage
go test -cover -coverprofile=coverage.out ./...
# Run short tests only
go test -short ./...
# Run tests with timeout
go test -timeout 30s ./...
# Run benchmarks
go test -bench=. -benchmem ./...
# Run fuzzing
go test -fuzz=FuzzParse -fuzztime=30s ./...
# Count test runs (for flaky test detection)
go test -count=10 ./...
```
## Best Practices
**DO:**
- Write tests FIRST (TDD)
- Use table-driven tests for comprehensive coverage
- Test behavior, not implementation
- Use `t.Helper()` in helper functions
- Use `t.Parallel()` for independent tests
- Clean up resources with `t.Cleanup()`
- Use meaningful test names that describe the scenario
**DON'T:**
- Test private functions directly (test through public API)
- Use `time.Sleep()` in tests (use channels or conditions)
- Ignore flaky tests (fix or remove them)
- Mock everything (prefer integration tests when possible)
- Skip error path testing
## Integration with CI/CD
```yaml
# GitHub Actions example
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Run tests
run: go test -race -coverprofile=coverage.out ./...
- name: Check coverage
run: |
go tool cover -func=coverage.out | grep total | awk '{print $3}' | \
awk -F'%' '{if ($1 < 80) exit 1}'
```
**Remember**: Tests are documentation. They show how your code is meant to be used. Write them clearly and keep them up to date.

View File

@@ -1,291 +0,0 @@
---
name: use-modern-go
description: Apply modern Go syntax guidelines based on project's Go version. Use when user ask for modern Go code guidelines.
---
# Modern Go Guidelines
## Detected Go Version
!`grep -rh "^go " --include="go.mod" . 2>/dev/null | cut -d' ' -f2 | sort | uniq -c | sort -nr | head -1 | xargs | cut -d' ' -f2 | grep . || echo unknown`
## How to Use This Skill
DO NOT search for go.mod files or try to detect the version yourself. Use ONLY the version shown above.
**If version detected (not "unknown"):**
- Say: "This project is using Go X.XX, so Ill stick to modern Go best practices and freely use language features up to and including this version. If youd prefer a different target version, just let me know."
- Do NOT list features, do NOT ask for confirmation
**If version is "unknown":**
- Say: "Could not detect Go version in this repository"
- Use AskUserQuestion: "Which Go version should I target?" → [1.23] / [1.24] / [1.25] / [1.26]
**When writing Go code**, use ALL features from this document up to the target version:
- Prefer modern built-ins and packages (`slices`, `maps`, `cmp`) over legacy patterns
- Never use features from newer Go versions than the target
- Never use outdated patterns when a modern alternative is available
---
## Features by Go Version
### Go 1.0+
- `time.Since`: `time.Since(start)` instead of `time.Now().Sub(start)`
### Go 1.8+
- `time.Until`: `time.Until(deadline)` instead of `deadline.Sub(time.Now())`
### Go 1.13+
- `errors.Is`: `errors.Is(err, target)` instead of `err == target` (works with wrapped errors)
### Go 1.18+
- `any`: Use `any` instead of `interface{}`
- `bytes.Cut`: `before, after, found := bytes.Cut(b, sep)` instead of Index+slice
- `strings.Cut`: `before, after, found := strings.Cut(s, sep)`
### Go 1.19+
- `fmt.Appendf`: `buf = fmt.Appendf(buf, "x=%d", x)` instead of `[]byte(fmt.Sprintf(...))`
- `atomic.Bool`/`atomic.Int64`/`atomic.Pointer[T]`: Type-safe atomics instead of `atomic.StoreInt32`
```go
var flag atomic.Bool
flag.Store(true)
if flag.Load() { ... }
var ptr atomic.Pointer[Config]
ptr.Store(cfg)
```
### Go 1.20+
- `strings.Clone`: `strings.Clone(s)` to copy string without sharing memory
- `bytes.Clone`: `bytes.Clone(b)` to copy byte slice
- `strings.CutPrefix/CutSuffix`: `if rest, ok := strings.CutPrefix(s, "pre:"); ok { ... }`
- `errors.Join`: `errors.Join(err1, err2)` to combine multiple errors
- `context.WithCancelCause`: `ctx, cancel := context.WithCancelCause(parent)` then `cancel(err)`
- `context.Cause`: `context.Cause(ctx)` to get the error that caused cancellation
### Go 1.21+
**Built-ins:**
- `min`/`max`: `max(a, b)` instead of if/else comparisons
- `clear`: `clear(m)` to delete all map entries, `clear(s)` to zero slice elements
**slices package:**
- `slices.Contains`: `slices.Contains(items, x)` instead of manual loops
- `slices.Index`: `slices.Index(items, x)` returns index (-1 if not found)
- `slices.IndexFunc`: `slices.IndexFunc(items, func(item T) bool { return item.ID == id })`
- `slices.SortFunc`: `slices.SortFunc(items, func(a, b T) int { return cmp.Compare(a.X, b.X) })`
- `slices.Sort`: `slices.Sort(items)` for ordered types
- `slices.Max`/`slices.Min`: `slices.Max(items)` instead of manual loop
- `slices.Reverse`: `slices.Reverse(items)` instead of manual swap loop
- `slices.Compact`: `slices.Compact(items)` removes consecutive duplicates in-place
- `slices.Clip`: `slices.Clip(s)` removes unused capacity
- `slices.Clone`: `slices.Clone(s)` creates a copy
**maps package:**
- `maps.Clone`: `maps.Clone(m)` instead of manual map iteration
- `maps.Copy`: `maps.Copy(dst, src)` copies entries from src to dst
- `maps.DeleteFunc`: `maps.DeleteFunc(m, func(k K, v V) bool { return condition })`
**sync package:**
- `sync.OnceFunc`: `f := sync.OnceFunc(func() { ... })` instead of `sync.Once` + wrapper
- `sync.OnceValue`: `getter := sync.OnceValue(func() T { return computeValue() })`
**context package:**
- `context.AfterFunc`: `stop := context.AfterFunc(ctx, cleanup)` runs cleanup on cancellation
- `context.WithTimeoutCause`: `ctx, cancel := context.WithTimeoutCause(parent, d, err)`
- `context.WithDeadlineCause`: Similar with deadline instead of duration
### Go 1.22+
**Loops:**
- `for i := range n`: `for i := range len(items)` instead of `for i := 0; i < len(items); i++`
- Loop variables are now safe to capture in goroutines (each iteration has its own copy)
**cmp package:**
- `cmp.Or`: `cmp.Or(flag, env, config, "default")` returns first non-zero value
```go
// Instead of:
name := os.Getenv("NAME")
if name == "" {
name = "default"
}
// Use:
name := cmp.Or(os.Getenv("NAME"), "default")
```
**reflect package:**
- `reflect.TypeFor`: `reflect.TypeFor[T]()` instead of `reflect.TypeOf((*T)(nil)).Elem()`
**net/http:**
- Enhanced `http.ServeMux` patterns: `mux.HandleFunc("GET /api/{id}", handler)` with method and path params
- `r.PathValue("id")` to get path parameters
### Go 1.23+
- `maps.Keys(m)` / `maps.Values(m)` return iterators
- `slices.Collect(iter)` not manual loop to build slice from iterator
- `slices.Sorted(iter)` to collect and sort in one step
```go
keys := slices.Collect(maps.Keys(m)) // not: for k := range m { keys = append(keys, k) }
sortedKeys := slices.Sorted(maps.Keys(m)) // collect + sort
for k := range maps.Keys(m) { process(k) } // iterate directly
```
**time package**
- `time.Tick`: Use `time.Tick` freely — as of Go 1.23, the garbage collector can recover unreferenced tickers, even if they haven't been stopped. The Stop method is no longer necessary to help the garbage collector. There is no longer any reason to prefer NewTicker when Tick will do.
### Go 1.24+
- `t.Context()` not `context.WithCancel(context.Background())` in tests.
ALWAYS use t.Context() when a test function needs a context.
Before:
```go
func TestFoo(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
result := doSomething(ctx)
}
```
After:
```go
func TestFoo(t *testing.T) {
ctx := t.Context()
result := doSomething(ctx)
}
```
- `omitzero` not `omitempty` in JSON struct tags.
ALWAYS use omitzero for time.Duration, time.Time, structs, slices, maps.
Before:
```go
type Config struct {
Timeout time.Duration `json:"timeout,omitempty"` // doesn't work for Duration!
}
```
After:
```go
type Config struct {
Timeout time.Duration `json:"timeout,omitzero"`
}
```
- `b.Loop()` not `for i := 0; i < b.N; i++` in benchmarks.
ALWAYS use b.Loop() for the main loop in benchmark functions.
Before:
```go
func BenchmarkFoo(b *testing.B) {
for i := 0; i < b.N; i++ {
doWork()
}
}
```
After:
```go
func BenchmarkFoo(b *testing.B) {
for b.Loop() {
doWork()
}
}
```
- `strings.SplitSeq` not `strings.Split` when iterating.
ALWAYS use SplitSeq/FieldsSeq when iterating over split results in a for-range loop.
Before:
```go
for _, part := range strings.Split(s, ",") {
process(part)
}
```
After:
```go
for part := range strings.SplitSeq(s, ",") {
process(part)
}
```
Also: `strings.FieldsSeq`, `bytes.SplitSeq`, `bytes.FieldsSeq`.
### Go 1.25+
- `wg.Go(fn)` not `wg.Add(1)` + `go func() { defer wg.Done(); ... }()`.
ALWAYS use wg.Go() when spawning goroutines with sync.WaitGroup.
Before:
```go
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func() {
defer wg.Done()
process(item)
}()
}
wg.Wait()
```
After:
```go
var wg sync.WaitGroup
for _, item := range items {
wg.Go(func() {
process(item)
})
}
wg.Wait()
```
### Go 1.26+
- `new(val)` not `x := val; &x` — returns pointer to any value.
Go 1.26 extends new() to accept expressions, not just types.
Type is inferred: new(0) → *int, new("s") → *string, new(T{}) → *T.
DO NOT use `x := val; &x` pattern — always use new(val) directly.
DO NOT use redundant casts like new(int(0)) — just write new(0).
Common use case: struct fields with pointer types.
Before:
```go
timeout := 30
debug := true
cfg := Config{
Timeout: &timeout,
Debug: &debug,
}
```
After:
```go
cfg := Config{
Timeout: new(30), // *int
Debug: new(true), // *bool
}
```
- `errors.AsType[T](err)` not `errors.As(err, &target)`.
ALWAYS use errors.AsType when checking if error matches a specific type.
Before:
```go
var pathErr *os.PathError
if errors.As(err, &pathErr) {
handle(pathErr)
}
```
After:
```go
if pathErr, ok := errors.AsType[*os.PathError](err); ok {
handle(pathErr)
}
```

View File

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

View File

@@ -1,140 +0,0 @@
package main
import (
"errors"
"flag"
"fmt"
"log"
"go.uber.org/zap"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
// Config 数据库配置
type Config struct {
Host string
Port int
User string
Password string
DBName string
SSLMode string
}
type PostImage struct {
ID string `gorm:"type:varchar(36);primaryKey"`
PostID string `gorm:"type:varchar(36);index"`
URL string `gorm:"type:text"`
ThumbnailURL string `gorm:"type:text"`
PreviewURL string `gorm:"type:text"`
PreviewURLLarge string `gorm:"type:text"`
Width int `gorm:"default:0"`
Height int `gorm:"default:0"`
Size int64 `gorm:"default:0"`
MimeType string `gorm:"type:varchar(50)"`
SortOrder int `gorm:"default:0"`
}
func (PostImage) TableName() string {
return "post_images"
}
func main() {
// 解析命令行参数
host := flag.String("host", "localhost", "数据库主机")
port := flag.Int("port", 5432, "数据库端口")
user := flag.String("user", "postgres", "数据库用户")
password := flag.String("password", "", "数据库密码")
dbname := flag.String("dbname", "carrot_bbs", "数据库名称")
sslmode := flag.String("sslmode", "disable", "SSL 模式")
flag.Parse()
cfg := &Config{
Host: *host,
Port: *port,
User: *user,
Password: *password,
DBName: *dbname,
SSLMode: *sslmode,
}
// 连接数据库
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
// 测试连接
sqlDB, err := db.DB()
if err != nil {
log.Fatalf("Failed to get database instance: %v", err)
}
defer sqlDB.Close()
if err := sqlDB.Ping(); err != nil {
zap.L().Fatal("Failed to ping database", zap.Error(err))
}
zap.L().Info("Connected to database")
// 运行迁移
if err := runMigration(db); err != nil {
zap.L().Fatal("Migration failed", zap.Error(err))
}
zap.L().Info("Migration completed successfully")
}
func runMigration(db *gorm.DB) error {
// 检查字段是否已存在
columns, err := db.Migrator().ColumnTypes(&PostImage{})
if err != nil {
return fmt.Errorf("failed to check column existence: %w", err)
}
hasPreviewURL := false
hasPreviewURLLarge := false
for _, col := range columns {
if col.Name() == "preview_url" {
hasPreviewURL = true
}
if col.Name() == "preview_url_large" {
hasPreviewURLLarge = true
}
}
if hasPreviewURL && hasPreviewURLLarge {
zap.L().Info("Columns already exist, skipping migration")
return nil
}
// 添加字段
var errs []error
if !hasPreviewURL {
zap.L().Info("Adding preview_url column...")
if err := db.Exec("ALTER TABLE post_images ADD COLUMN preview_url TEXT").Error; err != nil {
errs = append(errs, fmt.Errorf("failed to add preview_url column: %w", err))
}
}
if !hasPreviewURLLarge {
zap.L().Info("Adding preview_url_large column...")
if err := db.Exec("ALTER TABLE post_images ADD COLUMN preview_url_large TEXT").Error; err != nil {
errs = append(errs, fmt.Errorf("failed to add preview_url_large column: %w", err))
}
}
if len(errs) > 0 {
return errors.Join(errs...)
}
zap.L().Info("Migration completed successfully")
return nil
}
// 注意:此脚本仅用于添加数据库字段
// 预览图的生成由后端的 upload_service 自动处理
// 现有图片需要重新上传才能生成预览图,或者编写额外的后台任务来生成

View File

@@ -1,14 +1,16 @@
package main
package main
import (
"context"
"fmt"
"net/http"
"time"
"carrot_bbs/internal/config"
"carrot_bbs/internal/grpc/runner"
"carrot_bbs/internal/router"
"carrot_bbs/internal/service"
"with_you/internal/config"
"with_you/internal/grpc/runner"
"with_you/internal/pkg/ws"
"with_you/internal/router"
"with_you/internal/service"
"go.uber.org/zap"
"gorm.io/gorm"
@@ -16,13 +18,19 @@ import (
// App 应用程序结构体
type App struct {
Config *config.Config
DB *gorm.DB
Router *router.Router
PushService service.PushService
GRPCServer *runner.Server
Server *http.Server
Logger *zap.Logger
Config *config.Config
DB *gorm.DB
Router *router.Router
PushService service.PushService
HotRankWorker *service.HotRankWorker
FileCleanupWorker *service.FileCleanupWorker
DeviceCleanupWorker *service.DeviceCleanupWorker
GRPCServer *runner.Server
Server *http.Server
Publisher ws.MessagePublisher
TaskDispatcher runner.TaskDispatcher
Logger *zap.Logger
PushWorker *service.PushWorker
}
// NewApp 创建应用程序
@@ -31,16 +39,28 @@ func NewApp(
db *gorm.DB,
r *router.Router,
pushService service.PushService,
hotRankWorker *service.HotRankWorker,
fileCleanupWorker *service.FileCleanupWorker,
deviceCleanupWorker *service.DeviceCleanupWorker,
grpcServer *runner.Server,
publisher ws.MessagePublisher,
taskDispatcher runner.TaskDispatcher,
logger *zap.Logger,
pushWorker *service.PushWorker,
) *App {
return &App{
Config: cfg,
DB: db,
Router: r,
PushService: pushService,
GRPCServer: grpcServer,
Logger: logger,
Config: cfg,
DB: db,
Router: r,
PushService: pushService,
HotRankWorker: hotRankWorker,
FileCleanupWorker: fileCleanupWorker,
DeviceCleanupWorker: deviceCleanupWorker,
GRPCServer: grpcServer,
Publisher: publisher,
TaskDispatcher: taskDispatcher,
Logger: logger,
PushWorker: pushWorker,
}
}
@@ -57,6 +77,26 @@ func (a *App) Start(ctx context.Context) error {
// 2. 启动推送 Worker
a.PushService.StartPushWorker(ctx)
// 2.1 热门榜定时重算
if a.HotRankWorker != nil {
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 服务器
addr := fmt.Sprintf("%s:%d", a.Config.Server.Host, a.Config.Server.Port)
a.Logger.Info("Starting HTTP server",
@@ -64,8 +104,12 @@ func (a *App) Start(ctx context.Context) error {
)
a.Server = &http.Server{
Addr: addr,
Handler: a.Router.Engine(),
Addr: addr,
Handler: a.Router.Engine(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
// 4. 启动 HTTP 服务器
@@ -84,7 +128,18 @@ func (a *App) Start(ctx context.Context) error {
func (a *App) Stop(ctx context.Context) error {
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 {
a.Logger.Info("Stopping HTTP server")
if err := a.Server.Shutdown(ctx); err != nil {
@@ -94,17 +149,46 @@ func (a *App) Stop(ctx context.Context) error {
}
}
// 2. 停止 gRPC 服务器
// 4. 停止 gRPC 服务器
if a.GRPCServer != nil {
a.Logger.Info("Stopping gRPC server")
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.PushService.StopPushWorker()
// 4. 关闭数据库连接
if a.HotRankWorker != nil {
a.Logger.Info("Stopping hot rank worker")
a.HotRankWorker.Stop()
}
if a.FileCleanupWorker != nil {
a.Logger.Info("Stopping file cleanup worker")
a.FileCleanupWorker.Stop()
}
if a.DeviceCleanupWorker != nil {
a.Logger.Info("Stopping device cleanup worker")
a.DeviceCleanupWorker.Stop()
}
// 5.1 停止 PushWorker
if a.PushWorker != nil {
a.Logger.Info("Stopping push worker (stream)")
a.PushWorker.Stop()
}
// 6. 关闭数据库连接
a.Logger.Info("Closing database connection")
sqlDB, err := a.DB.DB()
if err == nil {

View File

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

View File

@@ -4,12 +4,15 @@
package main
import (
"carrot_bbs/internal/handler"
"carrot_bbs/internal/router"
"carrot_bbs/internal/service"
appwire "carrot_bbs/internal/wire"
"github.com/google/wire"
"with_you/internal/cache"
"with_you/internal/handler"
"with_you/internal/middleware"
"with_you/internal/repository"
"with_you/internal/router"
"with_you/internal/service"
appwire "with_you/internal/wire"
)
// InitializeApp 初始化应用程序
@@ -24,20 +27,27 @@ func InitializeApp() (*App, error) {
// ProvideRouter 提供路由
func ProvideRouter(
userRepo repository.UserRepository,
userHandler *handler.UserHandler,
postHandler *handler.PostHandler,
commentHandler *handler.CommentHandler,
messageHandler *handler.MessageHandler,
notificationHandler *handler.NotificationHandler,
uploadHandler *handler.UploadHandler,
jwtService *service.JWTService,
jwtService service.JWTService,
sessionService service.SessionService,
identityProvider middleware.IdentityProvider,
cache cache.Cache,
pushHandler *handler.PushHandler,
systemMessageHandler *handler.SystemMessageHandler,
groupHandler *handler.GroupHandler,
stickerHandler *handler.StickerHandler,
gorseHandler *handler.GorseHandler,
voteHandler *handler.VoteHandler,
channelHandler *handler.ChannelHandler,
scheduleHandler *handler.ScheduleHandler,
gradeHandler *handler.GradeHandler,
examHandler *handler.ExamHandler,
emptyClassroomHandler *handler.EmptyClassroomHandler,
roleHandler *handler.RoleHandler,
adminUserHandler *handler.AdminUserHandler,
adminPostHandler *handler.AdminPostHandler,
@@ -45,34 +55,67 @@ func ProvideRouter(
adminGroupHandler *handler.AdminGroupHandler,
adminDashboardHandler *handler.AdminDashboardHandler,
adminLogHandler *handler.AdminLogHandler,
qrcodeHandler *handler.QRCodeHandler,
materialHandler *handler.MaterialHandler,
callHandler *handler.CallHandler,
reportHandler *handler.ReportHandler,
adminReportHandler *handler.AdminReportHandler,
verificationHandler *handler.VerificationHandler,
adminVerificationHandler *handler.AdminVerificationHandler,
adminProfileAuditHandler *handler.AdminProfileAuditHandler,
setupHandler *handler.SetupHandler,
tradeHandler *handler.TradeHandler,
logService *service.LogService,
activityService service.UserActivityService,
casbinService service.CasbinService,
userService service.UserService,
wsHandler *handler.WSHandler,
liveKitHandler *handler.LiveKitHandler,
) *router.Router {
return router.New(
userHandler,
postHandler,
commentHandler,
messageHandler,
notificationHandler,
uploadHandler,
jwtService,
pushHandler,
systemMessageHandler,
groupHandler,
stickerHandler,
gorseHandler,
voteHandler,
scheduleHandler,
roleHandler,
adminUserHandler,
adminPostHandler,
adminCommentHandler,
adminGroupHandler,
adminDashboardHandler,
adminLogHandler,
logService,
activityService,
casbinService,
)
return router.New(router.RouterDeps{
UserRepo: userRepo,
UserHandler: userHandler,
PostHandler: postHandler,
CommentHandler: commentHandler,
MessageHandler: messageHandler,
NotificationHandler: notificationHandler,
UploadHandler: uploadHandler,
JWTService: jwtService,
SessionService: sessionService,
IdentityProvider: identityProvider,
Cache: cache,
PushHandler: pushHandler,
SystemMessageHandler: systemMessageHandler,
GroupHandler: groupHandler,
StickerHandler: stickerHandler,
VoteHandler: voteHandler,
ChannelHandler: channelHandler,
ScheduleHandler: scheduleHandler,
GradeHandler: gradeHandler,
ExamHandler: examHandler,
EmptyClassroomHandler: emptyClassroomHandler,
RoleHandler: roleHandler,
AdminUserHandler: adminUserHandler,
AdminPostHandler: adminPostHandler,
AdminCommentHandler: adminCommentHandler,
AdminGroupHandler: adminGroupHandler,
AdminDashboardHandler: adminDashboardHandler,
AdminLogHandler: adminLogHandler,
QRCodeHandler: qrcodeHandler,
MaterialHandler: materialHandler,
CallHandler: callHandler,
LiveKitHandler: liveKitHandler,
ReportHandler: reportHandler,
AdminReportHandler: adminReportHandler,
VerificationHandler: verificationHandler,
AdminVerificationHandler: adminVerificationHandler,
AdminProfileAuditHandler: adminProfileAuditHandler,
SetupHandler: setupHandler,
TradeHandler: tradeHandler,
LogService: logService,
ActivityService: activityService,
CasbinService: casbinService,
UserService: userService,
WSHandler: wsHandler,
})
}

View File

@@ -7,11 +7,13 @@
package main
import (
"carrot_bbs/internal/handler"
"carrot_bbs/internal/repository"
"carrot_bbs/internal/router"
"carrot_bbs/internal/service"
"carrot_bbs/internal/wire"
"with_you/internal/cache"
"with_you/internal/handler"
"with_you/internal/middleware"
"with_you/internal/repository"
"with_you/internal/router"
"with_you/internal/service"
"with_you/internal/wire"
)
// Injectors from wire.go:
@@ -31,85 +33,148 @@ func InitializeApp() (*App, error) {
pushRecordRepository := repository.NewPushRecordRepository(db)
deviceTokenRepository := repository.NewDeviceTokenRepository(db)
messageRepository := repository.NewMessageRepository(db)
hub := wire.ProvideSSEHub()
pushService := wire.ProvidePushService(pushRecordRepository, deviceTokenRepository, messageRepository, hub)
postRepository := repository.NewPostRepository(db)
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)
systemMessageService := wire.ProvideSystemMessageService(systemNotificationRepository, pushService, userRepository, postRepository, cache)
emailClient := wire.ProvideEmailClient(config)
emailService := wire.ProvideEmailService(emailClient)
userService := wire.ProvideUserService(userRepository, systemMessageService, emailService, cache)
userActivityRepository := wire.ProvideUserActivityRepository(db, cache)
userActivityService := wire.ProvideUserActivityService(userActivityRepository)
gorseClient := wire.ProvideGorseClient(config)
openaiClient := wire.ProvideOpenAIClient(config)
postAIService := wire.ProvidePostAIService(openaiClient)
transactionManager := wire.ProvideTransactionManager(db)
postService := wire.ProvidePostService(postRepository, systemMessageService, gorseClient, postAIService, cache, transactionManager)
commentRepository := repository.NewCommentRepository(db)
commentService := wire.ProvideCommentService(commentRepository, postRepository, systemMessageService, gorseClient, postAIService, cache)
operationLogRepository := repository.NewOperationLogRepository(db)
loginLogRepository := repository.NewLoginLogRepository(db)
dataChangeLogRepository := repository.NewDataChangeLogRepository(db)
logger := wire.ProvideLogger()
asyncLogManager := wire.ProvideAsyncLogManager(db, operationLogRepository, loginLogRepository, dataChangeLogRepository, logger, config)
asyncLogManager := wire.ProvideAsyncLogManager(operationLogRepository, loginLogRepository, dataChangeLogRepository, logger, config)
operationLogService := wire.ProvideOperationLogService(asyncLogManager, operationLogRepository)
loginLogService := wire.ProvideLoginLogService(asyncLogManager, loginLogRepository)
dataChangeLogService := wire.ProvideDataChangeLogService(asyncLogManager, dataChangeLogRepository)
logService := wire.ProvideLogService(operationLogService, loginLogService, dataChangeLogService)
userHandler := wire.ProvideUserHandler(userService, userActivityService, postService, commentService, logService)
postHandler := wire.ProvidePostHandler(postService, userService, 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)
userActivityService := wire.ProvideUserActivityService(userActivityRepository)
userProfileAuditRepository := repository.NewUserProfileAuditRepository(db)
manager := wire.ProvideHookManager()
openaiClient := wire.ProvideOpenAIClient(config)
tencentClient := wire.ProvideTencentClient(config)
postAIService := wire.ProvidePostAIService(openaiClient, tencentClient, config)
commentRepository := repository.NewCommentRepository(db)
sensitiveWordRepository := repository.NewSensitiveWordRepository(db)
sensitiveService := wire.ProvideSensitiveService(sensitiveWordRepository, client, config)
moderationHooks := wire.ProvideModerationHooks(manager, postAIService, postRepository, commentRepository, userRepository, sensitiveService, config)
userProfileAuditService := wire.ProvideUserProfileAuditService(userProfileAuditRepository, userRepository, manager, moderationHooks)
userHandler := wire.ProvideUserHandler(userService, userActivityService, logService, userProfileAuditService)
transactionManager := wire.ProvideTransactionManager(db)
builtinHooks := wire.ProvideBuiltinHooks(manager)
postService := wire.ProvidePostService(postRepository, systemMessageService, postAIService, cache, transactionManager, manager, moderationHooks, builtinHooks, logService)
postRefRepository := repository.NewPostRefRepository(db)
postRefService := wire.ProvidePostRefService(postRefRepository, postRepository)
channelRepository := repository.NewChannelRepository(db)
channelService := wire.ProvideChannelService(channelRepository, cache)
postHandler := handler.NewPostHandler(postService, postRefService, userService, channelService)
commentService := wire.ProvideCommentService(commentRepository, postRepository, systemMessageService, postAIService, cache, manager, logService)
commentHandler := handler.NewCommentHandler(commentService)
chatService := wire.ProvideChatService(db, messageRepository, userRepository, hub, cache)
messageService := wire.ProvideMessageService(db, messageRepository, cache)
groupRepository := repository.NewGroupRepository(db)
groupService := wire.ProvideGroupService(db, groupRepository, userRepository, messageRepository, hub, cache)
messageHandler := wire.ProvideMessageHandler(chatService, messageService, userService, groupService, hub)
notificationRepository := repository.NewNotificationRepository(db)
notificationService := wire.ProvideNotificationService(notificationRepository, cache)
notificationHandler := handler.NewNotificationHandler(notificationService)
s3Client, err := wire.ProvideS3Client(config)
if err != nil {
return nil, err
}
uploadService := wire.ProvideUploadService(s3Client, userService)
uploadedFileRepository := repository.NewUploadedFileRepository(db)
uploadService := wire.ProvideUploadService(s3Client, userService, uploadedFileRepository)
seqBufferManager := wire.ProvideSeqBufferManager(config, client, cache)
pushWorker := wire.ProvidePushWorker(config, client, messagePublisher, pushService, messageRepository, userRepository)
conversationVersionLogRepository := repository.NewConversationVersionLogRepository(db)
groupRepository := repository.NewGroupRepository(db)
groupJoinRequestRepository := repository.NewGroupJoinRequestRepository(db)
groupService := wire.ProvideGroupService(groupRepository, userRepository, messageRepository, groupJoinRequestRepository, systemNotificationRepository, messagePublisher, cache)
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)
notificationService := wire.ProvideNotificationService(notificationRepository, cache)
notificationHandler := handler.NewNotificationHandler(notificationService)
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)
systemMessageHandler := wire.ProvideSystemMessageHandler(systemMessageService, systemNotificationRepository, cache)
systemMessageHandler := wire.ProvideSystemMessageHandler(systemMessageService)
groupHandler := wire.ProvideGroupHandler(groupService, userService)
stickerRepository := repository.NewStickerRepository(db)
stickerService := wire.ProvideStickerService(stickerRepository)
stickerHandler := handler.NewStickerHandler(stickerService)
gorseHandler := wire.ProvideGorseHandler(config, db)
voteRepository := repository.NewVoteRepository(db)
voteService := wire.ProvideVoteService(voteRepository, postRepository, cache, postAIService, systemMessageService)
voteService := wire.ProvideVoteService(voteRepository, postRepository, cache, postAIService, systemMessageService, manager, logService)
voteHandler := handler.NewVoteHandler(voteService, postService)
channelHandler := handler.NewChannelHandler(channelService)
scheduleRepository := repository.NewScheduleRepository(db)
scheduleService := wire.ProvideScheduleService(scheduleRepository)
server := wire.ProvideGRPCServer(config, logger)
taskManager := wire.ProvideTaskManager(server)
runnerHub := wire.ProvideRunnerHub(logger)
taskManager := wire.ProvideTaskManager(runnerHub, logger)
scheduleSyncService := wire.ProvideScheduleSyncService(taskManager, scheduleRepository, logger)
scheduleHandler := wire.ProvideScheduleHandler(scheduleService, scheduleSyncService)
roleRepository := wire.ProvideRoleRepository(db)
enforcer := wire.ProvideCasbinEnforcer(config, db)
casbinService := wire.ProvideCasbinService(enforcer, cache, roleRepository, config)
gradeRepository := repository.NewGradeRepository(db)
gradeSyncService := wire.ProvideGradeSyncService(taskManager, gradeRepository, logger)
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)
roleHandler := handler.NewRoleHandler(roleService)
adminUserService := wire.ProvideAdminUserService(userRepository)
adminUserHandler := handler.NewAdminUserHandler(adminUserService)
adminPostService := wire.ProvideAdminPostService(postRepository, cache)
adminUserService := wire.ProvideAdminUserService(userRepository, sessionService, cache)
adminUserHandler := handler.NewAdminUserHandler(adminUserService, pushService)
adminPostService := wire.ProvideAdminPostService(postRepository, cache, logService)
adminPostHandler := handler.NewAdminPostHandler(adminPostService)
adminCommentService := wire.ProvideAdminCommentService(commentRepository, postRepository)
adminCommentService := wire.ProvideAdminCommentService(commentRepository, postRepository, cache)
adminCommentHandler := handler.NewAdminCommentHandler(adminCommentService)
adminGroupService := wire.ProvideAdminGroupService(groupRepository, userRepository)
adminGroupHandler := handler.NewAdminGroupHandler(adminGroupService)
adminDashboardService := wire.ProvideAdminDashboardService(db, userRepository, postRepository, commentRepository, groupRepository, userActivityRepository)
adminDashboardHandler := handler.NewAdminDashboardHandler(adminDashboardService)
adminLogHandler := handler.NewAdminLogHandler(operationLogService, loginLogService, dataChangeLogService)
router := ProvideRouter(userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, pushHandler, systemMessageHandler, groupHandler, stickerHandler, gorseHandler, voteHandler, scheduleHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, logService, userActivityService, casbinService)
app := NewApp(config, db, router, pushService, server, logger)
qrCodeLoginService := wire.ProvideQRCodeLoginService(client, messagePublisher, jwtService, userService, userActivityService, logService)
qrCodeHandler := handler.NewQRCodeHandler(qrCodeLoginService)
materialSubjectRepository := repository.NewMaterialSubjectRepository(db)
materialFileRepository := repository.NewMaterialFileRepository(db)
materialService := wire.ProvideMaterialService(materialSubjectRepository, materialFileRepository)
materialHandler := handler.NewMaterialHandler(materialService)
callRepository := repository.NewCallRepository(db)
callService := wire.ProvideCallService(callRepository, messagePublisher, config, db, client, groupService)
callHandler := handler.NewCallHandler(callService)
reportRepository := repository.NewReportRepository(db)
reportService := wire.ProvideReportService(reportRepository, postRepository, commentRepository, messageRepository, userRepository, systemNotificationRepository, pushService, transactionManager, operationLogService, config)
reportHandler := handler.NewReportHandler(reportService)
adminReportService := wire.ProvideAdminReportService(reportRepository, postRepository, commentRepository, messageRepository, userRepository, systemNotificationRepository, pushService, transactionManager, operationLogService)
adminReportHandler := handler.NewAdminReportHandler(adminReportService)
verificationRepository := repository.NewVerificationRepository(db)
verificationService := wire.ProvideVerificationService(verificationRepository, userRepository)
verificationHandler := handler.NewVerificationHandler(verificationService)
adminVerificationService := wire.ProvideAdminVerificationService(verificationRepository, userRepository)
adminVerificationHandler := wire.ProvideAdminVerificationHandler(adminVerificationService, userService)
adminProfileAuditHandler := handler.NewAdminProfileAuditHandler(userProfileAuditService)
setupService := wire.ProvideSetupService(userRepository, roleRepository, casbinService, config)
setupHandler := wire.ProvideSetupHandler(setupService)
tradeRepository := repository.NewTradeRepository(db)
tradeService := wire.ProvideTradeService(tradeRepository)
tradeHandler := handler.NewTradeHandler(tradeService)
wsHandler := wire.ProvideWSHandler(messagePublisher, chatService, groupService, jwtService, callService, userService)
liveKitService := wire.ProvideLiveKitService(config, logger)
liveKitHandler := wire.ProvideLiveKitHandler(liveKitService, callService, config, logger)
router := ProvideRouter(userRepository, userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, sessionService, identityProvider, cache, pushHandler, systemMessageHandler, groupHandler, stickerHandler, voteHandler, channelHandler, scheduleHandler, gradeHandler, examHandler, emptyClassroomHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, qrCodeHandler, materialHandler, callHandler, reportHandler, adminReportHandler, verificationHandler, adminVerificationHandler, adminProfileAuditHandler, setupHandler, tradeHandler, logService, userActivityService, casbinService, userService, wsHandler, liveKitHandler)
hotRankWorker := wire.ProvideHotRankWorker(config, postRepository, channelRepository, cache, client)
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
}
@@ -117,20 +182,27 @@ func InitializeApp() (*App, error) {
// ProvideRouter 提供路由
func ProvideRouter(
userRepo repository.UserRepository,
userHandler *handler.UserHandler,
postHandler *handler.PostHandler,
commentHandler *handler.CommentHandler,
messageHandler *handler.MessageHandler,
notificationHandler *handler.NotificationHandler,
uploadHandler *handler.UploadHandler,
jwtService *service.JWTService,
jwtService service.JWTService,
sessionService service.SessionService,
identityProvider middleware.IdentityProvider, cache2 cache.Cache,
pushHandler *handler.PushHandler,
systemMessageHandler *handler.SystemMessageHandler,
groupHandler *handler.GroupHandler,
stickerHandler *handler.StickerHandler,
gorseHandler *handler.GorseHandler,
voteHandler *handler.VoteHandler,
channelHandler *handler.ChannelHandler,
scheduleHandler *handler.ScheduleHandler,
gradeHandler *handler.GradeHandler,
examHandler *handler.ExamHandler,
emptyClassroomHandler *handler.EmptyClassroomHandler,
roleHandler *handler.RoleHandler,
adminUserHandler *handler.AdminUserHandler,
adminPostHandler *handler.AdminPostHandler,
@@ -138,34 +210,67 @@ func ProvideRouter(
adminGroupHandler *handler.AdminGroupHandler,
adminDashboardHandler *handler.AdminDashboardHandler,
adminLogHandler *handler.AdminLogHandler,
qrcodeHandler *handler.QRCodeHandler,
materialHandler *handler.MaterialHandler,
callHandler *handler.CallHandler,
reportHandler *handler.ReportHandler,
adminReportHandler *handler.AdminReportHandler,
verificationHandler *handler.VerificationHandler,
adminVerificationHandler *handler.AdminVerificationHandler,
adminProfileAuditHandler *handler.AdminProfileAuditHandler,
setupHandler *handler.SetupHandler,
tradeHandler *handler.TradeHandler,
logService *service.LogService,
activityService service.UserActivityService,
casbinService service.CasbinService,
userService service.UserService,
wsHandler *handler.WSHandler,
liveKitHandler *handler.LiveKitHandler,
) *router.Router {
return router.New(
userHandler,
postHandler,
commentHandler,
messageHandler,
notificationHandler,
uploadHandler,
jwtService,
pushHandler,
systemMessageHandler,
groupHandler,
stickerHandler,
gorseHandler,
voteHandler,
scheduleHandler,
roleHandler,
adminUserHandler,
adminPostHandler,
adminCommentHandler,
adminGroupHandler,
adminDashboardHandler,
adminLogHandler,
logService,
activityService,
casbinService,
)
return router.New(router.RouterDeps{
UserRepo: userRepo,
UserHandler: userHandler,
PostHandler: postHandler,
CommentHandler: commentHandler,
MessageHandler: messageHandler,
NotificationHandler: notificationHandler,
UploadHandler: uploadHandler,
JWTService: jwtService,
SessionService: sessionService,
IdentityProvider: identityProvider,
Cache: cache2,
PushHandler: pushHandler,
SystemMessageHandler: systemMessageHandler,
GroupHandler: groupHandler,
StickerHandler: stickerHandler,
VoteHandler: voteHandler,
ChannelHandler: channelHandler,
ScheduleHandler: scheduleHandler,
GradeHandler: gradeHandler,
ExamHandler: examHandler,
EmptyClassroomHandler: emptyClassroomHandler,
RoleHandler: roleHandler,
AdminUserHandler: adminUserHandler,
AdminPostHandler: adminPostHandler,
AdminCommentHandler: adminCommentHandler,
AdminGroupHandler: adminGroupHandler,
AdminDashboardHandler: adminDashboardHandler,
AdminLogHandler: adminLogHandler,
QRCodeHandler: qrcodeHandler,
MaterialHandler: materialHandler,
CallHandler: callHandler,
LiveKitHandler: liveKitHandler,
ReportHandler: reportHandler,
AdminReportHandler: adminReportHandler,
VerificationHandler: verificationHandler,
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]
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]
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

@@ -1,4 +1,4 @@
# 服务器配置
# 服务器配置
# 环境变量: APP_SERVER_HOST, APP_SERVER_PORT, APP_SERVER_MODE
server:
host: 0.0.0.0
@@ -13,14 +13,38 @@ server:
database:
type: sqlite # sqlite 或 postgres
sqlite:
path: ./data/carrot_bbs.db
path: ./data/with_you.db
postgres:
host: localhost
port: 5432
user: postgres
password: postgres
dbname: carrot_bbs
dbname: with_you
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_open_conns: 100
log_level: warn
@@ -64,6 +88,15 @@ cache:
unread_count_ttl: 30
group_members_ttl: 120
# 热门榜:定时在「候选池」上重算,只写入 Redis ZSETTopN+ top_ids供本地缓存不写数据库
hot_rank:
enabled: true
refresh_interval_seconds: 300
top_n: 100
recent_window_hours: 168 # 7 天内新帖进入候选
recent_fetch_limit: 500
candidate_cap: 800 # 上届 Top + 新帖 ID 合并后上限
# S3对象存储配置
# 环境变量: APP_S3_ENDPOINT, APP_S3_ACCESS_KEY, APP_S3_SECRET_KEY, APP_S3_BUCKET, APP_S3_DOMAIN
s3:
@@ -110,7 +143,7 @@ log:
- bank_card
rotation:
max_size: 100
max_age: 30
max_age: 180
max_backups: 10
compress: true
@@ -126,6 +159,14 @@ upload:
- image/gif
- 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:
enabled: true
@@ -153,18 +194,6 @@ audit:
baidu_api_key: ""
baidu_secret_key: ""
# Gorse推荐系统配置
# 环境变量: APP_GORSE_ADDRESS, APP_GORSE_API_KEY, APP_GORSE_DASHBOARD, APP_GORSE_IMPORT_PASSWORD
gorse:
enabled: true
address: "http://111.170.19.33:8088" # Gorse server地址
api_key: "" # API密钥
dashboard: "" # Gorse dashboard地址
import_password: "lanyimin123" # 导入数据密码
embedding_api_key: "sk-ZPN5NMPSqEaOGCPfD2LqndZ5Wwmw3DC4CQgzgKhM35fI3RpD"
embedding_url: "https://api.littlelan.cn/v1/embeddings"
embedding_model: "BAAI/bge-m3"
# OpenAI兼容接口配置用于帖子审核支持图文
# 环境变量:
# APP_OPENAI_ENABLED, APP_OPENAI_BASE_URL, APP_OPENAI_API_KEY
@@ -179,6 +208,19 @@ openai:
request_timeout: 30
strict_moderation: false
# 腾讯云文本内容安全配置(作为 OpenAI 备用)
# 当 OpenAI 不可用或审核失败时使用腾讯TMS进行文本审核
# 环境变量:
# APP_TENCENT_TMS_ENABLED, APP_TENCENT_TMS_SECRET_ID, APP_TENCENT_TMS_SECRET_KEY
# APP_TENCENT_TMS_REGION, APP_TENCENT_TMS_BIZ_TYPE, APP_TENCENT_TMS_TIMEOUT
tencent_tms:
enabled: false
secret_id: ""
secret_key: ""
region: "ap-guangzhou"
biz_type: "" # 可选,使用控制台配置的自定义策略
timeout: 30
# SMTP发信配置gomail.v2
# 环境变量:
# APP_EMAIL_ENABLED, APP_EMAIL_HOST, APP_EMAIL_PORT
@@ -192,7 +234,7 @@ email:
username: "no-reply@qczlit.cn"
password: "HbvwwVjRyiWg9gsK"
from_address: "no-reply@qczlit.cn"
from_name: "Carrot BBS"
from_name: "WithYou"
use_tls: true
insecure_skip_verify: false
timeout: 15
@@ -247,3 +289,124 @@ casbin:
- /api/v1/auth/password/reset
- /api/v1/auth/refresh
- /api/v1/admin/setup-super-admin
# 举报配置
# 环境变量: APP_REPORT_AUTO_HIDE_THRESHOLD
report:
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
# 设置后可使用 POST /api/v1/admin/setup-super-admin 接口初始化第一位超级管理员
# 该接口只能使用一次,一旦系统中存在超级管理员,此接口将永久拒绝请求
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
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:
build:
context: .
@@ -76,3 +128,5 @@ volumes:
# postgres_data:
# redis_data:
minio_data:
livekit_redis_data:
livekit_pg_data:

79
go.mod
View File

@@ -1,51 +1,61 @@
module carrot_bbs
module with_you
go 1.25.7
go 1.26
require (
github.com/alicebob/miniredis/v2 v2.37.0
github.com/casbin/casbin/v3 v3.10.0
github.com/casbin/gorm-adapter/v3 v3.41.0
github.com/cespare/xxhash/v2 v2.3.0
github.com/disintegration/imaging v1.6.2
github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/google/wire v0.7.0
github.com/gorse-io/gorse-go v0.5.0-alpha.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/redis/go-redis/v9 v9.18.0
github.com/spf13/viper v1.21.0
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.49.0
golang.org/x/image v0.37.0
golang.org/x/image v0.38.0
google.golang.org/grpc v1.79.3
google.golang.org/protobuf v1.36.11
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
gorm.io/plugin/dbresolver v1.6.2
)
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
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/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // 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/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/frostbyte73/core v0.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gammazero/deque v1.1.0 // indirect
github.com/gin-contrib/sse v1.1.1 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/glebarez/sqlite v1.11.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-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
@@ -55,17 +65,22 @@ require (
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // 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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jackc/pgx/v5 v5.9.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/jxskiss/base62 v1.1.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/crc32 v1.3.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-sqlite3 v1.14.37 // indirect
github.com/microsoft/go-mssqldb v1.9.8 // indirect
@@ -73,46 +88,72 @@ require (
github.com/minio/md5-simd v1.1.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // 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/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/opencontainers/runc v1.1.14 // indirect
github.com/pelletier/go-toml/v2 v2.3.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/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // 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/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/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/zeebo/xxh3 v1.0.2 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel v1.42.0 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/otel/trace 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/multierr v1.11.0 // indirect
go.uber.org/zap/exp v0.3.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // 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/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.43.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // 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
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/sqlserver v1.6.3 // indirect
gorm.io/plugin/dbresolver v1.6.2 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.47.0 // indirect
modernc.org/sqlite v1.48.0 // indirect
)

179
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/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
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.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/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.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/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/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.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
@@ -37,8 +57,8 @@ github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/casbin/casbin/v3 v3.10.0 h1:039ORla55vCeIZWd0LfzWFt1yiEA5X4W41xBW2bQuHs=
github.com/casbin/casbin/v3 v3.10.0/go.mod h1:5rJbQr2e6AuuDDNxnPc5lQlC9nIgg6nS1zYwKXhpHC8=
github.com/casbin/gorm-adapter/v3 v3.41.0 h1:Xhpi0tfRP9aKPDWDf6dgBxHZ9UM6IophxxPIEGWqCNM=
@@ -46,32 +66,48 @@ 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.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
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/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
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/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/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/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
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/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/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/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
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/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/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
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/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
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/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/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
@@ -80,7 +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/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
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/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -101,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-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
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.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
@@ -112,12 +151,19 @@ 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/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
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/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/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/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -125,8 +171,8 @@ github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorse-io/gorse-go v0.5.0-alpha.3 h1:GR/OWzq016VyFyTTxgQWeayGahRVzB1cGFIW/AaShC4=
github.com/gorse-io/gorse-go v0.5.0-alpha.3/go.mod h1:ZxmVHzZPKm5pmEIlqaRDwK0rkfTRHlfziO033XZ+RW0=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
@@ -135,8 +181,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
@@ -151,8 +197,10 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
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/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
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/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.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
@@ -171,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/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
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/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
@@ -184,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/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/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-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -191,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/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/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/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
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/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
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/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-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
@@ -205,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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
@@ -221,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/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
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/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/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@@ -231,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/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -250,21 +366,31 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
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/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/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/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/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
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/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
@@ -285,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/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
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/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
@@ -303,9 +431,11 @@ 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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
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.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -404,8 +534,10 @@ 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=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
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/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
@@ -420,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.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.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
@@ -459,8 +592,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4=
modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -4,17 +4,22 @@ import (
"cmp"
"context"
"encoding/json"
"errors"
"math/rand"
"sync"
"time"
"github.com/redis/go-redis/v9"
)
var ErrKeyNotFound = errors.New("key not found")
// Cache 缓存接口
type Cache interface {
// Set 设置缓存值支持TTL
Set(key string, value interface{}, ttl time.Duration)
Set(key string, value any, ttl time.Duration)
// Get 获取缓存值
Get(key string) (interface{}, bool)
Get(key string) (any, bool)
// Delete 删除缓存
Delete(key string)
// DeleteByPrefix 根据前缀删除缓存
@@ -27,20 +32,24 @@ type Cache interface {
Increment(key string) int64
// IncrementBy 增加指定值
IncrementBy(key string, value int64) int64
// DeleteBatch 批量删除多个 key使用 Pipeline 减少网络往返)
DeleteBatch(keys []string)
// ==================== Hash 操作 ====================
// HSet 设置 Hash 字段
HSet(ctx context.Context, key string, field string, value interface{}) error
HSet(ctx context.Context, key string, field string, value any) error
// HMSet 批量设置 Hash 字段
HMSet(ctx context.Context, key string, values map[string]interface{}) error
HMSet(ctx context.Context, key string, values map[string]any) error
// HGet 获取 Hash 字段值
HGet(ctx context.Context, key string, field string) (string, error)
// HMGet 批量获取 Hash 字段值
HMGet(ctx context.Context, key string, fields ...string) ([]interface{}, error)
HMGet(ctx context.Context, key string, fields ...string) ([]any, error)
// HGetAll 获取 Hash 所有字段
HGetAll(ctx context.Context, key string) (map[string]string, error)
// HDel 删除 Hash 字段
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 操作 ====================
// ZAdd 添加 Sorted Set 成员
@@ -49,14 +58,20 @@ type Cache interface {
ZRangeByScore(ctx context.Context, key string, min, max string, offset, count int64) ([]string, error)
// ZRevRangeByScore 按分数范围获取成员(降序)
ZRevRangeByScore(ctx context.Context, key string, max, min string, offset, count int64) ([]string, error)
// ZRevRange 按排名倒序取成员score 从高到低start/stop 为排名下标,含端点)
ZRevRange(ctx context.Context, key string, start, stop int64) ([]string, error)
// ZReplaceSortedSet 原子替换整个 ZSET先删后批量写入
ZReplaceSortedSet(ctx context.Context, key string, members []redis.Z) error
// ZRem 删除 Sorted Set 成员
ZRem(ctx context.Context, key string, members ...interface{}) error
ZRem(ctx context.Context, key string, members ...any) error
// ZCard 获取 Sorted Set 成员数量
ZCard(ctx context.Context, key string) (int64, error)
// ==================== 计数器操作 ====================
// Incr 原子递增(返回新值)
Incr(ctx context.Context, key string) (int64, error)
// IncrBySeq 原子递增指定值(用于 seq 对齐)
IncrBySeq(ctx context.Context, key string, delta int64) (int64, error)
// Expire 设置过期时间
Expire(ctx context.Context, key string, ttl time.Duration) error
}
@@ -84,7 +99,7 @@ var settings = Settings{
NullTTL: 5 * time.Second,
JitterRatio: 0.1,
PostListTTL: 30 * time.Second,
ConversationTTL: 60 * time.Second,
ConversationTTL: 120 * time.Second,
UnreadCountTTL: 30 * time.Second,
GroupMembersTTL: 120 * time.Second,
DisableFlushDB: true,
@@ -127,11 +142,16 @@ func normalizeKey(key string) string {
return cmp.Or(settings.KeyPrefix, "") + ":" + key
}
// ResolveKey 返回带全局前缀的实际 Redis 键(供需直连 Redis 的组件使用)
func ResolveKey(key string) string {
return normalizeKey(key)
}
func normalizePrefix(prefix string) string {
return cmp.Or(settings.KeyPrefix, "") + ":" + prefix
}
func SetWithJitter(c Cache, key string, value interface{}, ttl time.Duration, jitterRatio float64) {
func SetWithJitter(c Cache, key string, value any, ttl time.Duration, jitterRatio float64) {
if !settings.Enabled {
return
}

View File

@@ -1,4 +1,4 @@
package cache
package cache
import (
"context"
@@ -6,11 +6,40 @@ import (
"fmt"
"time"
"carrot_bbs/internal/model"
"github.com/redis/go-redis/v9"
"with_you/internal/model"
redisPkg "with_you/internal/pkg/redis"
"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 带缓存元数据的会话
type CachedConversation struct {
Data *model.Conversation // 实际数据
@@ -72,19 +101,23 @@ type ConversationCacheSettings struct {
MessageListTTL time.Duration // 消息分页列表缓存 (5min)
MessageIndexTTL time.Duration // 消息索引缓存 (30min)
MessageCountTTL time.Duration // 消息计数缓存 (30min)
// Seq 缓存配置
SeqTTL time.Duration // Seq 计数器缓存 (24h)
}
// DefaultConversationCacheSettings 返回默认配置
func DefaultConversationCacheSettings() *ConversationCacheSettings {
return &ConversationCacheSettings{
DetailTTL: 5 * time.Minute,
ListTTL: 60 * time.Second,
ParticipantTTL: 5 * time.Minute,
DetailTTL: 15 * time.Minute,
ListTTL: 120 * time.Second,
ParticipantTTL: 15 * time.Minute,
UnreadTTL: 30 * time.Second,
MessageDetailTTL: 30 * time.Minute,
MessageListTTL: 5 * time.Minute,
MessageIndexTTL: 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)
GetMessagesAfterSeq(convID string, afterSeq 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 会话缓存管理器
type ConversationCache struct {
cache Cache // 底层缓存
settings *ConversationCacheSettings // 配置
repo ConversationRepository // 数据仓库接口(用于 cache-aside 回源)
msgRepo MessageRepository // 消息数据仓库接口(用于消息缓存回源)
cache Cache // 底层缓存
settings *ConversationCacheSettings // 配置
repo ConversationRepository // 数据仓库接口(用于 cache-aside 回源)
msgRepo MessageRepository // 消息数据仓库接口(用于消息缓存回源)
seqBufferMgr *SeqBufferManager // seq 预分配管理器nil 则使用旧 INCR 逻辑)
}
// 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 {
settings = DefaultConversationCacheSettings()
}
return &ConversationCache{
cache: cache,
settings: settings,
repo: repo,
msgRepo: msgRepo,
cache: cache,
settings: settings,
repo: repo,
msgRepo: msgRepo,
seqBufferMgr: seqBufferMgr,
}
}
@@ -478,6 +514,289 @@ func (c *ConversationCache) InvalidateUnreadCount(userID, convID string) {
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
}
// CacheMessage 缓存单条消息(立即写入缓存
// 写入 Hash、Sorted Set、更新计数
// CacheMessage 缓存单条消息(使用 Redis Pipeline 减少网络往返
func (c *ConversationCache) CacheMessage(ctx context.Context, convID string, msg *model.Message) error {
hashKey := MessageHashKey(convID)
indexKey := MessageIndexKey(convID)
countKey := MessageCountKey(convID)
msgData := MessageCacheDataFromModel(msg)
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)
}
// HSET 消息详情
if err := c.cache.HSet(ctx, hashKey, fmt.Sprintf("%d", msg.Seq), string(data)); err != nil {
hashKey = ResolveKey(hashKey)
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)
}
// ZADD 消息索引
if err := c.cache.ZAdd(ctx, indexKey, float64(msg.Seq), fmt.Sprintf("%d", msg.Seq)); err != nil {
if err := c.cache.ZAdd(ctx, MessageIndexKey(convID), float64(msg.Seq), fmt.Sprintf("%d", msg.Seq)); err != nil {
return fmt.Errorf("failed to add to sorted set: %w", err)
}
// 设置 TTL
c.cache.Expire(ctx, hashKey, c.settings.MessageDetailTTL)
c.cache.Expire(ctx, indexKey, c.settings.MessageIndexTTL)
// INCR 消息计数
c.cache.Expire(ctx, MessageHashKey(convID), c.settings.MessageDetailTTL)
c.cache.Expire(ctx, MessageIndexKey(convID), c.settings.MessageIndexTTL)
c.cache.Incr(ctx, MessageCountKey(convID))
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

@@ -9,6 +9,10 @@ const (
// 帖子相关
PrefixPostList = "posts:list"
PrefixPost = "posts:detail"
// HotRankZSet 热门榜有序集合member=post_idscore=归一化热度,仅存 TopN
HotRankZSet = "posts:hot:zset"
// HotRankTopIDs 热门榜有序 post_id 列表JSON []string供分层缓存本地层命中
HotRankTopIDs = "posts:hot:top_ids"
// 会话相关
PrefixConversationList = "conversations:list"
@@ -22,24 +26,52 @@ const (
PrefixUnreadSystem = "unread:system"
PrefixUnreadConversation = "unread:conversation"
PrefixUnreadDetail = "unread:detail"
// 已读位置相关OpenIM 风格:缓存 hasReadSeq用于 O(1) 未读数计算)
PrefixUserReadSeq = "user_read_seq"
// 用户相关
PrefixUserInfo = "users:info"
PrefixUserMe = "users:me"
// 频道相关(全量列表一条缓存,供 /channels、帖子 channel 填充共用)
PrefixChannelsAllList = "channels:all_list"
// 消息缓存相关
keyPrefixMsgHash = "msg_hash" // 消息详情 Hash
keyPrefixMsgIndex = "msg_index" // 消息索引 Sorted Set
keyPrefixMsgCount = "msg_count" // 消息计数
keyPrefixMsgSeq = "msg_seq" // Seq 计数器
keyPrefixMsgPage = "msg_page" // 分页缓存
keyPrefixMsgHash = "msg_hash" // 消息详情 Hash
keyPrefixMsgIndex = "msg_index" // 消息索引 Sorted Set
keyPrefixMsgCount = "msg_count" // 消息计数
keyPrefixMsgSeq = "msg_seq" // Seq 计数器
keyPrefixMsgPage = "msg_page" // 分页缓存
keyPrefixMsgIdempotent = "msg_idem" // 消息幂等键
// 帖子创建幂等键前缀clientRequestID -> 已创建的 postID
keyPrefixPostIdempotent = "post_idem"
)
// PostListKey 生成帖子列表缓存键
// postType: 帖子类型 (recommend, hot, follow, latest)
// postType: 帖子类型 (hot, follow, latest)
// page: 页码
// pageSize: 每页数量
// userID: 用户维度(仅在个性化列表如 follow 场景使用)
// HotRankZSetKey 热门榜 Redis ZSET 逻辑键(仅存 TopN会经 normalizeKey 加前缀)
func HotRankZSetKey() string {
return HotRankZSet
}
// HotRankTopIDsKey 热门榜 Top 顺序 ID 列表键(会经 normalizeKey 加前缀)
func HotRankTopIDsKey() string {
return HotRankTopIDs
}
func HotRankZSetChannelKey(channelID string) string {
return fmt.Sprintf("%s:ch:%s", HotRankZSet, channelID)
}
func HotRankTopIDsChannelKey(channelID string) string {
return fmt.Sprintf("%s:ch:%s", HotRankTopIDs, channelID)
}
func PostListKey(postType string, userID string, page, pageSize int) string {
if userID == "" {
return fmt.Sprintf("%s:%s:%d:%d", PrefixPostList, postType, page, pageSize)
@@ -57,25 +89,13 @@ func ConversationListKey(userID string, page, pageSize int) string {
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 生成群组成员缓存键
func GroupMembersKey(groupID string, page, pageSize int) string {
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 生成系统消息未读数缓存键
func UnreadSystemKey(userID string) string {
@@ -92,14 +112,15 @@ func UnreadDetailKey(userID, conversationID string) string {
return fmt.Sprintf("%s:%s:%s", PrefixUnreadDetail, userID, conversationID)
}
// UserInfoKey 生成用户信息缓存键
func UserInfoKey(userID string) string {
return fmt.Sprintf("%s:%s", PrefixUserInfo, userID)
// ChannelsAllListKey 频道全量列表缓存键(含未启用,供 MapByIDs 解析历史帖子 channel
func ChannelsAllListKey() string {
return PrefixChannelsAllList
}
// UserMeKey 生成当前用户信息缓存键
func UserMeKey(userID string) string {
return fmt.Sprintf("%s:%s", PrefixUserMe, userID)
// InvalidateChannelList 失效频道列表缓存(管理端增删改频道后调用)
func InvalidateChannelList(c Cache) {
c.Delete(ChannelsAllListKey())
}
// InvalidatePostList 失效帖子列表缓存
@@ -117,20 +138,12 @@ func InvalidateConversationList(cache Cache, userID string) {
cache.DeleteByPrefix(PrefixConversationList + ":" + userID + ":")
}
// InvalidateConversationDetail 失效会话详情缓存
func InvalidateConversationDetail(cache Cache, conversationID, userID string) {
cache.Delete(ConversationDetailKey(conversationID, userID))
}
// InvalidateGroupMembers 失效群组成员缓存
func InvalidateGroupMembers(cache Cache, groupID string) {
cache.DeleteByPrefix(PrefixGroupMembers + ":" + groupID)
}
// InvalidateGroupInfo 失效群组信息缓存
func InvalidateGroupInfo(cache Cache, groupID string) {
cache.Delete(GroupInfoKey(groupID))
}
// InvalidateUnreadSystem 失效系统消息未读数缓存
func InvalidateUnreadSystem(cache Cache, userID string) {
@@ -147,11 +160,6 @@ func InvalidateUnreadDetail(cache Cache, userID, conversationID string) {
cache.Delete(UnreadDetailKey(userID, conversationID))
}
// InvalidateUserInfo 失效用户信息缓存
func InvalidateUserInfo(cache Cache, userID string) {
cache.Delete(UserInfoKey(userID))
cache.Delete(UserMeKey(userID))
}
// ============================================================
// 消息缓存 Key 生成函数
@@ -186,3 +194,20 @@ func MessagePageKey(convID string, page, pageSize int) string {
func InvalidateMessagePages(cache Cache, conversationID string) {
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)
}

461
internal/cache/layered_cache.go vendored Normal file
View File

@@ -0,0 +1,461 @@
package cache
import (
"context"
"strconv"
"sync"
"time"
redislib "github.com/redis/go-redis/v9"
redisPkg "with_you/internal/pkg/redis"
)
type LayeredCache struct {
local *LRUStriped
redis *RedisCache
mu sync.RWMutex
stats CacheStats
enabled bool
keyPrefix string
}
type CacheStats struct {
LocalHits int64
RedisHits int64
Misses int64
Sets int64
Invalidates int64
}
type LayeredCacheOptions struct {
LocalSize int
LocalBuckets int
LocalDefaultTTL time.Duration
KeyPrefix string
Enabled bool
}
func NewLayeredCache(redisClient *redisPkg.Client, opts *LayeredCacheOptions) *LayeredCache {
if opts == nil {
opts = &LayeredCacheOptions{
LocalSize: 10000,
LocalBuckets: 0,
LocalDefaultTTL: 5 * time.Minute,
KeyPrefix: "",
Enabled: true,
}
}
if opts.LocalSize <= 0 {
opts.LocalSize = 10000
}
if opts.LocalDefaultTTL <= 0 {
opts.LocalDefaultTTL = 5 * time.Minute
}
localCache := NewLRUStriped(&LRUStripedOptions{
Size: opts.LocalSize,
DefaultExpiry: opts.LocalDefaultTTL,
Buckets: opts.LocalBuckets,
Name: "layered_local",
})
var redisCache *RedisCache
if redisClient != nil {
redisCache = NewRedisCache(redisClient)
}
return &LayeredCache{
local: localCache,
redis: redisCache,
enabled: opts.Enabled,
keyPrefix: opts.KeyPrefix,
}
}
func (c *LayeredCache) Set(key string, value any, ttl time.Duration) {
if !c.enabled {
return
}
c.mu.Lock()
c.stats.Sets++
c.mu.Unlock()
c.local.Set(key, value, ttl)
if c.redis != nil {
c.redis.Set(key, value, ttl)
}
}
func (c *LayeredCache) Get(key string) (any, bool) {
if !c.enabled {
return nil, false
}
if val, ok := c.local.Get(key); ok {
c.mu.Lock()
c.stats.LocalHits++
c.mu.Unlock()
return val, true
}
if c.redis != nil {
if val, ok := c.redis.Get(key); ok {
c.mu.Lock()
c.stats.RedisHits++
c.mu.Unlock()
if ttl := c.getLocalTTL(); ttl > 0 {
if str, ok := val.(string); ok {
c.local.SetRaw(key, []byte(str), ttl)
} else {
c.local.Set(key, val, ttl)
}
}
return val, true
}
}
c.mu.Lock()
c.stats.Misses++
c.mu.Unlock()
return nil, false
}
func (c *LayeredCache) getLocalTTL() time.Duration {
return 30 * time.Second
}
func (c *LayeredCache) Delete(key string) {
if !c.enabled {
return
}
c.mu.Lock()
c.stats.Invalidates++
c.mu.Unlock()
c.local.Delete(key)
if c.redis != nil {
c.redis.Delete(key)
}
}
func (c *LayeredCache) DeleteByPrefix(prefix string) {
if !c.enabled {
return
}
c.mu.Lock()
c.stats.Invalidates++
c.mu.Unlock()
c.local.DeleteByPrefix(prefix)
if c.redis != nil {
c.redis.DeleteByPrefix(prefix)
}
}
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() {
c.local.Clear()
if c.redis != nil {
c.redis.Clear()
}
c.mu.Lock()
c.stats = CacheStats{}
c.mu.Unlock()
}
func (c *LayeredCache) Exists(key string) bool {
if !c.enabled {
return false
}
if c.local.Exists(key) {
return true
}
if c.redis != nil {
return c.redis.Exists(key)
}
return false
}
func (c *LayeredCache) Increment(key string) int64 {
if !c.enabled {
return 0
}
if c.redis != nil {
val := c.redis.Increment(key)
c.local.Delete(key)
return val
}
return c.local.Increment(key)
}
func (c *LayeredCache) IncrementBy(key string, value int64) int64 {
if !c.enabled {
return 0
}
if c.redis != nil {
val := c.redis.IncrementBy(key, value)
c.local.Delete(key)
return val
}
return c.local.IncrementBy(key, value)
}
func (c *LayeredCache) GetStats() CacheStats {
c.mu.RLock()
defer c.mu.RUnlock()
return c.stats
}
func (c *LayeredCache) GetHitRate() float64 {
c.mu.RLock()
defer c.mu.RUnlock()
total := c.stats.LocalHits + c.stats.RedisHits + c.stats.Misses
if total == 0 {
return 0
}
return float64(c.stats.LocalHits+c.stats.RedisHits) / float64(total)
}
func (c *LayeredCache) HSet(ctx context.Context, key string, field string, value any) error {
if !c.enabled || c.redis == nil {
return nil
}
c.local.Delete(key)
return c.redis.HSet(ctx, key, field, value)
}
func (c *LayeredCache) HMSet(ctx context.Context, key string, values map[string]any) error {
if !c.enabled || c.redis == nil {
return nil
}
c.local.Delete(key)
return c.redis.HMSet(ctx, key, values)
}
func (c *LayeredCache) HGet(ctx context.Context, key string, field string) (string, error) {
if !c.enabled || c.redis == nil {
return "", ErrKeyNotFound
}
return c.redis.HGet(ctx, key, field)
}
func (c *LayeredCache) HMGet(ctx context.Context, key string, fields ...string) ([]any, error) {
if !c.enabled || c.redis == nil {
return nil, ErrKeyNotFound
}
return c.redis.HMGet(ctx, key, fields...)
}
func (c *LayeredCache) HGetAll(ctx context.Context, key string) (map[string]string, error) {
if !c.enabled || c.redis == nil {
return nil, ErrKeyNotFound
}
return c.redis.HGetAll(ctx, key)
}
func (c *LayeredCache) HDel(ctx context.Context, key string, fields ...string) error {
if !c.enabled || c.redis == nil {
return nil
}
c.local.Delete(key)
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 {
if !c.enabled || c.redis == nil {
return nil
}
c.local.Delete(key)
return c.redis.ZAdd(ctx, key, score, member)
}
func (c *LayeredCache) ZRangeByScore(ctx context.Context, key string, min, max string, offset, count int64) ([]string, error) {
if !c.enabled || c.redis == nil {
return nil, ErrKeyNotFound
}
return c.redis.ZRangeByScore(ctx, key, min, max, offset, count)
}
func (c *LayeredCache) ZRevRangeByScore(ctx context.Context, key string, max, min string, offset, count int64) ([]string, error) {
if !c.enabled || c.redis == nil {
return nil, ErrKeyNotFound
}
return c.redis.ZRevRangeByScore(ctx, key, max, min, offset, count)
}
func (c *LayeredCache) ZRevRange(ctx context.Context, key string, start, stop int64) ([]string, error) {
if !c.enabled || c.redis == nil {
return nil, ErrKeyNotFound
}
return c.redis.ZRevRange(ctx, key, start, stop)
}
func (c *LayeredCache) ZReplaceSortedSet(ctx context.Context, key string, members []redislib.Z) error {
if !c.enabled || c.redis == nil {
return ErrKeyNotFound
}
c.local.Delete(key)
return c.redis.ZReplaceSortedSet(ctx, key, members)
}
func (c *LayeredCache) ZRem(ctx context.Context, key string, members ...any) error {
if !c.enabled || c.redis == nil {
return nil
}
c.local.Delete(key)
return c.redis.ZRem(ctx, key, members...)
}
func (c *LayeredCache) ZCard(ctx context.Context, key string) (int64, error) {
if !c.enabled || c.redis == nil {
return 0, ErrKeyNotFound
}
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) {
if !c.IsEnabled() {
return 0, ErrKeyNotFound
}
c.local.Delete(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 {
if !c.enabled || c.redis == nil {
return nil
}
return c.redis.Expire(ctx, key, ttl)
}
func (c *LayeredCache) SetLocalOnly(key string, value any, ttl time.Duration) {
if !c.enabled {
return
}
c.local.Set(key, value, ttl)
}
func (c *LayeredCache) GetLocalOnly(key string) (any, bool) {
if !c.enabled {
return nil, false
}
return c.local.Get(key)
}
func (c *LayeredCache) SetRedisOnly(key string, value any, ttl time.Duration) {
if !c.enabled || c.redis == nil {
return
}
c.redis.Set(key, value, ttl)
}
func (c *LayeredCache) GetRedisOnly(key string) (any, bool) {
if !c.enabled || c.redis == nil {
return nil, false
}
return c.redis.Get(key)
}
func (c *LayeredCache) GetRedisClient() *redisPkg.Client {
if c.redis == nil {
return nil
}
return c.redis.GetRedisClient()
}
func (c *LayeredCache) InvalidateUserCache(userID uint) {
c.DeleteByPrefix(cacheKeyPrefixUser + ":")
c.Delete(buildUserDetailKey(userID))
}
func (c *LayeredCache) InvalidatePostCache(postID uint) {
c.Delete(buildPostDetailKey(postID))
c.DeleteByPrefix(cacheKeyPrefixPostList)
}
func (c *LayeredCache) InvalidateGroupCache(groupID uint) {
c.Delete(buildGroupDetailKey(groupID))
c.Delete(buildGroupMembersKey(groupID))
}
func (c *LayeredCache) InvalidateAllLists() {
c.DeleteByPrefix(cacheKeyPrefixPostList)
c.DeleteByPrefix(cacheKeyPrefixUserActivity)
}
var (
cacheKeyPrefixUser = "user"
cacheKeyPrefixPostList = "post_list"
cacheKeyPrefixUserActivity = "user_activity"
)
func buildUserDetailKey(userID uint) string {
return "user:detail:" + strconv.FormatUint(uint64(userID), 10)
}
func buildPostDetailKey(postID uint) string {
return "post:detail:" + strconv.FormatUint(uint64(postID), 10)
}
func buildGroupDetailKey(groupID uint) string {
return "group:detail:" + strconv.FormatUint(uint64(groupID), 10)
}
func buildGroupMembersKey(groupID uint) string {
return "group:members:" + strconv.FormatUint(uint64(groupID), 10)
}
var _ Cache = (*LayeredCache)(nil)

238
internal/cache/lru.go vendored Normal file
View File

@@ -0,0 +1,238 @@
package cache
import (
"container/list"
"encoding/json"
"sync"
"time"
)
type LRUOptions struct {
Size int
DefaultExpiry time.Duration
Name string
}
type LRU struct {
lock sync.RWMutex
size int
len int
currentGeneration int64
evictList *list.List
items map[string]*list.Element
defaultExpiry time.Duration
name string
}
type lruEntry struct {
key string
value []byte
expires time.Time
generation int64
}
func NewLRU(opts *LRUOptions) *LRU {
if opts.Size <= 0 {
opts.Size = 1000
}
return &LRU{
name: opts.Name,
size: opts.Size,
evictList: list.New(),
items: make(map[string]*list.Element, opts.Size),
defaultExpiry: opts.DefaultExpiry,
}
}
func (l *LRU) Set(key string, value any, ttl time.Duration) {
if ttl <= 0 {
ttl = l.defaultExpiry
}
l.set(key, value, ttl)
}
func (l *LRU) set(key string, value any, ttl time.Duration) {
var expires time.Time
if ttl > 0 {
expires = time.Now().Add(ttl)
}
buf, err := json.Marshal(value)
if err != nil {
return
}
l.lock.Lock()
defer l.lock.Unlock()
if ent, ok := l.items[key]; ok {
l.evictList.MoveToFront(ent)
e := ent.Value.(*lruEntry)
e.value = buf
e.expires = expires
if e.generation != l.currentGeneration {
e.generation = l.currentGeneration
l.len++
}
return
}
ent := &lruEntry{key, buf, 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())
}
}
// 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) {
val, err := l.getItem(key)
if err != nil {
return nil, false
}
return val, true
}
func (l *LRU) getItem(key string) ([]byte, error) {
l.lock.Lock()
defer l.lock.Unlock()
ent, ok := l.items[key]
if !ok {
return nil, ErrKeyNotFound
}
e := ent.Value.(*lruEntry)
if e.generation != l.currentGeneration || (!e.expires.IsZero() && time.Now().After(e.expires)) {
l.removeElement(ent)
return nil, ErrKeyNotFound
}
l.evictList.MoveToFront(ent)
return e.value, nil
}
func (l *LRU) Delete(key string) {
l.lock.Lock()
defer l.lock.Unlock()
if ent, ok := l.items[key]; ok {
l.removeElement(ent)
}
}
func (l *LRU) DeleteByPrefix(prefix string) {
l.lock.Lock()
defer l.lock.Unlock()
for key, ent := range l.items {
if len(key) >= len(prefix) && key[:len(prefix)] == prefix {
l.removeElement(ent)
}
}
}
func (l *LRU) Clear() {
l.lock.Lock()
defer l.lock.Unlock()
l.len = 0
l.currentGeneration++
}
func (l *LRU) Exists(key string) bool {
l.lock.RLock()
defer l.lock.RUnlock()
ent, ok := l.items[key]
if !ok {
return false
}
e := ent.Value.(*lruEntry)
return e.generation == l.currentGeneration && (e.expires.IsZero() || !time.Now().After(e.expires))
}
func (l *LRU) Increment(key string) int64 {
return l.IncrementBy(key, 1)
}
func (l *LRU) IncrementBy(key string, value int64) int64 {
l.lock.Lock()
defer l.lock.Unlock()
var current int64
if ent, ok := l.items[key]; ok {
e := ent.Value.(*lruEntry)
if e.generation == l.currentGeneration && (e.expires.IsZero() || !time.Now().After(e.expires)) {
json.Unmarshal(e.value, &current)
}
}
current += value
buf, _ := json.Marshal(current)
if ent, ok := l.items[key]; ok {
e := ent.Value.(*lruEntry)
e.value = buf
l.evictList.MoveToFront(ent)
} else {
ent := &lruEntry{key, buf, time.Time{}, l.currentGeneration}
entry := l.evictList.PushFront(ent)
l.items[key] = entry
l.len++
}
return current
}
func (l *LRU) Len() int {
l.lock.RLock()
defer l.lock.RUnlock()
return l.len
}
func (l *LRU) Name() string {
return l.name
}
func (l *LRU) removeElement(e *list.Element) {
l.evictList.Remove(e)
kv := e.Value.(*lruEntry)
if kv.generation == l.currentGeneration {
l.len--
}
delete(l.items, kv.key)
}

176
internal/cache/lru_striped.go vendored Normal file
View File

@@ -0,0 +1,176 @@
package cache
import (
"math"
"runtime"
"time"
"github.com/cespare/xxhash/v2"
)
type LRUStriped struct {
buckets []*LRU
name string
}
type LRUStripedOptions struct {
Size int
DefaultExpiry time.Duration
Name string
Buckets int
}
func NewLRUStriped(opts *LRUStripedOptions) *LRUStriped {
if opts.Buckets <= 0 {
opts.Buckets = runtime.NumCPU()
}
if opts.Buckets < 4 {
opts.Buckets = 4
}
if opts.Size < opts.Buckets {
opts.Size = opts.Buckets * 100
}
opts.Size += int(math.Ceil(float64(opts.Size) * 10.0 / 100.0))
bucketSize := (opts.Size / opts.Buckets) + (opts.Size % opts.Buckets)
buckets := make([]*LRU, opts.Buckets)
for i := 0; i < opts.Buckets; i++ {
buckets[i] = NewLRU(&LRUOptions{
Size: bucketSize,
DefaultExpiry: opts.DefaultExpiry,
Name: opts.Name,
})
}
return &LRUStriped{
buckets: buckets,
name: opts.Name,
}
}
func (l *LRUStriped) hashKey(key string) uint64 {
return xxhash.Sum64String(key)
}
func (l *LRUStriped) keyBucket(key string) *LRU {
return l.buckets[l.hashKey(key)%uint64(len(l.buckets))]
}
func (l *LRUStriped) Set(key string, value any, ttl time.Duration) {
l.keyBucket(key).Set(key, value, ttl)
}
func (l *LRUStriped) Get(key string) (any, bool) {
return l.keyBucket(key).Get(key)
}
func (l *LRUStriped) Delete(key string) {
l.keyBucket(key).Delete(key)
}
func (l *LRUStriped) DeleteByPrefix(prefix string) {
for _, bucket := range l.buckets {
bucket.DeleteByPrefix(prefix)
}
}
func (l *LRUStriped) Clear() {
for _, bucket := range l.buckets {
bucket.Clear()
}
}
func (l *LRUStriped) Exists(key string) bool {
return l.keyBucket(key).Exists(key)
}
func (l *LRUStriped) Increment(key string) int64 {
return l.keyBucket(key).Increment(key)
}
func (l *LRUStriped) IncrementBy(key string, value int64) int64 {
return l.keyBucket(key).IncrementBy(key, value)
}
func (l *LRUStriped) Len() int {
var total int
for _, bucket := range l.buckets {
total += bucket.Len()
}
return total
}
func (l *LRUStriped) Name() string {
return l.name
}
func (l *LRUStriped) DeleteMulti(keys []string) error {
for _, key := range keys {
l.Delete(key)
}
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 {
errs := make([]error, len(values))
for i, key := range keys {
if val, ok := l.Get(key); ok {
values[i] = val
} else {
errs[i] = ErrKeyNotFound
}
}
return errs
}
func (l *LRUStriped) SetMulti(items map[string]any, ttl time.Duration) error {
for key, value := range items {
l.Set(key, value, ttl)
}
return nil
}
func (l *LRUStriped) Keys() []string {
keys := make([]string, 0)
for _, bucket := range l.buckets {
bucket.lock.RLock()
for key, ent := range bucket.items {
e := ent.Value.(*lruEntry)
if e.generation == bucket.currentGeneration && (e.expires.IsZero() || !time.Now().After(e.expires)) {
keys = append(keys, key)
}
}
bucket.lock.RUnlock()
}
return keys
}
type LRUStripedInterface interface {
Set(key string, value any, ttl time.Duration)
Get(key string) (any, bool)
Delete(key string)
DeleteByPrefix(prefix string)
Clear()
Exists(key string) bool
Increment(key string) int64
IncrementBy(key string, value int64) int64
Len() int
Name() string
DeleteMulti(keys []string) error
GetMulti(keys []string, values []any) []error
SetMulti(items map[string]any, ttl time.Duration) error
Keys() []string
}
var _ LRUStripedInterface = (*LRUStriped)(nil)

View File

@@ -14,25 +14,6 @@ type cacheMetrics struct {
// metrics 全局缓存指标实例
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 记录缓存命中
func recordHit() {

View File

@@ -1,14 +1,14 @@
package cache
package cache
import (
"context"
"encoding/json"
"time"
"github.com/redis/go-redis/v9"
redislib "github.com/redis/go-redis/v9"
"go.uber.org/zap"
redisPkg "carrot_bbs/internal/pkg/redis"
redisPkg "with_you/internal/pkg/redis"
)
// RedisCache Redis缓存实现
@@ -26,7 +26,7 @@ func NewRedisCache(client *redisPkg.Client) *RedisCache {
}
// Set 设置缓存值
func (c *RedisCache) Set(key string, value interface{}, ttl time.Duration) {
func (c *RedisCache) Set(key string, value any, ttl time.Duration) {
key = normalizeKey(key)
// 将值序列化为JSON
data, err := json.Marshal(value)
@@ -49,11 +49,11 @@ func (c *RedisCache) Set(key string, value interface{}, ttl time.Duration) {
}
// Get 获取缓存值
func (c *RedisCache) Get(key string) (interface{}, bool) {
func (c *RedisCache) Get(key string) (any, bool) {
key = normalizeKey(key)
data, err := c.client.Get(c.ctx, key)
if err != nil {
if err == redis.Nil {
if err == redislib.Nil {
return nil, false
}
zap.L().Error("Failed to get key",
@@ -82,7 +82,6 @@ func (c *RedisCache) Delete(key string) {
// DeleteByPrefix 根据前缀删除缓存
func (c *RedisCache) DeleteByPrefix(prefix string) {
prefix = normalizePrefix(prefix)
// 使用原生客户端执行SCAN命令
rdb := c.client.GetClient()
var cursor uint64
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 清空所有缓存
func (c *RedisCache) Clear() {
if settings.DisableFlushDB {
@@ -164,13 +187,13 @@ func (c *RedisCache) IncrementBy(key string, value int64) int64 {
// ==================== RedisCache Hash 操作 ====================
// HSet 设置 Hash 字段
func (c *RedisCache) HSet(ctx context.Context, key string, field string, value interface{}) error {
func (c *RedisCache) HSet(ctx context.Context, key string, field string, value any) error {
key = normalizeKey(key)
return c.client.HSet(ctx, key, field, value)
}
// HMSet 批量设置 Hash 字段
func (c *RedisCache) HMSet(ctx context.Context, key string, values map[string]interface{}) error {
func (c *RedisCache) HMSet(ctx context.Context, key string, values map[string]any) error {
key = normalizeKey(key)
return c.client.HMSet(ctx, key, values)
}
@@ -182,7 +205,7 @@ func (c *RedisCache) HGet(ctx context.Context, key string, field string) (string
}
// HMGet 批量获取 Hash 字段值
func (c *RedisCache) HMGet(ctx context.Context, key string, fields ...string) ([]interface{}, error) {
func (c *RedisCache) HMGet(ctx context.Context, key string, fields ...string) ([]any, error) {
key = normalizeKey(key)
return c.client.HMGet(ctx, key, fields...)
}
@@ -199,6 +222,12 @@ func (c *RedisCache) HDel(ctx context.Context, key string, fields ...string) err
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 操作 ====================
// ZAdd 添加 Sorted Set 成员
@@ -219,8 +248,27 @@ func (c *RedisCache) ZRevRangeByScore(ctx context.Context, key string, max, min
return c.client.ZRevRangeByScore(ctx, key, max, min, offset, count)
}
// ZRevRange 按排名倒序获取成员
func (c *RedisCache) ZRevRange(ctx context.Context, key string, start, stop int64) ([]string, error) {
key = normalizeKey(key)
return c.client.ZRevRange(ctx, key, start, stop)
}
// ZReplaceSortedSet 删除后整表重写 ZSET
func (c *RedisCache) ZReplaceSortedSet(ctx context.Context, key string, members []redislib.Z) error {
key = normalizeKey(key)
rdb := c.client.GetClient()
if err := rdb.Del(ctx, key).Err(); err != nil {
return err
}
if len(members) == 0 {
return nil
}
return c.client.ZAddArgs(ctx, key, members...)
}
// ZRem 删除 Sorted Set 成员
func (c *RedisCache) ZRem(ctx context.Context, key string, members ...interface{}) error {
func (c *RedisCache) ZRem(ctx context.Context, key string, members ...any) error {
key = normalizeKey(key)
return c.client.ZRem(ctx, key, members...)
}
@@ -239,6 +287,13 @@ func (c *RedisCache) Incr(ctx context.Context, key string) (int64, error) {
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 设置过期时间
func (c *RedisCache) Expire(ctx context.Context, key string, ttl time.Duration) error {
key = normalizeKey(key)

View File

@@ -1,17 +1,17 @@
package cache
package cache
import (
"carrot_bbs/internal/model"
"carrot_bbs/internal/repository"
"with_you/internal/model"
"with_you/internal/repository"
)
// ConversationRepositoryAdapter 适配 MessageRepository 到 ConversationRepository 接口
type ConversationRepositoryAdapter struct {
repo *repository.MessageRepository
repo repository.MessageRepository
}
// NewConversationRepositoryAdapter 创建适配器
func NewConversationRepositoryAdapter(repo *repository.MessageRepository) ConversationRepository {
func NewConversationRepositoryAdapter(repo repository.MessageRepository) ConversationRepository {
return &ConversationRepositoryAdapter{repo: repo}
}
@@ -42,11 +42,11 @@ func (a *ConversationRepositoryAdapter) GetUnreadCount(userID, convID string) (i
// MessageRepositoryAdapter 适配 MessageRepository 到 MessageRepository 接口
type MessageRepositoryAdapter struct {
repo *repository.MessageRepository
repo repository.MessageRepository
}
// NewMessageRepositoryAdapter 创建适配器
func NewMessageRepositoryAdapter(repo *repository.MessageRepository) MessageRepository {
func NewMessageRepositoryAdapter(repo repository.MessageRepository) MessageRepository {
return &MessageRepositoryAdapter{repo: repo}
}
@@ -74,3 +74,8 @@ func (a *MessageRepositoryAdapter) CreateMessage(msg *model.Message) error {
func (a *MessageRepositoryAdapter) UpdateConversationLastSeq(convID string, seq int64) error {
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 (
"cmp"
"fmt"
"os"
"strconv"
"strings"
"time"
@@ -22,13 +19,26 @@ type Config struct {
Log LogConfig `mapstructure:"log"`
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
Upload UploadConfig `mapstructure:"upload"`
Gorse GorseConfig `mapstructure:"gorse"`
FileCleanup FileCleanupConfig `mapstructure:"file_cleanup"`
DeviceCleanup DeviceCleanupConfig `mapstructure:"device_cleanup"`
OpenAI OpenAIConfig `mapstructure:"openai"`
TencentTMS TencentTMSConfig `mapstructure:"tencent_tms"`
Email EmailConfig `mapstructure:"email"`
ConversationCache ConversationCacheConfig `mapstructure:"conversation_cache"`
GRPC GRPCConfig `mapstructure:"grpc"`
Casbin CasbinConfig `mapstructure:"casbin"`
Encryption EncryptionConfig `mapstructure:"encryption"`
HotRank HotRankConfig `mapstructure:"hot_rank"`
LiveKit LiveKitConfig `mapstructure:"livekit"`
Report ReportConfig `mapstructure:"report"`
Sensitive SensitiveConfig `mapstructure:"sensitive"`
JPush JPushConfig `mapstructure:"jpush"`
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 加载配置文件
@@ -47,13 +57,28 @@ func Load(configPath string) (*Config, error) {
viper.SetDefault("server.mode", "debug")
viper.SetDefault("server.host", "0.0.0.0")
viper.SetDefault("database.type", "sqlite")
viper.SetDefault("database.sqlite.path", "./data/carrot_bbs.db")
viper.SetDefault("database.sqlite.path", "./data/with_you.db")
viper.SetDefault("database.max_idle_conns", 10)
viper.SetDefault("database.max_open_conns", 100)
viper.SetDefault("database.log_level", "warn")
viper.SetDefault("database.slow_threshold_ms", 200)
viper.SetDefault("database.ignore_record_not_found", 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.redis.host", "localhost")
viper.SetDefault("redis.redis.port", 6379)
@@ -72,6 +97,10 @@ func Load(configPath string) (*Config, error) {
viper.SetDefault("cache.modules.conversation_ttl", 60)
viper.SetDefault("cache.modules.unread_count_ttl", 30)
viper.SetDefault("cache.modules.group_members_ttl", 120)
viper.SetDefault("cache.local.enabled", true)
viper.SetDefault("cache.local.size", 10000)
viper.SetDefault("cache.local.buckets", 0)
viper.SetDefault("cache.local.default_ttl", 300)
viper.SetDefault("jwt.secret", "your-jwt-secret-key-change-in-production")
viper.SetDefault("jwt.access_token_expire", 86400)
viper.SetDefault("jwt.refresh_token_expire", 604800)
@@ -82,6 +111,15 @@ func Load(configPath string) (*Config, error) {
viper.SetDefault("rate_limit.requests_per_minute", 60)
viper.SetDefault("upload.max_file_size", 10485760)
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.access_key", "")
viper.SetDefault("s3.secret_key", "")
@@ -91,16 +129,12 @@ func Load(configPath string) (*Config, error) {
viper.SetDefault("s3.domain", "")
viper.SetDefault("sensitive.enabled", true)
viper.SetDefault("sensitive.replace_str", "***")
viper.SetDefault("sensitive.min_match_len", 2)
viper.SetDefault("sensitive.load_from_db", true)
viper.SetDefault("sensitive.load_from_redis", false)
viper.SetDefault("sensitive.redis_key_prefix", "sensitive_words")
viper.SetDefault("audit.enabled", false)
viper.SetDefault("audit.provider", "local")
viper.SetDefault("gorse.enabled", false)
viper.SetDefault("gorse.address", "http://localhost:8087")
viper.SetDefault("gorse.api_key", "")
viper.SetDefault("gorse.dashboard", "http://localhost:8088")
viper.SetDefault("gorse.import_password", "")
viper.SetDefault("gorse.embedding_api_key", "")
viper.SetDefault("gorse.embedding_url", "https://api.littlelan.cn/v1/embeddings")
viper.SetDefault("gorse.embedding_model", "BAAI/bge-m3")
viper.SetDefault("openai.enabled", true)
viper.SetDefault("openai.base_url", "https://api.littlelan.cn/")
viper.SetDefault("openai.api_key", "")
@@ -108,13 +142,19 @@ func Load(configPath string) (*Config, error) {
viper.SetDefault("openai.moderation_max_images_per_request", 1)
viper.SetDefault("openai.request_timeout", 30)
viper.SetDefault("openai.strict_moderation", false)
viper.SetDefault("tencent_tms.enabled", false)
viper.SetDefault("tencent_tms.secret_id", "")
viper.SetDefault("tencent_tms.secret_key", "")
viper.SetDefault("tencent_tms.region", "ap-guangzhou")
viper.SetDefault("tencent_tms.biz_type", "")
viper.SetDefault("tencent_tms.timeout", 30)
viper.SetDefault("email.enabled", false)
viper.SetDefault("email.host", "")
viper.SetDefault("email.port", 587)
viper.SetDefault("email.username", "")
viper.SetDefault("email.password", "")
viper.SetDefault("email.from_address", "")
viper.SetDefault("email.from_name", "Carrot BBS")
viper.SetDefault("email.from_name", "WithYou")
viper.SetDefault("email.use_tls", true)
viper.SetDefault("email.insecure_skip_verify", false)
viper.SetDefault("email.timeout", 15)
@@ -137,7 +177,6 @@ func Load(configPath string) (*Config, error) {
viper.SetDefault("grpc.tls_enabled", false)
viper.SetDefault("grpc.tls_cert_file", "")
viper.SetDefault("grpc.tls_key_file", "")
// Casbin 默认值
viper.SetDefault("casbin.model_path", "./configs/casbin/model.conf")
viper.SetDefault("casbin.auto_save", true)
viper.SetDefault("casbin.auto_load", true)
@@ -148,6 +187,142 @@ func Load(configPath string) (*Config, error) {
viper.SetDefault("encryption.enabled", false)
viper.SetDefault("encryption.key", "")
viper.SetDefault("encryption.key_version", 1)
viper.SetDefault("hot_rank.enabled", true)
viper.SetDefault("hot_rank.refresh_interval_seconds", 300)
viper.SetDefault("hot_rank.top_n", 100)
viper.SetDefault("hot_rank.recent_window_hours", 168)
viper.SetDefault("hot_rank.recent_fetch_limit", 500)
viper.SetDefault("hot_rank.candidate_cap", 800)
// Report 默认值
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", "")
// 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 {
return nil, fmt.Errorf("failed to read config: %w", err)
@@ -158,92 +333,26 @@ func Load(configPath string) (*Config, error) {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
// Runner mode 和 instance_id 空则跟随 WebSocket 配置
cfg.Runner.ApplyWSDefaults(cfg.WebSocket)
// Convert seconds to duration
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.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.Gorse.Address = getEnvOrDefault("APP_GORSE_ADDRESS", cfg.Gorse.Address)
cfg.Gorse.APIKey = getEnvOrDefault("APP_GORSE_API_KEY", cfg.Gorse.APIKey)
cfg.Gorse.Dashboard = getEnvOrDefault("APP_GORSE_DASHBOARD", cfg.Gorse.Dashboard)
cfg.Gorse.ImportPassword = getEnvOrDefault("APP_GORSE_IMPORT_PASSWORD", cfg.Gorse.ImportPassword)
cfg.Gorse.EmbeddingAPIKey = getEnvOrDefault("APP_GORSE_EMBEDDING_API_KEY", cfg.Gorse.EmbeddingAPIKey)
cfg.Gorse.EmbeddingURL = getEnvOrDefault("APP_GORSE_EMBEDDING_URL", cfg.Gorse.EmbeddingURL)
cfg.Gorse.EmbeddingModel = getEnvOrDefault("APP_GORSE_EMBEDDING_MODEL", cfg.Gorse.EmbeddingModel)
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)))
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)))
// Viper AutomaticEnv 已自动处理所有 APP_ 前缀的环境变量
// 安全检查生产模式必须设置JWT密钥
if cfg.Server.Mode != "debug" {
if cfg.JWT.Secret == "" || cfg.JWT.Secret == "your-jwt-secret-key-change-in-production" {
return nil, fmt.Errorf("SECURITY ERROR: JWT secret must be set via APP_JWT_SECRET environment variable in production mode")
}
if len(cfg.JWT.Secret) < 32 {
return nil, fmt.Errorf("SECURITY ERROR: JWT secret must be at least 32 characters long in production mode")
}
}
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 数据库配置
type DatabaseConfig struct {
Type string `mapstructure:"type"`
SQLite SQLiteConfig `mapstructure:"sqlite"`
Postgres PostgresConfig `mapstructure:"postgres"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
MaxOpenConns int `mapstructure:"max_open_conns"`
LogLevel string `mapstructure:"log_level"`
SlowThresholdMs int `mapstructure:"slow_threshold_ms"`
IgnoreRecordNotFound bool `mapstructure:"ignore_record_not_found"`
ParameterizedQueries bool `mapstructure:"parameterized_queries"`
Type string `mapstructure:"type"`
SQLite SQLiteConfig `mapstructure:"sqlite"`
Postgres PostgresConfig `mapstructure:"postgres"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
MaxOpenConns int `mapstructure:"max_open_conns"`
LogLevel string `mapstructure:"log_level"`
SlowThresholdMs int `mapstructure:"slow_threshold_ms"`
IgnoreRecordNotFound bool `mapstructure:"ignore_record_not_found"`
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 配置
@@ -20,7 +27,7 @@ type SQLiteConfig struct {
Path string `mapstructure:"path"`
}
// PostgresConfig PostgreSQL 配置
// PostgresConfig PostgreSQL 配置(主库和副本共用)
type PostgresConfig struct {
Host string `mapstructure:"host"`
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,
)
}
// HasReplica 返回是否配置了读副本(单副本或多副本)
func (d DatabaseConfig) HasReplica() bool {
return d.Replica.Host != "" || len(d.Replicas) > 0
}
// AllReplicas 返回所有读副本配置(合并单副本 + 多副本)
func (d DatabaseConfig) AllReplicas() []PostgresConfig {
var result []PostgresConfig
if d.Replica.Host != "" {
result = append(result, d.Replica)
}
result = append(result, d.Replicas...)
return result
}

View File

@@ -1,15 +1,17 @@
package config
// GorseConfig Gorse 推荐系统配置
type GorseConfig struct {
Address string `mapstructure:"address"`
APIKey string `mapstructure:"api_key"`
Enabled bool `mapstructure:"enabled"`
Dashboard string `mapstructure:"dashboard"`
ImportPassword string `mapstructure:"import_password"`
EmbeddingAPIKey string `mapstructure:"embedding_api_key"`
EmbeddingURL string `mapstructure:"embedding_url"`
EmbeddingModel string `mapstructure:"embedding_model"`
// HotRankConfig 热门榜定时重算(仅 Redis ZSET + 分层缓存中的 Top ID 列表,不写库)
type HotRankConfig struct {
Enabled bool `mapstructure:"enabled"`
RefreshIntervalSeconds int `mapstructure:"refresh_interval_seconds"`
// TopN 写入缓存的热门条数ZSET 与 top_ids 列表长度上限)
TopN int `mapstructure:"top_n"`
// RecentWindowHours 候选池:该时间窗内新发的帖子 ID 会参与本轮重算
RecentWindowHours int `mapstructure:"recent_window_hours"`
// RecentFetchLimit 时间窗内最多拉取的帖子 ID 数(按 created_at 倒序)
RecentFetchLimit int `mapstructure:"recent_fetch_limit"`
// CandidateCap 候选 ID 合并后的上限(上届 Top + 新帖去重后截断)
CandidateCap int `mapstructure:"candidate_cap"`
}
// OpenAIConfig OpenAI 配置
@@ -22,3 +24,13 @@ type OpenAIConfig struct {
RequestTimeout int `mapstructure:"request_timeout"`
StrictModeration bool `mapstructure:"strict_moderation"`
}
// TencentTMSConfig 腾讯云文本内容安全配置(作为 OpenAI 审核的备用)
type TencentTMSConfig struct {
Enabled bool `mapstructure:"enabled"`
SecretID string `mapstructure:"secret_id"`
SecretKey string `mapstructure:"secret_key"`
Region string `mapstructure:"region"`
BizType string `mapstructure:"biz_type"`
Timeout int `mapstructure:"timeout"`
}

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
import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
)
// RedisConfig Redis 配置
@@ -17,13 +14,22 @@ type RedisConfig struct {
// CacheConfig 缓存配置
type CacheConfig struct {
Enabled bool `mapstructure:"enabled"`
KeyPrefix string `mapstructure:"key_prefix"`
DefaultTTL int `mapstructure:"default_ttl"`
NullTTL int `mapstructure:"null_ttl"`
JitterRatio float64 `mapstructure:"jitter_ratio"`
DisableFlushDB bool `mapstructure:"disable_flushdb"`
Modules CacheModuleTTL `mapstructure:"modules"`
Enabled bool `mapstructure:"enabled"`
KeyPrefix string `mapstructure:"key_prefix"`
DefaultTTL int `mapstructure:"default_ttl"`
NullTTL int `mapstructure:"null_ttl"`
JitterRatio float64 `mapstructure:"jitter_ratio"`
DisableFlushDB bool `mapstructure:"disable_flushdb"`
Modules CacheModuleTTL `mapstructure:"modules"`
Local LocalCacheConfig `mapstructure:"local"`
}
// LocalCacheConfig 本地缓存配置
type LocalCacheConfig struct {
Enabled bool `mapstructure:"enabled"`
Size int `mapstructure:"size"`
Buckets int `mapstructure:"buckets"`
DefaultTTL int `mapstructure:"default_ttl"`
}
// CacheModuleTTL 缓存模块 TTL 配置
@@ -53,19 +59,3 @@ type MiniredisConfig struct {
Port int `mapstructure:"port"`
}
// NewRedis 创建 Redis 客户端(真实 Redis
func NewRedis(cfg *RedisConfig) (*redis.Client, error) {
client := redis.NewClient(&redis.Options{
Addr: cfg.Redis.Addr(),
Password: cfg.Redis.Password,
DB: cfg.Redis.DB,
PoolSize: cfg.PoolSize,
})
ctx := context.Background()
if err := client.Ping(ctx).Err(); err != nil {
return nil, fmt.Errorf("failed to connect to redis: %w", err)
}
return client, nil
}

View File

@@ -0,0 +1,7 @@
package config
// ReportConfig 举报配置
type ReportConfig struct {
// AutoHideThreshold 自动隐藏阈值(举报次数达到此数值时自动隐藏内容)
AutoHideThreshold int `mapstructure:"auto_hide_threshold"`
}

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
type SensitiveConfig struct {
Enabled bool `mapstructure:"enabled"`
ReplaceStr string `mapstructure:"replace_str"`
MinMatchLen int `mapstructure:"min_match_len"`
LoadFromDB bool `mapstructure:"load_from_db"`
LoadFromRedis bool `mapstructure:"load_from_redis"`
RedisKeyPrefix string `mapstructure:"redis_key_prefix"`
}

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"`
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
import (
"context"
"fmt"
"time"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
// S3Config S3 存储配置
type S3Config struct {
Endpoint string `mapstructure:"endpoint"`
@@ -19,32 +10,3 @@ type S3Config struct {
Region string `mapstructure:"region"`
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 {
return nil, fmt.Errorf("failed to check bucket: %w", err)
}
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

@@ -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,8 +1,8 @@
package dto
import (
"carrot_bbs/internal/model"
"time"
"with_you/internal/model"
)
// ==================== User DTOs ====================
@@ -24,6 +24,9 @@ type UserResponse struct {
IsFollowing bool `json:"is_following"`
IsFollowingMe bool `json:"is_following_me"`
CreatedAt string `json:"created_at"`
// 认证信息
Identity model.UserIdentity `json:"identity,omitempty"`
VerificationStatus model.VerificationStatus `json:"verification_status,omitempty"`
}
// UserSelfResponse 用户自身信息响应(包含敏感信息,仅本人可见)
@@ -44,6 +47,11 @@ type UserSelfResponse struct {
FollowingCount int `json:"following_count"`
IsVerified bool `json:"is_verified"`
CreatedAt string `json:"created_at"`
// 隐私设置
PrivacySettings *PrivacySettingsResponse `json:"privacy_settings,omitempty"`
// 认证信息
Identity model.UserIdentity `json:"identity,omitempty"`
VerificationStatus model.VerificationStatus `json:"verification_status,omitempty"`
}
// UserDetailResponse 用户详情响应(仅本人可见,包含敏感信息)
@@ -66,6 +74,11 @@ type UserDetailResponse struct {
IsFollowing bool `json:"is_following"`
IsFollowingMe bool `json:"is_following_me"`
CreatedAt string `json:"created_at"`
// 隐私设置
PrivacySettings *PrivacySettingsResponse `json:"privacy_settings,omitempty"`
// 认证信息
Identity model.UserIdentity `json:"identity,omitempty"`
VerificationStatus model.VerificationStatus `json:"verification_status,omitempty"`
}
// ==================== Admin User DTOs ====================
@@ -118,65 +131,6 @@ type AdminUpdateUserStatusRequest struct {
Status string `json:"status" binding:"required,oneof=active banned"`
}
// ==================== Post DTOs ====================
// 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"`
Title string `json:"title"`
Content string `json:"content"`
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"`
Author *UserResponse `json:"author"`
IsLiked bool `json:"is_liked"`
IsFavorited bool `json:"is_favorited"`
}
// PostDetailResponse 帖子详情响应
type PostDetailResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Title string `json:"title"`
Content string `json:"content"`
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"`
Author *UserResponse `json:"author"`
IsLiked bool `json:"is_liked"`
IsFavorited bool `json:"is_favorited"`
}
// ==================== Comment DTOs ====================
// CommentImageResponse 评论图片响应
@@ -193,6 +147,7 @@ type CommentResponse struct {
ParentID *string `json:"parent_id"`
RootID *string `json:"root_id"`
Content string `json:"content"`
Segments model.MessageSegments `json:"segments,omitempty"`
Images []CommentImageResponse `json:"images"`
LikesCount int `json:"likes_count"`
RepliesCount int `json:"replies_count"`
@@ -223,15 +178,17 @@ type NotificationResponse struct {
type SegmentType string
const (
SegmentTypeText SegmentType = "text"
SegmentTypeImage SegmentType = "image"
SegmentTypeVoice SegmentType = "voice"
SegmentTypeVideo SegmentType = "video"
SegmentTypeFile SegmentType = "file"
SegmentTypeAt SegmentType = "at"
SegmentTypeReply SegmentType = "reply"
SegmentTypeFace SegmentType = "face"
SegmentTypeLink SegmentType = "link"
SegmentTypeText SegmentType = "text"
SegmentTypeImage SegmentType = "image"
SegmentTypeVoice SegmentType = "voice"
SegmentTypeVideo SegmentType = "video"
SegmentTypeFile SegmentType = "file"
SegmentTypeAt SegmentType = "at"
SegmentTypeReply SegmentType = "reply"
SegmentTypeFace SegmentType = "face"
SegmentTypeLink SegmentType = "link"
SegmentTypeVote SegmentType = "vote"
SegmentTypePostRef SegmentType = "post_ref"
)
// TextSegmentData 文本数据
@@ -299,6 +256,36 @@ type LinkSegmentData struct {
Image string `json:"image,omitempty"`
}
// VoteOptionData 投票选项数据
type VoteOptionData struct {
ID string `json:"id"`
Content string `json:"content"`
}
// PostRefSegmentData 帖子内链Segment数据
type PostRefSegmentData struct {
PostID string `json:"post_id"`
Title string `json:"title,omitempty"`
Author *UserResponse `json:"author,omitempty"`
Status string `json:"status,omitempty"`
Accessible bool `json:"accessible"`
ClickCount int `json:"click_count,omitzero"`
}
// PostRefBrief 帖子内链简要信息 (用于搜索联想)
type PostRefBrief struct {
ID string `json:"id"`
Title string `json:"title"`
Channel string `json:"channel,omitempty"`
}
// VoteSegmentData 投票Segment数据
type VoteSegmentData struct {
Options []VoteOptionData `json:"options"`
MaxChoices int `json:"max_choices,omitempty"`
IsMultiChoice bool `json:"is_multi_choice,omitempty"`
}
// ==================== Message DTOs ====================
// MessageResponse 消息响应
@@ -317,26 +304,27 @@ type MessageResponse struct {
// ConversationResponse 会话响应
type ConversationResponse struct {
ID string `json:"id"`
Type string `json:"type"`
IsPinned bool `json:"is_pinned"`
Group *GroupResponse `json:"group,omitempty"`
LastSeq int64 `json:"last_seq"`
LastMessage *MessageResponse `json:"last_message"`
LastMessageAt string `json:"last_message_at"`
UnreadCount int `json:"unread_count"`
Participants []*UserResponse `json:"participants,omitempty"` // 私聊时使用
MemberCount int `json:"member_count,omitempty"` // 聊时使用
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID string `json:"id"`
Type string `json:"type"`
IsPinned bool `json:"is_pinned"`
NotificationMuted bool `json:"notification_muted"`
Group *GroupResponse `json:"group,omitempty"`
LastSeq int64 `json:"last_seq"`
LastMessage *MessageResponse `json:"last_message"`
LastMessageAt string `json:"last_message_at"`
UnreadCount int `json:"unread_count"`
Participants []*UserResponse `json:"participants,omitempty"` // 聊时使用
MemberCount int `json:"member_count,omitempty"` // 群聊时使用
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// ConversationParticipantResponse 会话参与者响应
type ConversationParticipantResponse struct {
UserID string `json:"user_id"`
LastReadSeq int64 `json:"last_read_seq"`
Muted bool `json:"muted"`
IsPinned bool `json:"is_pinned"`
UserID string `json:"user_id"`
LastReadSeq int64 `json:"last_read_seq"`
NotificationMuted bool `json:"notification_muted"`
IsPinned bool `json:"is_pinned"`
}
// ==================== Auth DTOs ====================
@@ -393,8 +381,9 @@ type CreateConversationRequest struct {
// SendMessageRequest 发送消息请求
type SendMessageRequest struct {
Segments model.MessageSegments `json:"segments" binding:"required"` // 消息链(必须)
ReplyToID *string `json:"reply_to_id,omitempty"` // 回复的消息ID (string类型)
Segments model.MessageSegments `json:"segments" binding:"required"` // 消息链(必须)
ReplyToID *string `json:"reply_to_id,omitempty"` // 回复的消息ID (string类型)
ClientMsgID string `json:"client_msg_id,omitempty"` // 客户端消息ID幂等性去重
}
// MarkReadRequest 标记已读请求
@@ -402,6 +391,23 @@ type MarkReadRequest struct {
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 设置会话置顶请求
type SetConversationPinnedRequest struct {
ConversationID string `json:"conversation_id" binding:"required"`
@@ -420,18 +426,19 @@ type ConversationListResponse struct {
// ConversationDetailResponse 会话详情响应
type ConversationDetailResponse struct {
ID string `json:"id"`
Type string `json:"type"`
IsPinned bool `json:"is_pinned"`
LastSeq int64 `json:"last_seq"`
LastMessage *MessageResponse `json:"last_message"`
LastMessageAt string `json:"last_message_at"`
UnreadCount int64 `json:"unread_count"`
Participants []*UserResponse `json:"participants"`
MyLastReadSeq int64 `json:"my_last_read_seq"` // 当前用户的已读位置
OtherLastReadSeq int64 `json:"other_last_read_seq"` // 对方用户的已读位置
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID string `json:"id"`
Type string `json:"type"`
IsPinned bool `json:"is_pinned"`
NotificationMuted bool `json:"notification_muted"`
LastSeq int64 `json:"last_seq"`
LastMessage *MessageResponse `json:"last_message"`
LastMessageAt string `json:"last_message_at"`
UnreadCount int64 `json:"unread_count"`
Participants []*UserResponse `json:"participants"`
MyLastReadSeq int64 `json:"my_last_read_seq"` // 当前用户的已读位置
OtherLastReadSeq int64 `json:"other_last_read_seq"` // 对方用户的已读位置
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// UnreadCountResponse 未读数响应
@@ -445,6 +452,18 @@ type ConversationUnreadCountResponse struct {
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 消息列表响应
type MessageListResponse struct {
Messages []*MessageResponse `json:"messages"`
@@ -480,6 +499,22 @@ type DeviceTokenResponse struct {
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 ====================
// PushRecordResponse 推送记录响应
@@ -504,15 +539,15 @@ type PushRecordListResponse struct {
// SystemMessageResponse 系统消息响应
type SystemMessageResponse struct {
ID string `json:"id"`
SenderID string `json:"sender_id"`
ReceiverID string `json:"receiver_id"`
Content string `json:"content"`
Category string `json:"category"`
SystemType string `json:"system_type"`
ExtraData map[string]interface{} `json:"extra_data,omitempty"`
IsRead bool `json:"is_read"`
CreatedAt time.Time `json:"created_at"`
ID string `json:"id"`
SenderID string `json:"sender_id"`
ReceiverID string `json:"receiver_id"`
Content string `json:"content"`
Category string `json:"category"`
SystemType string `json:"system_type"`
ExtraData map[string]any `json:"extra_data,omitempty"`
IsRead bool `json:"is_read"`
CreatedAt time.Time `json:"created_at"`
}
// SystemMessageListResponse 系统消息列表响应
@@ -528,6 +563,14 @@ type SystemUnreadCountResponse struct {
UnreadCount int64 `json:"unread_count"`
}
// SystemMessageCursorPageResponse 系统消息游标分页响应
type SystemMessageCursorPageResponse struct {
Items []*SystemMessageResponse `json:"list"`
NextCursor string `json:"next_cursor"`
PrevCursor string `json:"prev_cursor"`
HasMore bool `json:"has_more"`
}
// ==================== 时间格式化 ====================
// FormatTime 格式化时间
@@ -546,144 +589,30 @@ func FormatTimePointer(t *time.Time) string {
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 ====================
// WSEventResponse WebSocket事件响应结构体
// 用于后端推送消息给前端的标准格式
type WSEventResponse struct {
ID string `json:"id"` // 事件唯一ID (UUID)
Time int64 `json:"time"` // 时间戳 (毫秒)
Type string `json:"type"` // 事件类型 (message, notification, system等)
DetailType string `json:"detail_type"` // 详细类型 (private, group, like, comment等)
Seq string `json:"seq"` // 消息序列号
Segments model.MessageSegments `json:"segments"` // 消息段数组
SenderID string `json:"sender_id"` // 发送者用户ID
ID string `json:"id"` // 事件唯一ID (UUID)
Time int64 `json:"time"` // 时间戳 (毫秒)
Type string `json:"type"` // 事件类型 (message, notification, system等)
DetailType string `json:"detail_type"` // 详细类型 (private, group, like, comment等)
ConversationID string `json:"conversation_id"` // 会话ID
Seq int64 `json:"seq"` // 消息序列号
Segments model.MessageSegments `json:"segments"` // 消息段数组
SenderID string `json:"sender_id"` // 发送者用户ID
}
// ==================== WebSocket Request DTOs ====================
// SendMessageParams 发送消息参数(用于 REST API
type SendMessageParams struct {
DetailType string `json:"detail_type"` // 消息类型: private, group
ConversationID string `json:"conversation_id"` // 会话ID
Segments model.MessageSegments `json:"segments"` // 消息内容(消息段数组)
ReplyToID *string `json:"reply_to_id,omitempty"` // 回复的消息ID
DetailType string `json:"detail_type"` // 消息类型: private, group
ConversationID string `json:"conversation_id"` // 会话ID
Segments model.MessageSegments `json:"segments"` // 消息内容(消息段数组)
ReplyToID *string `json:"reply_to_id,omitempty"` // 回复的消息ID
ClientMsgID string `json:"client_msg_id,omitempty"` // 客户端消息ID幂等性去重
}
// DeleteMsgParams 撤回消息参数
@@ -732,6 +661,12 @@ type SetGroupAvatarParams struct {
Avatar string `json:"avatar"` // 头像URL
}
// SetGroupDescriptionParams 设置群描述参数
type SetGroupDescriptionParams struct {
GroupID string `json:"group_id"` // 群组ID
Description string `json:"description"` // 群描述
}
// SetGroupLeaveParams 退出群组参数
type SetGroupLeaveParams struct {
GroupID string `json:"group_id"` // 群组ID
@@ -854,92 +789,68 @@ type GetMyMemberInfoParams struct {
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"`
CommunityID string `json:"community_id"`
Images []string `json:"images"`
VoteOptions []string `json:"vote_options" binding:"required,min=2,max=10"` // 投票选项至少2个最多10个
}
// 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 ====================
// WSResponse WebSocket响应结构体
type WSResponse struct {
Success bool `json:"success"` // 是否成功
Action string `json:"action"` // 响应原action
Data interface{} `json:"data,omitempty"` // 响应数据
Error string `json:"error,omitempty"` // 错误信息
Success bool `json:"success"` // 是否成功
Action string `json:"action"` // 响应原action
Data any `json:"data,omitempty"` // 响应数据
Error string `json:"error,omitempty"` // 错误信息
}
// ==================== Admin Post DTOs ====================
// AdminPostListResponse 管理端帖子列表响应
type AdminPostListResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Title string `json:"title"`
Content string `json:"content"`
Images []PostImageResponse `json:"images"`
Status string `json:"status"`
LikesCount int `json:"likes_count,omitzero"`
CommentsCount int `json:"comments_count,omitzero"`
FavoritesCount int `json:"favorites_count,omitzero"`
ViewsCount int `json:"views_count,omitzero"`
IsPinned bool `json:"is_pinned"`
IsFeatured bool `json:"is_featured"`
IsLocked bool `json:"is_locked"`
RejectReason string `json:"reject_reason,omitempty"`
ReviewedAt string `json:"reviewed_at,omitempty"`
ReviewedBy string `json:"reviewed_by,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Author *UserResponse `json:"author"`
ID string `json:"id"`
UserID string `json:"user_id"`
Title string `json:"title"`
Content string `json:"content"`
Images []PostImageResponse `json:"images"`
Status string `json:"status"`
LikesCount int `json:"likes_count,omitzero"`
CommentsCount int `json:"comments_count,omitzero"`
FavoritesCount int `json:"favorites_count,omitzero"`
ViewsCount int `json:"views_count,omitzero"`
IsPinned bool `json:"is_pinned"`
IsFeatured bool `json:"is_featured"`
IsLocked bool `json:"is_locked"`
RejectReason string `json:"reject_reason,omitempty"`
ReviewedAt string `json:"reviewed_at,omitempty"`
ReviewedBy string `json:"reviewed_by,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ContentEditedAt string `json:"content_edited_at,omitempty"`
Author *UserResponse `json:"author"`
}
// AdminPostDetailResponse 管理端帖子详情响应
type AdminPostDetailResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
CommunityID string `json:"community_id"`
Title string `json:"title"`
Content string `json:"content"`
Images []PostImageResponse `json:"images"`
Status string `json:"status"`
LikesCount int `json:"likes_count,omitzero"`
CommentsCount int `json:"comments_count,omitzero"`
FavoritesCount int `json:"favorites_count,omitzero"`
SharesCount int `json:"shares_count,omitzero"`
ViewsCount int `json:"views_count,omitzero"`
HotScore float64 `json:"hot_score"`
IsPinned bool `json:"is_pinned"`
IsFeatured bool `json:"is_featured"`
IsLocked bool `json:"is_locked"`
IsVote bool `json:"is_vote"`
RejectReason string `json:"reject_reason,omitempty"`
ReviewedAt string `json:"reviewed_at,omitempty"`
ReviewedBy string `json:"reviewed_by,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Author *UserResponse `json:"author"`
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,omitzero"`
CommentsCount int `json:"comments_count,omitzero"`
FavoritesCount int `json:"favorites_count,omitzero"`
SharesCount int `json:"shares_count,omitzero"`
ViewsCount int `json:"views_count,omitzero"`
IsPinned bool `json:"is_pinned"`
IsFeatured bool `json:"is_featured"`
IsLocked bool `json:"is_locked"`
IsVote bool `json:"is_vote"`
RejectReason string `json:"reject_reason,omitempty"`
ReviewedAt string `json:"reviewed_at,omitempty"`
ReviewedBy string `json:"reviewed_by,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ContentEditedAt string `json:"content_edited_at,omitempty"`
Author *UserResponse `json:"author"`
}
// AdminModeratePostRequest 审核帖子请求
@@ -950,12 +861,12 @@ type AdminModeratePostRequest struct {
// AdminBatchDeletePostsRequest 批量删除帖子请求
type AdminBatchDeletePostsRequest struct {
IDs []string `json:"ids" binding:"required,min=1"`
IDs []string `json:"ids" binding:"required,min=1,max=100,dive,required"`
}
// AdminBatchModeratePostsRequest 批量审核帖子请求
type AdminBatchModeratePostsRequest struct {
IDs []string `json:"ids" binding:"required,min=1"`
IDs []string `json:"ids" binding:"required,min=1,max=100,dive,required"`
Status string `json:"status" binding:"required,oneof=published rejected"`
}
@@ -988,6 +899,9 @@ type AdminCommentListResponse struct {
Content string `json:"content"`
Images []CommentImageResponse `json:"images"`
Status string `json:"status"`
RejectReason string `json:"reject_reason,omitempty"`
ReviewedAt string `json:"reviewed_at,omitempty"`
ReviewedBy string `json:"reviewed_by,omitempty"`
LikesCount int `json:"likes_count"`
RepliesCount int `json:"replies_count"`
CreatedAt string `json:"created_at"`
@@ -1012,18 +926,33 @@ type AdminCommentDetailResponse struct {
Content string `json:"content"`
Images []CommentImageResponse `json:"images"`
Status string `json:"status"`
RejectReason string `json:"reject_reason,omitempty"`
ReviewedAt string `json:"reviewed_at,omitempty"`
ReviewedBy string `json:"reviewed_by,omitempty"`
LikesCount int `json:"likes_count"`
RepliesCount int `json:"replies_count"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Author *UserResponse `json:"author"`
Post *AdminCommentPostInfo `json:"post"`
ReplyToUser *UserResponse `json:"reply_to_user,omitempty"` // 被回复的用户信息
ReplyToUser *UserResponse `json:"reply_to_user,omitempty"`
}
// AdminBatchDeleteCommentsRequest 批量删除评论请求
type AdminBatchDeleteCommentsRequest struct {
IDs []string `json:"ids" binding:"required,min=1"`
IDs []string `json:"ids" binding:"required,min=1,max=100,dive,required"`
}
// AdminModerateCommentRequest 审核评论请求
type AdminModerateCommentRequest struct {
Status string `json:"status" binding:"required,oneof=published rejected"`
Reason string `json:"reason"`
}
// AdminBatchModerateCommentsRequest 批量审核评论请求
type AdminBatchModerateCommentsRequest struct {
IDs []string `json:"ids" binding:"required,min=1,max=100,dive,required"`
Status string `json:"status" binding:"required,oneof=published rejected"`
}
// ==================== Admin Group DTOs ====================
@@ -1119,6 +1048,9 @@ type DashboardStatsResponse struct {
PendingReview int64 `json:"pending_review"` // 待审核总数
PendingPostsCount int64 `json:"pending_posts_count"` // 待审核帖子数
PendingComments int64 `json:"pending_comments_count"` // 待审核评论数
PendingAvatars int64 `json:"pending_avatars"` // 待审核头像数
PendingCovers int64 `json:"pending_covers"` // 待审核背景图数
PendingBios int64 `json:"pending_bios"` // 待审核签名数
}
// UserActivityTrendResponse 用户活跃度趋势响应
@@ -1179,3 +1111,236 @@ type PendingContentAuthor struct {
type OnlineUsersResponse struct {
Count int64 `json:"count"` // 在线用户数
}
// ==================== Cursor Pagination DTOs ====================
// CursorPageRequest 游标分页请求
type CursorPageRequest struct {
Cursor string `json:"cursor" form:"cursor"` // 游标(首次请求为空)
Direction string `json:"direction" form:"direction"` // forward | backward
PageSize int `json:"page_size" form:"page_size"` // 每页数量
}
// CursorPageResponse 游标分页响应(泛型版本)
// 注意:由于 Go 的限制,这里使用 any 作为 Items 类型
// 实际使用时可以创建特定类型的响应结构体
type CursorPageResponse struct {
Items any `json:"list"`
NextCursor string `json:"next_cursor,omitempty"` // 下一页游标
PrevCursor string `json:"prev_cursor,omitempty"` // 上一页游标(可选)
HasMore bool `json:"has_more"` // 是否有更多数据
}
// CursorPageMeta 分页元信息
type CursorPageMeta struct {
PageSize int `json:"page_size"`
Direction string `json:"direction"`
}
// PostCursorPageResponse 帖子游标分页响应
type PostCursorPageResponse struct {
Items []*PostResponse `json:"list"`
NextCursor string `json:"next_cursor,omitempty"`
PrevCursor string `json:"prev_cursor,omitempty"`
HasMore bool `json:"has_more"`
IsBlocked bool `json:"is_blocked,omitempty"`
}
// CommentCursorPageResponse 评论游标分页响应
type CommentCursorPageResponse struct {
Items []*CommentResponse `json:"list"`
NextCursor string `json:"next_cursor,omitempty"`
PrevCursor string `json:"prev_cursor,omitempty"`
HasMore bool `json:"has_more"`
}
// MessageCursorPageResponse 消息游标分页响应
type MessageCursorPageResponse struct {
Items []*MessageResponse `json:"list"`
NextCursor string `json:"next_cursor,omitempty"`
PrevCursor string `json:"prev_cursor,omitempty"`
HasMore bool `json:"has_more"`
}
// ConversationCursorPageResponse 会话游标分页响应
type ConversationCursorPageResponse struct {
Items []*ConversationResponse `json:"list"`
NextCursor string `json:"next_cursor,omitempty"`
PrevCursor string `json:"prev_cursor,omitempty"`
HasMore bool `json:"has_more"`
}
// GroupCursorPageResponse 群组游标分页响应
type GroupCursorPageResponse struct {
Items []*GroupResponse `json:"list"`
NextCursor string `json:"next_cursor,omitempty"`
PrevCursor string `json:"prev_cursor,omitempty"`
HasMore bool `json:"has_more"`
}
// NotificationCursorPageResponse 通知游标分页响应
type NotificationCursorPageResponse struct {
Items []*NotificationResponse `json:"list"`
NextCursor string `json:"next_cursor,omitempty"`
PrevCursor string `json:"prev_cursor,omitempty"`
HasMore bool `json:"has_more"`
}
// GroupMemberCursorPageResponse 群成员游标分页响应
type GroupMemberCursorPageResponse struct {
Items []*GroupMemberResponse `json:"list"`
NextCursor string `json:"next_cursor,omitempty"`
PrevCursor string `json:"prev_cursor,omitempty"`
HasMore bool `json:"has_more"`
}
// GroupAnnouncementCursorPageResponse 群公告游标分页响应
type GroupAnnouncementCursorPageResponse struct {
Items []*GroupAnnouncementResponse `json:"list"`
NextCursor string `json:"next_cursor,omitempty"`
PrevCursor string `json:"prev_cursor,omitempty"`
HasMore bool `json:"has_more"`
}
// ==================== Verification DTOs ====================
// VerificationStatus 审核状态
type VerificationStatusDTO string
const (
VerificationStatusNotSubmittedDTO VerificationStatusDTO = "not_submitted"
VerificationStatusPendingDTO VerificationStatusDTO = "pending"
VerificationStatusApprovedDTO VerificationStatusDTO = "approved"
VerificationStatusRejectedDTO VerificationStatusDTO = "rejected"
)
// UserIdentityDTO 用户身份类型
type UserIdentityDTO string
const (
UserIdentityGeneralDTO UserIdentityDTO = "general"
UserIdentityStudentDTO UserIdentityDTO = "student"
UserIdentityTeacherDTO UserIdentityDTO = "teacher"
UserIdentityStaffDTO UserIdentityDTO = "staff"
UserIdentityAlumniDTO UserIdentityDTO = "alumni"
UserIdentityExternalDTO UserIdentityDTO = "external"
)
// SubmitVerificationRequest 提交认证申请请求
type SubmitVerificationRequest struct {
Identity UserIdentityDTO `json:"identity" binding:"required,oneof=student teacher staff alumni"`
RealName string `json:"real_name" binding:"required,max=100"`
StudentID string `json:"student_id" binding:"omitempty,max=50"`
EmployeeID string `json:"employee_id" binding:"omitempty,max=50"`
Department string `json:"department" binding:"omitempty,max=200"`
ProofMaterials string `json:"proof_materials" binding:"required"`
}
// VerificationRecordResponse 认证记录响应
type VerificationRecordResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Identity string `json:"identity"`
Status string `json:"status"`
RealName string `json:"real_name"`
StudentID string `json:"student_id,omitempty"`
EmployeeID string `json:"employee_id,omitempty"`
Department string `json:"department,omitempty"`
ProofMaterials string `json:"proof_materials"`
ReviewedAt string `json:"reviewed_at,omitempty"`
ReviewedBy string `json:"reviewed_by,omitempty"`
RejectReason string `json:"reject_reason,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// VerificationStatusResponse 认证状态响应
type VerificationStatusResponse struct {
Identity string `json:"identity"`
VerificationStatus string `json:"verification_status"`
HasPendingRequest bool `json:"has_pending_request"`
LatestRecord *VerificationRecordResponse `json:"latest_record,omitempty"`
}
// AdminVerificationListRequest 管理端认证列表请求
type AdminVerificationListRequest struct {
Page int `form:"page" binding:"omitempty,min=1"`
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"`
Status string `form:"status" binding:"omitempty,oneof=pending approved rejected"`
Keyword string `form:"keyword" binding:"omitempty,max=100"`
}
// AdminVerificationListResponse 管理端认证列表响应
type AdminVerificationListResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Identity string `json:"identity"`
Status string `json:"status"`
RealName string `json:"real_name"`
StudentID string `json:"student_id,omitempty"`
EmployeeID string `json:"employee_id,omitempty"`
Department string `json:"department,omitempty"`
CreatedAt string `json:"created_at"`
ReviewedAt string `json:"reviewed_at,omitempty"`
ReviewerName string `json:"reviewer_name,omitempty"`
}
// AdminVerificationDetailResponse 管理端认证详情响应
type AdminVerificationDetailResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Identity string `json:"identity"`
Status string `json:"status"`
RealName string `json:"real_name"`
StudentID string `json:"student_id,omitempty"`
EmployeeID string `json:"employee_id,omitempty"`
Department string `json:"department,omitempty"`
ProofMaterials string `json:"proof_materials"`
CreatedAt string `json:"created_at"`
ReviewedAt string `json:"reviewed_at,omitempty"`
ReviewedBy string `json:"reviewed_by,omitempty"`
ReviewerName string `json:"reviewer_name,omitempty"`
RejectReason string `json:"reject_reason,omitempty"`
}
// AdminReviewVerificationRequest 管理端审核认证请求
type AdminReviewVerificationRequest struct {
Approve bool `json:"approve"`
RejectReason string `json:"reject_reason" binding:"omitempty,max=500"`
}
// ==================== Privacy & Account Deletion DTOs ====================
// PrivacySettingsRequest 隐私设置请求
type PrivacySettingsRequest struct {
FollowersVisibility *string `json:"followers_visibility" binding:"omitempty,oneof=everyone following self"`
FollowingVisibility *string `json:"following_visibility" binding:"omitempty,oneof=everyone following self"`
PostsVisibility *string `json:"posts_visibility" binding:"omitempty,oneof=everyone following self"`
FavoritesVisibility *string `json:"favorites_visibility" binding:"omitempty,oneof=everyone following self"`
}
// PrivacySettingsResponse 隐私设置响应
type PrivacySettingsResponse struct {
FollowersVisibility string `json:"followers_visibility"`
FollowingVisibility string `json:"following_visibility"`
PostsVisibility string `json:"posts_visibility"`
FavoritesVisibility string `json:"favorites_visibility"`
}
// RequestDeletionRequest 申请注销请求
type RequestDeletionRequest struct {
Password string `json:"password" binding:"required"`
}
// DeletionStatusResponse 注销状态响应
type DeletionStatusResponse struct {
IsPendingDeletion bool `json:"is_pending_deletion"`
DeletionRequestedAt string `json:"deletion_requested_at,omitempty"`
CoolDownDays int `json:"cool_down_days,omitempty"`
}

View File

@@ -1,7 +1,7 @@
package dto
package dto
import (
"carrot_bbs/internal/model"
"with_you/internal/model"
)
// ==================== Group 转换 ====================
@@ -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
func GroupAnnouncementToResponse(announcement *model.GroupAnnouncement) *GroupAnnouncementResponse {
@@ -94,3 +74,30 @@ func GroupAnnouncementsToResponse(announcements []model.GroupAnnouncement) []*Gr
}
return result
}
// GroupsToResponseFromPtr 将Group指针切片转换为GroupResponse列表
func GroupsToResponseFromPtr(groups []*model.Group) []*GroupResponse {
result := make([]*GroupResponse, 0, len(groups))
for _, group := range groups {
result = append(result, GroupToResponse(group))
}
return result
}
// GroupMembersToResponseFromPtr 将GroupMember指针切片转换为GroupMemberResponse列表
func GroupMembersToResponseFromPtr(members []*model.GroupMember) []*GroupMemberResponse {
result := make([]*GroupMemberResponse, 0, len(members))
for _, member := range members {
result = append(result, GroupMemberToResponse(member))
}
return result
}
// GroupAnnouncementsToResponseFromPtr 将GroupAnnouncement指针切片转换为GroupAnnouncementResponse列表
func GroupAnnouncementsToResponseFromPtr(announcements []*model.GroupAnnouncement) []*GroupAnnouncementResponse {
result := make([]*GroupAnnouncementResponse, 0, len(announcements))
for _, announcement := range announcements {
result = append(result, GroupAnnouncementToResponse(announcement))
}
return result
}

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

@@ -1,13 +1,20 @@
package dto
package dto
import (
"carrot_bbs/internal/model"
"with_you/internal/model"
)
// ==================== Message 转换 ====================
// ConvertMessageToResponse 将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 {
return nil
}
@@ -15,6 +22,19 @@ func ConvertMessageToResponse(message *model.Message) *MessageResponse {
// 直接使用 segments不需要解析
segments := make(model.MessageSegments, len(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{
Type: seg.Type,
Data: seg.Data,
@@ -38,7 +58,7 @@ func ConvertMessageToResponse(message *model.Message) *MessageResponse {
// participants: 会话参与者列表(用户信息)
// unreadCount: 当前用户的未读消息数
// lastMessage: 最后一条消息
func ConvertConversationToResponse(conv *model.Conversation, participants []*model.User, unreadCount int, lastMessage *model.Message, isPinned bool) *ConversationResponse {
func ConvertConversationToResponse(conv *model.Conversation, participants []*model.User, unreadCount int, lastMessage *model.Message, isPinned bool, notificationMuted bool) *ConversationResponse {
if conv == nil {
return nil
}
@@ -55,22 +75,23 @@ func ConvertConversationToResponse(conv *model.Conversation, participants []*mod
}
return &ConversationResponse{
ID: conv.ID,
Type: string(conv.Type),
IsPinned: isPinned,
Group: groupResponse,
LastSeq: conv.LastSeq,
LastMessage: ConvertMessageToResponse(lastMessage),
LastMessageAt: FormatTimePointer(conv.LastMsgTime),
UnreadCount: unreadCount,
Participants: participantResponses,
CreatedAt: FormatTime(conv.CreatedAt),
UpdatedAt: FormatTime(conv.UpdatedAt),
ID: conv.ID,
Type: string(conv.Type),
IsPinned: isPinned,
NotificationMuted: notificationMuted,
Group: groupResponse,
LastSeq: conv.LastSeq,
LastMessage: ConvertMessageToResponse(lastMessage),
LastMessageAt: FormatTimePointer(conv.LastMsgTime),
UnreadCount: unreadCount,
Participants: participantResponses,
CreatedAt: FormatTime(conv.CreatedAt),
UpdatedAt: FormatTime(conv.UpdatedAt),
}
}
// ConvertConversationToDetailResponse 将Conversation转换为ConversationDetailResponse
func ConvertConversationToDetailResponse(conv *model.Conversation, participants []*model.User, unreadCount int64, lastMessage *model.Message, myLastReadSeq int64, otherLastReadSeq int64, isPinned bool) *ConversationDetailResponse {
func ConvertConversationToDetailResponse(conv *model.Conversation, participants []*model.User, unreadCount int64, lastMessage *model.Message, myLastReadSeq int64, otherLastReadSeq int64, isPinned bool, notificationMuted bool) *ConversationDetailResponse {
if conv == nil {
return nil
}
@@ -81,18 +102,19 @@ func ConvertConversationToDetailResponse(conv *model.Conversation, participants
}
return &ConversationDetailResponse{
ID: conv.ID,
Type: string(conv.Type),
IsPinned: isPinned,
LastSeq: conv.LastSeq,
LastMessage: ConvertMessageToResponse(lastMessage),
LastMessageAt: FormatTimePointer(conv.LastMsgTime),
UnreadCount: unreadCount,
Participants: participantResponses,
MyLastReadSeq: myLastReadSeq,
OtherLastReadSeq: otherLastReadSeq,
CreatedAt: FormatTime(conv.CreatedAt),
UpdatedAt: FormatTime(conv.UpdatedAt),
ID: conv.ID,
Type: string(conv.Type),
IsPinned: isPinned,
NotificationMuted: notificationMuted,
LastSeq: conv.LastSeq,
LastMessage: ConvertMessageToResponse(lastMessage),
LastMessageAt: FormatTimePointer(conv.LastMsgTime),
UnreadCount: unreadCount,
Participants: participantResponses,
MyLastReadSeq: myLastReadSeq,
OtherLastReadSeq: otherLastReadSeq,
CreatedAt: FormatTime(conv.CreatedAt),
UpdatedAt: FormatTime(conv.UpdatedAt),
}
}
@@ -105,15 +127,16 @@ func ConvertMessagesToResponse(messages []*model.Message) []*MessageResponse {
return result
}
// ConvertConversationsToResponse 将Conversation列表转换为响应列表
func ConvertConversationsToResponse(convs []*model.Conversation) []*ConversationResponse {
result := make([]*ConversationResponse, 0, len(convs))
for _, conv := range convs {
result = append(result, ConvertConversationToResponse(conv, nil, 0, nil, false))
// ConvertMessagesToResponseWithExpiry 将Message列表转换为响应列表,并标记过期文件
func ConvertMessagesToResponseWithExpiry(messages []*model.Message, expiredURLSet map[string]struct{}) []*MessageResponse {
result := make([]*MessageResponse, 0, len(messages))
for _, msg := range messages {
result = append(result, ConvertMessageToResponseWithExpiry(msg, expiredURLSet))
}
return result
}
// ==================== PushRecord 转换 ====================
// PushRecordToResponse 将PushRecord转换为PushRecordResponse
@@ -176,3 +199,34 @@ func DeviceTokensToResponse(tokens []*model.DeviceToken) []*DeviceTokenResponse
}
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

@@ -1,7 +1,7 @@
package dto
package dto
import (
"carrot_bbs/internal/model"
"with_you/internal/model"
"strconv"
)
@@ -33,49 +33,6 @@ func ConvertNotificationsToResponse(notifications []*model.Notification) []*Noti
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]interface{}{
"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
func SystemNotificationToResponse(n *model.SystemNotification) *SystemMessageResponse {
if n == nil {
@@ -92,7 +49,7 @@ func SystemNotificationToResponse(n *model.SystemNotification) *SystemMessageRes
CreatedAt: n.CreatedAt,
}
if n.ExtraData != nil {
resp.ExtraData = map[string]interface{}{
resp.ExtraData = map[string]any{
"actor_id": n.ExtraData.ActorID,
"actor_id_str": n.ExtraData.ActorIDStr,
"actor_name": n.ExtraData.ActorName,
@@ -118,11 +75,3 @@ func SystemNotificationToResponse(n *model.SystemNotification) *SystemMessageRes
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

@@ -1,9 +1,9 @@
package dto
import (
"carrot_bbs/internal/model"
"context"
"encoding/json"
"with_you/internal/model"
)
// ==================== Post 转换 ====================
@@ -14,11 +14,13 @@ func ConvertPostImageToResponse(img *model.PostImage) PostImageResponse {
return PostImageResponse{}
}
return PostImageResponse{
ID: img.ID,
URL: img.URL,
ThumbnailURL: img.ThumbnailURL,
Width: img.Width,
Height: img.Height,
ID: img.ID,
URL: img.URL,
ThumbnailURL: img.ThumbnailURL,
PreviewURL: img.PreviewURL,
PreviewURLLarge: img.PreviewURLLarge,
Width: img.Width,
Height: img.Height,
}
}
@@ -31,8 +33,56 @@ func ConvertPostImagesToResponse(images []model.PostImage) []PostImageResponse {
return result
}
// ConvertPostToResponse 将Post转换为PostResponse列表用
func ConvertPostToResponse(post *model.Post, isLiked, isFavorited bool) *PostResponse {
func postChannelBrief(post *model.Post, channelByID map[string]*model.Channel) *PostChannelBrief {
if post == nil || post.ChannelID == nil || *post.ChannelID == "" || channelByID == nil {
return nil
}
ch := channelByID[*post.ChannelID]
if ch == nil {
return nil
}
return &PostChannelBrief{ID: ch.ID, Name: ch.Name}
}
// CollectPostChannelIDs 从帖子列表收集去重后的频道 ID用于批量查库填充 channel 名称)
func CollectPostChannelIDs(posts []*model.Post) []string {
seen := make(map[string]struct{})
out := make([]string, 0)
for _, p := range posts {
if p == nil || p.ChannelID == nil || *p.ChannelID == "" {
continue
}
id := *p.ChannelID
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
// PostChannelBriefForPost 根据帖子与频道映射生成频道摘要(供 Handler 手动组装响应)
func PostChannelBriefForPost(post *model.Post, channelByID map[string]*model.Channel) *PostChannelBrief {
return postChannelBrief(post, channelByID)
}
// SegmentsOrDefault 如果 segments 为空但 content 非空,
// 返回降级的单 text segment否则原样返回。
func SegmentsOrDefault(segments model.MessageSegments, content string) model.MessageSegments {
if len(segments) > 0 {
return segments
}
if content != "" {
return model.MessageSegments{
{Type: string(SegmentTypeText), Data: map[string]any{"text": content}},
}
}
return nil
}
// ConvertPostToResponse 将Post转换为PostResponse列表用channelByID 可为 nil
func ConvertPostToResponse(post *model.Post, channelByID map[string]*model.Channel, isLiked, isFavorited bool) *PostResponse {
if post == nil {
return nil
}
@@ -48,69 +98,34 @@ func ConvertPostToResponse(post *model.Post, isLiked, isFavorited bool) *PostRes
}
return &PostResponse{
ID: post.ID,
UserID: post.UserID,
Title: post.Title,
Content: 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),
Author: author,
IsLiked: isLiked,
IsFavorited: isFavorited,
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),
}
}
// ConvertPostToDetailResponse 将Post转换为PostDetailResponse
func ConvertPostToDetailResponse(post *model.Post, 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,
Title: post.Title,
Content: 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),
Author: author,
IsLiked: isLiked,
IsFavorited: isFavorited,
}
}
// ConvertPostsToResponse 将Post列表转换为响应列表每个帖子独立检查点赞/收藏状态)
func ConvertPostsToResponse(posts []*model.Post, isLikedMap, isFavoritedMap map[string]bool) []*PostResponse {
// ConvertPostsToResponse 将Post列表转换为响应列表channelByID 可为 nil
func ConvertPostsToResponse(posts []*model.Post, channelByID map[string]*model.Channel, isLikedMap, isFavoritedMap map[string]bool) []*PostResponse {
result := make([]*PostResponse, 0, len(posts))
for _, post := range posts {
isLiked := false
@@ -121,21 +136,21 @@ func ConvertPostsToResponse(posts []*model.Post, isLikedMap, isFavoritedMap map[
if isFavoritedMap != nil {
isFavorited = isFavoritedMap[post.ID]
}
result = append(result, ConvertPostToResponse(post, isLiked, isFavorited))
result = append(result, ConvertPostToResponse(post, channelByID, isLiked, isFavorited))
}
return result
}
// BuildPostResponse 构建单个帖子响应(包含交互状态)
// 这是一个语义化的辅助函数,便于 Handler 层调用
func BuildPostResponse(post *model.Post, isLiked, isFavorited bool) *PostResponse {
return ConvertPostToResponse(post, isLiked, isFavorited)
func BuildPostResponse(post *model.Post, channelByID map[string]*model.Channel, isLiked, isFavorited bool) *PostResponse {
return ConvertPostToResponse(post, channelByID, isLiked, isFavorited)
}
// BuildPostsWithInteractionResponse 批量构建帖子响应(包含交互状态)
// 这是一个语义化的辅助函数,便于 Handler 层调用
func BuildPostsWithInteractionResponse(posts []*model.Post, isLikedMap, isFavoritedMap map[string]bool) []*PostResponse {
return ConvertPostsToResponse(posts, isLikedMap, isFavoritedMap)
func BuildPostsWithInteractionResponse(posts []*model.Post, channelByID map[string]*model.Channel, isLikedMap, isFavoritedMap map[string]bool) []*PostResponse {
return ConvertPostsToResponse(posts, channelByID, isLikedMap, isFavoritedMap)
}
// ==================== Comment 转换 ====================
@@ -185,6 +200,7 @@ func ConvertCommentToResponse(comment *model.Comment, isLiked bool) *CommentResp
ParentID: comment.ParentID,
RootID: comment.RootID,
Content: comment.Content,
Segments: SegmentsOrDefault(comment.Segments, comment.Content),
Images: images,
LikesCount: comment.LikesCount,
RepliesCount: comment.RepliesCount,
@@ -196,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 点赞状态检查器接口
type IsLikedChecker interface {
@@ -261,6 +269,7 @@ func ConvertCommentToResponseWithUser(comment *model.Comment, userID string, che
ParentID: comment.ParentID,
RootID: comment.RootID,
Content: comment.Content,
Segments: SegmentsOrDefault(comment.Segments, comment.Content),
Images: images,
LikesCount: comment.LikesCount,
RepliesCount: comment.RepliesCount,

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

171
internal/dto/report.go Normal file
View File

@@ -0,0 +1,171 @@
package dto
import (
"with_you/internal/model"
)
// ==================== 举报 DTOs ====================
// CreateReportRequest 创建举报请求
type CreateReportRequest struct {
TargetType string `json:"target_type" binding:"required,oneof=post comment message"`
TargetID string `json:"target_id" binding:"required"`
Reason string `json:"reason" binding:"required,oneof=spam inappropriate harassment misinformation other"`
Description string `json:"description" binding:"max=500"`
}
// ReportResponse 举报响应
type ReportResponse struct {
ID string `json:"id"`
ReporterID string `json:"reporter_id"`
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
Reason string `json:"reason"`
Description string `json:"description,omitempty"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
// AdminReportListResponse 管理端举报列表响应
type AdminReportListResponse struct {
ID string `json:"id"`
ReporterID string `json:"reporter_id"`
Reporter *UserResponse `json:"reporter,omitempty"`
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
Reason string `json:"reason"`
Description string `json:"description,omitempty"`
Status string `json:"status"`
HandledBy *string `json:"handled_by,omitempty"`
Handler *UserResponse `json:"handler,omitempty"`
HandledAt *string `json:"handled_at,omitempty"`
Result *string `json:"result,omitempty"`
CreatedAt string `json:"created_at"`
}
// AdminReportDetailResponse 管理端举报详情响应
type AdminReportDetailResponse struct {
ID string `json:"id"`
ReporterID string `json:"reporter_id"`
Reporter *UserResponse `json:"reporter"`
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
Reason string `json:"reason"`
Description string `json:"description,omitempty"`
Status string `json:"status"`
HandledBy *string `json:"handled_by,omitempty"`
Handler *UserResponse `json:"handler,omitempty"`
HandledAt *string `json:"handled_at,omitempty"`
Result *string `json:"result,omitempty"`
CreatedAt string `json:"created_at"`
// 被举报内容详情
TargetContent *ReportTargetContent `json:"target_content,omitempty"`
}
// ReportTargetContent 被举报内容详情
type ReportTargetContent struct {
Type string `json:"type"` // post, comment, message
ID string `json:"id"`
Author *UserResponse `json:"author,omitempty"`
Content string `json:"content,omitempty"`
Title string `json:"title,omitempty"` // 帖子标题
Images []string `json:"images,omitempty"` // 图片列表
Status string `json:"status,omitempty"` // 内容状态
CreatedAt string `json:"created_at,omitempty"`
}
// HandleReportRequest 处理举报请求
type HandleReportRequest struct {
Action string `json:"action" binding:"required,oneof=approve reject"` // approve: 确认违规, reject: 驳回
Result string `json:"result" binding:"max=500"` // 处理结果说明
}
// BatchHandleReportRequest 批量处理举报请求
type BatchHandleReportRequest struct {
IDs []string `json:"ids" binding:"required,min=1,max=100"`
Action string `json:"action" binding:"required,oneof=approve reject"`
Result string `json:"result" binding:"max=500"`
}
// AdminBatchHandleResponse 批量处理响应
type AdminBatchHandleResponse struct {
SuccessCount int `json:"success_count"`
FailedCount int `json:"failed_count"`
FailedIDs []string `json:"failed_ids,omitempty"`
}
// ==================== 转换函数 ====================
// ConvertReportToResponse 将 Report 模型转换为响应
func ConvertReportToResponse(report *model.Report) *ReportResponse {
return &ReportResponse{
ID: report.ID,
ReporterID: report.ReporterID,
TargetType: string(report.TargetType),
TargetID: report.TargetID,
Reason: string(report.Reason),
Description: report.Description,
Status: string(report.Status),
CreatedAt: FormatTime(report.CreatedAt),
}
}
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 转换为管理端列表响应
func ConvertReportToAdminListResponse(report *model.Report, reporter *model.User, handler *model.User) *AdminReportListResponse {
resp := &AdminReportListResponse{
ID: report.ID,
ReporterID: report.ReporterID,
TargetType: string(report.TargetType),
TargetID: report.TargetID,
Reason: string(report.Reason),
Description: report.Description,
Status: string(report.Status),
HandledBy: report.HandledBy,
Result: report.Result,
CreatedAt: FormatTime(report.CreatedAt),
}
fillAdminReportCommon(resp, report, reporter, handler)
return resp
}
// ConvertReportToAdminDetailResponse 将 Report 转换为管理端详情响应
func ConvertReportToAdminDetailResponse(report *model.Report, reporter *model.User, handler *model.User, targetContent *ReportTargetContent) *AdminReportDetailResponse {
resp := &AdminReportDetailResponse{
ID: report.ID,
ReporterID: report.ReporterID,
TargetType: string(report.TargetType),
TargetID: report.TargetID,
Reason: string(report.Reason),
Description: report.Description,
Status: string(report.Status),
HandledBy: report.HandledBy,
Result: report.Result,
CreatedAt: FormatTime(report.CreatedAt),
TargetContent: targetContent,
}
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
}

View File

@@ -1,9 +1,9 @@
package dto
package dto
import (
"encoding/json"
"carrot_bbs/internal/model"
"with_you/internal/model"
)
func ConvertScheduleCourseToResponse(course *model.ScheduleCourse, weeks []int) *ScheduleCourseResponse {

View File

@@ -5,160 +5,38 @@ import (
"fmt"
"slices"
"carrot_bbs/internal/model"
"with_you/internal/model"
)
// ParseSegmentData 解析Segment数据到目标结构体
func ParseSegmentData(segment model.MessageSegment, target interface{}) error {
dataBytes, err := json.Marshal(segment.Data)
if err != nil {
return err
}
return json.Unmarshal(dataBytes, target)
}
// NewTextSegment 创建文本Segment
func NewTextSegment(content string) model.MessageSegment {
return model.MessageSegment{
Type: string(SegmentTypeText),
Data: map[string]interface{}{"text": content},
Data: map[string]any{"text": content},
}
}
// NewImageSegment 创建图片Segment
func NewImageSegment(url string, width, height int, thumbnailURL string) model.MessageSegment {
return model.MessageSegment{
Type: string(SegmentTypeImage),
Data: map[string]interface{}{
"url": url,
"width": width,
"height": height,
"thumbnail_url": thumbnailURL,
},
// ExtractVoteSegmentData 从消息链中提取投票Segment数据
func ExtractVoteSegmentData(segments model.MessageSegments) *VoteSegmentData {
for _, segment := range segments {
if segment.Type == string(SegmentTypeVote) {
dataBytes, err := json.Marshal(segment.Data)
if err != nil {
continue
}
var voteData VoteSegmentData
if err := json.Unmarshal(dataBytes, &voteData); err != nil {
continue
}
return &voteData
}
}
return nil
}
// NewImageSegmentWithSize 创建带文件大小的图片Segment
func NewImageSegmentWithSize(url string, width, height int, thumbnailURL string, fileSize int64) model.MessageSegment {
return model.MessageSegment{
Type: string(SegmentTypeImage),
Data: map[string]interface{}{
"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]interface{}{
"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]interface{}{
"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]interface{}{
"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]interface{}{
"user_id": userID,
},
}
}
// NewAtAllSegment 创建@所有人Segment
func NewAtAllSegment() model.MessageSegment {
return model.MessageSegment{
Type: string(SegmentTypeAt),
Data: map[string]interface{}{
"user_id": "all",
},
}
}
// NewReplySegment 创建回复Segment
func NewReplySegment(messageID string) model.MessageSegment {
return model.MessageSegment{
Type: string(SegmentTypeReply),
Data: map[string]interface{}{"id": messageID},
}
}
// NewFaceSegment 创建表情Segment
func NewFaceSegment(id int, name, url string) model.MessageSegment {
return model.MessageSegment{
Type: string(SegmentTypeFace),
Data: map[string]interface{}{
"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]interface{}{
"url": url,
"title": title,
"description": description,
"image": image,
},
}
}
// 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)
// HasVoteSegment 检查消息链中是否包含投票Segment
func HasVoteSegment(segments model.MessageSegments) bool {
return HasSegmentType(segments, SegmentTypeVote)
}
// ExtractTextContentFromModel 从 model.MessageSegments 中提取纯文本内容
@@ -202,6 +80,14 @@ func ExtractTextContentFromModel(segments model.MessageSegments) string {
} else {
result += "[链接]"
}
case "vote":
result += "[投票]"
case "post_ref":
if title, ok := segment.Data["title"].(string); ok && title != "" {
result += fmt.Sprintf("[帖子:%s]", title)
} else {
result += "[帖子引用]"
}
}
}
return result
@@ -231,65 +117,21 @@ func ExtractMentionedUsers(segments model.MessageSegments) []string {
return userIDs
}
// 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
// ExtractReferencedPosts 从消息链中提取所有被引用的帖子ID
func ExtractReferencedPosts(segments model.MessageSegments) []string {
var postIDs []string
seen := make(map[string]bool)
for _, segment := range segments {
if segment.Type == string(SegmentTypePostRef) {
postID, _ := segment.Data["post_id"].(string)
if postID != "" && !seen[postID] {
postIDs = append(postIDs, postID)
seen[postID] = 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
return postIDs
}
// HasSegmentType 检查消息链中是否包含指定类型的segment
@@ -299,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

@@ -0,0 +1,68 @@
package dto
import (
"with_you/internal/model"
)
func ConvertTradeImageToResponse(img *model.TradeImage) TradeImageResponse {
if img == nil {
return TradeImageResponse{}
}
return TradeImageResponse{
ID: img.ID,
URL: img.URL,
ThumbnailURL: img.ThumbnailURL,
PreviewURL: img.PreviewURL,
PreviewURLLarge: img.PreviewURLLarge,
Width: img.Width,
Height: img.Height,
}
}
func ConvertTradeImagesToResponse(images []model.TradeImage) []TradeImageResponse {
result := make([]TradeImageResponse, 0, len(images))
for i := range images {
result = append(result, ConvertTradeImageToResponse(&images[i]))
}
return result
}
func ConvertTradeItemToResponse(item *model.TradeItem, isFavorited bool) *TradeItemResponse {
if item == nil {
return nil
}
resp := &TradeItemResponse{
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: FormatTime(item.CreatedAt),
UpdatedAt: FormatTime(item.UpdatedAt),
}
if len(item.Segments) > 0 {
resp.Segments = item.Segments
}
if item.User != nil {
resp.Author = ConvertUserToResponse(item.User)
}
return resp
}
func ConvertTradeItemsToResponse(items []*model.TradeItem, favoriteMap map[string]bool) []*TradeItemResponse {
result := make([]*TradeItemResponse, 0, len(items))
for _, item := range items {
isFav := favoriteMap[item.ID]
result = append(result, ConvertTradeItemToResponse(item, isFav))
}
return result
}

72
internal/dto/trade_dto.go Normal file
View File

@@ -0,0 +1,72 @@
package dto
import (
"with_you/internal/model"
)
type TradeImageResponse 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"`
}
type TradeItemResponse 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 {
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"`
Title string `json:"title" binding:"required,max=100"`
Content string `json:"content"`
Segments model.MessageSegments `json:"segments"`
Images []string `json:"images"`
Price *float64 `json:"price"`
Condition string `json:"condition" binding:"omitempty,oneof=brand_new like_new lightly_used well_used"`
}
type UpdateTradeItemRequest struct {
TradeType *string `json:"trade_type" binding:"omitempty,oneof=sell buy"`
Category *string `json:"category" binding:"omitempty,oneof=digital book clothing daily cosmetics sport ticket other"`
Title *string `json:"title" binding:"omitempty,max=100"`
Content *string `json:"content"`
Segments model.MessageSegments `json:"segments"`
Images []string `json:"images"`
Price *float64 `json:"price"`
Condition *string `json:"condition" binding:"omitempty,oneof=brand_new like_new lightly_used well_used"`
}
type UpdateTradeItemStatusRequest struct {
Status string `json:"status" binding:"required,oneof=active sold bought offline"`
}
type TradeCursorPageResponse struct {
Items []*TradeItemResponse `json:"list"`
NextCursor string `json:"next_cursor,omitempty"`
PrevCursor string `json:"prev_cursor,omitempty"`
HasMore bool `json:"has_more"`
}
type TradeFavoriteResponse struct {
Favorited bool `json:"favorited"`
}

View File

@@ -1,179 +1,101 @@
package dto
package dto
import (
"carrot_bbs/internal/model"
"carrot_bbs/internal/pkg/utils"
"with_you/internal/model"
"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
func getAvatarOrDefault(user *model.User) string {
return utils.GetAvatarOrDefault(user.Username, user.Nickname, user.Avatar)
}
// ConvertUserToResponse 将User转换为UserResponse公开信息不包含敏感数据
func ConvertUserToResponse(user *model.User) *UserResponse {
// publicVerificationFields 返回认证信息:
// - 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 {
return nil
}
identity, vs := publicVerificationFields(user)
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,
CreatedAt: FormatTime(user.CreatedAt),
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,
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本人信息包含敏感数据
func ConvertUserToSelfResponse(user *model.User, postsCount int) *UserSelfResponse {
if user == nil {
return nil
}
return &UserSelfResponse{
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
Email: user.Email,
Phone: user.Phone,
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,
IsVerified: user.IsVerified,
CreatedAt: FormatTime(user.CreatedAt),
}
}
// 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),
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
Email: user.Email,
Phone: user.Phone,
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,
IsVerified: user.IsVerified,
CreatedAt: FormatTime(user.CreatedAt),
PrivacySettings: convertPrivacySettingsToResponse(&user.PrivacySettings),
Identity: user.Identity,
VerificationStatus: user.VerificationStatus,
}
}
@@ -183,50 +105,50 @@ func ConvertUserToDetailResponseWithPostsCount(user *model.User, postsCount int)
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),
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,
}
}
func convertPrivacySettingsToResponse(ps *model.PrivacySettings) *PrivacySettingsResponse {
if ps == nil {
return nil
}
return &PrivacySettingsResponse{
FollowersVisibility: string(ps.FollowersVisibility),
FollowingVisibility: string(ps.FollowingVisibility),
PostsVisibility: string(ps.PostsVisibility),
FavoritesVisibility: string(ps.FavoritesVisibility),
}
}
// ConvertUsersToResponse 将User列表转换为响应列表
func ConvertUsersToResponse(users []*model.User) []*UserResponse {
func ConvertUsersToResponse(users []*model.User, opts ...UserResponseOption) []*UserResponse {
result := make([]*UserResponse, 0, len(users))
for _, user := range users {
result = append(result, ConvertUserToResponse(user))
result = append(result, ConvertUserToResponse(user, opts...))
}
return result
}
// ConvertUsersToResponseWithMutualFollow 将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列表转换为响应列表包含双向关注状态和实时计算的帖子数量
// ConvertUsersToResponseWithMutualFollowAndPostsCount 将User列表转换为响应列表包含双向关注状态和帖子数量
// followingStatusMap: key是用户IDvalue是[isFollowing, isFollowingMe]
// postsCountMap: key是用户IDvalue是帖子数量
func ConvertUsersToResponseWithMutualFollowAndPostsCount(users []*model.User, followingStatusMap map[string][2]bool, postsCountMap map[string]int64) []*UserResponse {
@@ -234,17 +156,15 @@ func ConvertUsersToResponseWithMutualFollowAndPostsCount(users []*model.User, fo
for _, user := range users {
status, hasStatus := followingStatusMap[user.ID]
postsCount, hasPostsCount := postsCountMap[user.ID]
// 如果没有帖子数量,使用数据库中的值
if !hasPostsCount {
postsCount = int64(user.PostsCount)
}
var opts []UserResponseOption
if hasStatus {
result = append(result, ConvertUserToResponseWithMutualFollowAndPostsCount(user, status[0], status[1], int(postsCount)))
} else {
result = append(result, ConvertUserToResponseWithPostsCount(user, int(postsCount)))
opts = append(opts, WithFollowing(status[0], status[1]))
}
opts = append(opts, WithPostsCount(int(postsCount)))
result = append(result, ConvertUserToResponse(user, opts...))
}
return result
}
}

View File

@@ -99,6 +99,59 @@ var (
ErrCannotModifyOwnRole = &AppError{Code: "CANNOT_MODIFY_OWN_ROLE", Message: "不能修改自己的角色"}
ErrCannotModifySuperAdmin = &AppError{Code: "CANNOT_MODIFY_SUPER_ADMIN", Message: "不能修改超级管理员角色"}
ErrCasbinInternal = &AppError{Code: "CASBIN_INTERNAL_ERROR", Message: "权限系统内部错误"}
// 通话相关错误
ErrCallNotFound = &AppError{Code: "CALL_NOT_FOUND", Message: "通话不存在"}
ErrCallNotActive = &AppError{Code: "CALL_NOT_ACTIVE", Message: "通话未激活"}
ErrCallInProgress = &AppError{Code: "CALL_IN_PROGRESS", Message: "用户之间已有通话"}
ErrNotCallParticipant = &AppError{Code: "NOT_CALL_PARTICIPANT", Message: "不是通话参与者"}
ErrInvalidCallState = &AppError{Code: "INVALID_CALL_STATE", Message: "通话状态无效"}
ErrCalleeOffline = &AppError{Code: "CALLEE_OFFLINE", 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: "会话不存在或无权限"}
ErrNotConversationMember = &AppError{Code: "NOT_CONVERSATION_MEMBER", Message: "不是会话参与者"}
ErrMessageNotFound = &AppError{Code: "MESSAGE_NOT_FOUND", Message: "消息不存在"}
ErrMessageAlreadyRecalled = &AppError{Code: "MESSAGE_ALREADY_RECALLED", Message: "消息已撤回"}
ErrMessageRecallTimeout = &AppError{Code: "MESSAGE_RECALL_TIMEOUT", Message: "消息撤回超时2分钟"}
ErrCannotSendMessage = &AppError{Code: "CANNOT_SEND_MESSAGE", Message: "无法发送消息"}
ErrCannotSendImage = &AppError{Code: "CANNOT_SEND_IMAGE", Message: "对方未关注你,暂不支持发送图片"}
ErrMessageLimitReached = &AppError{Code: "MESSAGE_LIMIT_REACHED", Message: "对方未关注你前,仅允许发送一条消息"}
ErrQRCodeSessionExpired = &AppError{Code: "QRCODE_SESSION_EXPIRED", Message: "二维码已过期"}
ErrQRCodeAlreadyScanned = &AppError{Code: "QRCODE_ALREADY_SCANNED", Message: "二维码已被扫描"}
ErrQRCodeInvalidStatus = &AppError{Code: "QRCODE_INVALID_STATUS", Message: "二维码状态无效"}
ErrPushNotImplemented = &AppError{Code: "PUSH_NOT_IMPLEMENTED", Message: "推送服务未实现"}
ErrPushQueueFull = &AppError{Code: "PUSH_QUEUE_FULL", Message: "推送队列已满"}
ErrUserOffline = &AppError{Code: "USER_OFFLINE", Message: "用户不在线"}
// 学习资料相关错误
ErrSubjectHasMaterials = &AppError{Code: "SUBJECT_HAS_MATERIALS", Message: "学科下存在资料,无法删除"}
// 帖子创建幂等性:同一 client_request_id 的请求仍在处理中(占位未回填真实结果)
ErrDuplicatePostRequest = &AppError{Code: "DUPLICATE_POST_REQUEST", Message: "正在发布中,请稍候"}
// 身份认证相关错误
ErrVerificationPending = &AppError{Code: "VERIFICATION_PENDING", Message: "已有待审核的认证申请,请等待审核"}
ErrVerificationNotFound = &AppError{Code: "VERIFICATION_NOT_FOUND", Message: "认证记录不存在"}
ErrInvalidIdentity = &AppError{Code: "INVALID_IDENTITY", Message: "无效的身份类型"}
ErrAlreadyVerified = &AppError{Code: "ALREADY_VERIFIED", Message: "已完成身份认证,无需重复申请"}
ErrVerificationAlreadyHandled = &AppError{Code: "VERIFICATION_ALREADY_HANDLED", 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: "超级管理员已初始化,无法重复设置"}
ErrInvalidSetupSecret = &AppError{Code: "INVALID_SETUP_SECRET", Message: "初始化密钥无效"}
ErrSetupSecretNotConfigured = &AppError{Code: "SETUP_SECRET_NOT_CONFIGURED", Message: "未配置初始化密钥,无法使用此接口"}
)
// Wrap 包装错误
@@ -110,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
func As(err error) (*AppError, bool) {
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

@@ -1,4 +1,4 @@
package runner
package runner
import (
"context"
@@ -6,7 +6,7 @@ import (
"sync"
"time"
"carrot_bbs/proto/runner"
"with_you/proto/runner"
"go.uber.org/zap"
)
@@ -26,22 +26,20 @@ type RunnerConnection struct {
type RunnerHub struct {
runner.UnimplementedRunnerHubServer // 嵌入以实现 gRPC 接口
mu sync.RWMutex
runners map[string]*RunnerConnection
taskManager *TaskManager
registerChan chan *RunnerConnection
unregisterChan chan string
logger *zap.Logger
mu sync.RWMutex
runners map[string]*RunnerConnection
taskManager *TaskManager
bus *TaskBus
registry *RunnerRegistry
logger *zap.Logger
}
// NewRunnerHub 创建新的 RunnerHub
func NewRunnerHub(taskManager *TaskManager, logger *zap.Logger) *RunnerHub {
return &RunnerHub{
runners: make(map[string]*RunnerConnection),
taskManager: taskManager,
registerChan: make(chan *RunnerConnection, 100),
unregisterChan: make(chan string, 100),
logger: logger,
runners: make(map[string]*RunnerConnection),
taskManager: taskManager,
logger: logger,
}
}
@@ -137,6 +135,15 @@ func (h *RunnerHub) handleRegister(req *runner.RegisterRequest, stream runner.Ru
h.runners[req.RunnerId] = conn
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{
Message: &runner.StreamMessage_RegisterResponse{
@@ -161,6 +168,15 @@ func (h *RunnerHub) handleHeartbeat(conn *RunnerConnection, hb *runner.Heartbeat
}
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{
Message: &runner.StreamMessage_HeartbeatAck{
HeartbeatAck: &runner.HeartbeatAck{
@@ -179,6 +195,12 @@ func (h *RunnerHub) handleTaskResult(result *runner.TaskResult) {
zap.String("task_id", result.TaskId),
zap.String("status", result.Status.String()))
// 如果有 Bus委托给 Bus 处理(可能需要转发到远程实例)
if h.bus != nil {
h.bus.HandleResult(result)
return
}
if h.taskManager != nil {
h.taskManager.HandleResult(result)
}
@@ -187,12 +209,19 @@ func (h *RunnerHub) handleTaskResult(result *runner.TaskResult) {
// unregisterRunner 注销 Runner
func (h *RunnerHub) unregisterRunner(runnerID string) {
h.mu.Lock()
var capabilities map[string]string
if conn, ok := h.runners[runnerID]; ok {
capabilities = conn.Capabilities
close(conn.sendChan)
delete(h.runners, runnerID)
}
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))
}
@@ -236,13 +265,13 @@ func (h *RunnerHub) GetAvailableRunner(taskType runner.TaskType) (*RunnerConnect
}
// GetAllRunners 获取所有已连接的 Runner 信息
func (h *RunnerHub) GetAllRunners() []map[string]interface{} {
func (h *RunnerHub) GetAllRunners() []map[string]any {
h.mu.RLock()
defer h.mu.RUnlock()
result := make([]map[string]interface{}, 0, len(h.runners))
result := make([]map[string]any, 0, len(h.runners))
for id, conn := range h.runners {
result = append(result, map[string]interface{}{
result = append(result, map[string]any{
"id": id,
"version": conn.Version,
"capabilities": conn.Capabilities,
@@ -257,6 +286,37 @@ func (h *RunnerHub) SetTaskManager(tm *TaskManager) {
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 发送消息
func (c *RunnerConnection) send(msg *runner.StreamMessage) error {
select {
@@ -270,8 +330,11 @@ func (c *RunnerConnection) send(msg *runner.StreamMessage) error {
// CheckStaleRunners 检查并清理超时的 Runner 连接
func (h *RunnerHub) CheckStaleRunners(timeout time.Duration) {
h.mu.Lock()
defer h.mu.Unlock()
var toRemove []struct {
id string
capabilities map[string]string
}
now := time.Now()
for id, conn := range h.runners {
if now.Sub(conn.LastSeen) > timeout {
@@ -280,6 +343,18 @@ func (h *RunnerHub) CheckStaleRunners(timeout time.Duration) {
zap.Time("last_seen", conn.LastSeen))
close(conn.sendChan)
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,18 +1,16 @@
package runner
package runner
import (
"context"
"fmt"
"net"
"time"
"carrot_bbs/internal/config"
"carrot_bbs/proto/runner"
"with_you/internal/config"
"with_you/proto/runner"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
)
// Server gRPC 服务器
@@ -23,13 +21,8 @@ type Server struct {
grpcSrv *grpc.Server
}
// NewServer 创建新的 gRPC 服务器
func NewServer(cfg *config.GRPCConfig, logger *zap.Logger) *Server {
// 创建 TaskManager 和 RunnerHub解决循环依赖
taskManager := NewTaskManager(nil, 60*time.Second, logger)
hub := NewRunnerHub(taskManager, logger)
taskManager.SetHub(hub)
// NewServerWithDeps 使用外部依赖创建 gRPC 服务器Wire DI 使用)
func NewServerWithDeps(cfg *config.GRPCConfig, logger *zap.Logger, hub *RunnerHub) *Server {
return &Server{
cfg: cfg,
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

@@ -1,4 +1,4 @@
package runner
package runner
import (
"context"
@@ -7,7 +7,7 @@ import (
"sync"
"time"
"carrot_bbs/proto/runner"
"with_you/proto/runner"
"github.com/google/uuid"
"go.uber.org/zap"
@@ -16,6 +16,11 @@ import (
// TaskCallback 任务完成回调函数类型
type TaskCallback func(result *runner.TaskResult)
// TaskDispatcher 任务分发接口
type TaskDispatcher interface {
DispatchTask(task *runner.Task) error
}
// PendingTask 表示一个等待处理的任务
type PendingTask struct {
Task *runner.Task
@@ -25,25 +30,25 @@ type PendingTask struct {
// TaskManager 管理任务的生命周期
type TaskManager struct {
mu sync.RWMutex
pending map[string]*PendingTask
hub *RunnerHub
timeout time.Duration
logger *zap.Logger
mu sync.RWMutex
pending map[string]*PendingTask
dispatcher TaskDispatcher
timeout time.Duration
logger *zap.Logger
}
// 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{
pending: make(map[string]*PendingTask),
hub: hub,
timeout: timeout,
logger: logger,
pending: make(map[string]*PendingTask),
dispatcher: dispatcher,
timeout: timeout,
logger: logger,
}
}
// SubmitTask 提交任务并等待结果
func (tm *TaskManager) SubmitTask(ctx context.Context, taskType runner.TaskType, payload interface{}, callback TaskCallback) (*runner.Task, error) {
func (tm *TaskManager) SubmitTask(ctx context.Context, taskType runner.TaskType, payload any, callback TaskCallback) (*runner.Task, error) {
payloadBytes, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal payload: %w", err)
@@ -69,17 +74,10 @@ func (tm *TaskManager) SubmitTask(ctx context.Context, taskType runner.TaskType,
zap.String("task_id", task.TaskId),
zap.String("type", taskType.String()))
// 获取可用 Runner
conn, err := tm.hub.GetAvailableRunner(taskType)
if err != nil {
// 通过 dispatcher 分发任务
if err := tm.dispatcher.DispatchTask(task); err != nil {
tm.cleanupTask(task.TaskId)
return nil, fmt.Errorf("no available runner: %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)
return nil, fmt.Errorf("failed to dispatch task: %w", err)
}
// 启动超时检查
@@ -89,7 +87,7 @@ func (tm *TaskManager) SubmitTask(ctx context.Context, taskType runner.TaskType,
}
// SubmitTaskSync 同步提交任务并等待结果
func (tm *TaskManager) SubmitTaskSync(ctx context.Context, taskType runner.TaskType, payload interface{}) (*runner.TaskResult, error) {
func (tm *TaskManager) SubmitTaskSync(ctx context.Context, taskType runner.TaskType, payload any) (*runner.TaskResult, error) {
resultChan := make(chan *runner.TaskResult, 1)
task, err := tm.SubmitTask(ctx, taskType, payload, func(result *runner.TaskResult) {
@@ -203,9 +201,9 @@ func (tm *TaskManager) GetPendingTaskCount() int {
return len(tm.pending)
}
// SetHub 设置 RunnerHub(用于解决循环依赖)
func (tm *TaskManager) SetHub(hub *RunnerHub) {
tm.hub = hub
// SetDispatcher 设置任务分发器(用于解决循环依赖)
func (tm *TaskManager) SetDispatcher(dispatcher TaskDispatcher) {
tm.dispatcher = dispatcher
}
// CleanupStaleTasks 清理超时的任务

View File

@@ -3,10 +3,10 @@ package handler
import (
"strconv"
"carrot_bbs/internal/dto"
"carrot_bbs/internal/model"
"carrot_bbs/internal/pkg/response"
"carrot_bbs/internal/service"
"with_you/internal/dto"
"with_you/internal/model"
"with_you/internal/pkg/response"
"with_you/internal/service"
"github.com/gin-gonic/gin"
)
@@ -121,6 +121,58 @@ func (h *AdminCommentHandler) BatchDeleteComments(c *gin.Context) {
response.Success(c, result)
}
func (h *AdminCommentHandler) ModerateComment(c *gin.Context) {
commentID := c.Param("id")
if commentID == "" {
response.BadRequest(c, "comment id is required")
return
}
var req dto.AdminModerateCommentRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "invalid request body: "+err.Error())
return
}
if req.Status != string(model.CommentStatusPublished) && req.Status != string(model.CommentStatusRejected) {
response.BadRequest(c, "invalid status value, must be 'published' or 'rejected'")
return
}
reviewedBy := c.GetString("user_id")
commentDetail, err := h.adminCommentService.ModerateComment(c.Request.Context(), commentID, model.CommentStatus(req.Status), req.Reason, reviewedBy)
if err != nil {
response.HandleError(c, err, "failed to moderate comment")
return
}
response.Success(c, commentDetail)
}
func (h *AdminCommentHandler) BatchModerateComments(c *gin.Context) {
var req dto.AdminBatchModerateCommentsRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "invalid request body: "+err.Error())
return
}
if req.Status != string(model.CommentStatusPublished) && req.Status != string(model.CommentStatusRejected) {
response.BadRequest(c, "invalid status value, must be 'published' or 'rejected'")
return
}
reviewedBy := c.GetString("user_id")
result, err := h.adminCommentService.BatchModerateComments(c.Request.Context(), req.IDs, model.CommentStatus(req.Status), reviewedBy)
if err != nil {
response.InternalServerError(c, "failed to batch moderate comments")
return
}
response.Success(c, result)
}
// GetPostComments 获取帖子的评论列表
// GET /api/v1/admin/posts/:postId/comments
func (h *AdminCommentHandler) GetPostComments(c *gin.Context) {

View File

@@ -3,8 +3,8 @@ package handler
import (
"strconv"
"carrot_bbs/internal/pkg/response"
"carrot_bbs/internal/service"
"with_you/internal/pkg/response"
"with_you/internal/service"
"github.com/gin-gonic/gin"
)
@@ -36,14 +36,9 @@ func (h *AdminDashboardHandler) GetStats(c *gin.Context) {
// GetUserActivityTrend 获取用户活跃度趋势
// GET /api/v1/admin/dashboard/user-activity
func (h *AdminDashboardHandler) GetUserActivityTrend(c *gin.Context) {
// 解析天数参数默认7天
// 解析天数参数默认7天限制在1-30天
days, _ := strconv.Atoi(c.DefaultQuery("days", "7"))
if days <= 0 {
days = 7
}
if days > 30 {
days = 30
}
days = min(max(days, 1), 30)
trend, err := h.dashboardService.GetUserActivityTrend(c.Request.Context(), days)
if err != nil {
@@ -69,14 +64,9 @@ func (h *AdminDashboardHandler) GetContentStats(c *gin.Context) {
// GetPendingContent 获取待审核内容
// GET /api/v1/admin/dashboard/pending-content
func (h *AdminDashboardHandler) GetPendingContent(c *gin.Context) {
// 解析limit参数默认10条
// 解析limit参数默认10条限制在1-50条
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
if limit <= 0 {
limit = 10
}
if limit > 50 {
limit = 50
}
limit = min(max(limit, 1), 50)
content, err := h.dashboardService.GetPendingContent(c.Request.Context(), limit)
if err != nil {

View File

@@ -3,9 +3,9 @@ package handler
import (
"strconv"
"carrot_bbs/internal/dto"
"carrot_bbs/internal/pkg/response"
"carrot_bbs/internal/service"
"with_you/internal/dto"
"with_you/internal/pkg/response"
"with_you/internal/service"
"github.com/gin-gonic/gin"
)

View File

@@ -4,9 +4,9 @@ import (
"strconv"
"time"
"carrot_bbs/internal/pkg/response"
"carrot_bbs/internal/repository"
"carrot_bbs/internal/service"
"with_you/internal/pkg/response"
"with_you/internal/query"
"with_you/internal/service"
"github.com/gin-gonic/gin"
)
@@ -37,7 +37,7 @@ func (h *AdminLogHandler) GetOperationLogs(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
filters := repository.LogFilter{
filters := query.LogFilter{
UserID: c.Query("user_id"),
Operation: c.Query("operation"),
TargetType: c.Query("target_type"),
@@ -70,7 +70,7 @@ func (h *AdminLogHandler) GetLoginLogs(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
filters := repository.LoginFilter{
filters := query.LoginFilter{
UserID: c.Query("user_id"),
Event: c.Query("event"),
Result: c.Query("result"),
@@ -113,7 +113,7 @@ func (h *AdminLogHandler) GetDataChangeLogs(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
filters := repository.DataChangeFilter{
filters := query.DataChangeFilter{
UserID: c.Query("user_id"),
OperatorID: c.Query("operator_id"),
ChangeType: c.Query("change_type"),
@@ -190,7 +190,7 @@ func (h *AdminLogHandler) ExportLogs(c *gin.Context) {
"logs": logs,
})
case "login":
logs, _, err := h.loginLogService.GetLoginLogs(c.Request.Context(), repository.LoginFilter{}, 1, 10000)
logs, _, err := h.loginLogService.GetLoginLogs(c.Request.Context(), query.LoginFilter{}, 1, 10000)
if err != nil {
response.HandleError(c, err, "failed to export login logs")
return
@@ -200,7 +200,7 @@ func (h *AdminLogHandler) ExportLogs(c *gin.Context) {
"log": logs,
})
case "data_change":
logs, _, err := h.dataChangeLogService.GetDataChangeLogs(c.Request.Context(), repository.DataChangeFilter{}, 1, 10000)
logs, _, err := h.dataChangeLogService.GetDataChangeLogs(c.Request.Context(), query.DataChangeFilter{}, 1, 10000)
if err != nil {
response.HandleError(c, err, "failed to export data change logs")
return

View File

@@ -3,10 +3,10 @@ package handler
import (
"strconv"
"carrot_bbs/internal/dto"
"carrot_bbs/internal/model"
"carrot_bbs/internal/pkg/response"
"carrot_bbs/internal/service"
"with_you/internal/dto"
"with_you/internal/model"
"with_you/internal/pkg/response"
"with_you/internal/service"
"github.com/gin-gonic/gin"
)

View File

@@ -0,0 +1,129 @@
package handler
import (
"strconv"
"with_you/internal/model"
"with_you/internal/pkg/response"
"with_you/internal/service"
"github.com/gin-gonic/gin"
)
type AdminProfileAuditHandler struct {
profileAuditService service.UserProfileAuditService
}
func NewAdminProfileAuditHandler(profileAuditService service.UserProfileAuditService) *AdminProfileAuditHandler {
return &AdminProfileAuditHandler{
profileAuditService: profileAuditService,
}
}
func (h *AdminProfileAuditHandler) GetPendingList(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page <= 0 {
page = 1
}
if pageSize <= 0 || pageSize > 100 {
pageSize = 20
}
contentType := c.Query("content_type")
var ct model.UserProfileContentType
switch contentType {
case "avatar":
ct = model.UserProfileContentTypeAvatar
case "cover":
ct = model.UserProfileContentTypeCover
case "bio":
ct = model.UserProfileContentTypeBio
default:
ct = ""
}
audits, total, err := h.profileAuditService.GetPendingList(c.Request.Context(), ct, page, pageSize)
if err != nil {
response.InternalServerError(c, "failed to get pending profile audits")
return
}
items := make([]map[string]any, 0, len(audits))
for _, audit := range audits {
item := map[string]any{
"id": audit.ID,
"user_id": audit.UserID,
"content_type": audit.ContentType,
"content": audit.Content,
"status": audit.Status,
"created_at": audit.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
if audit.User != nil {
item["user"] = map[string]any{
"id": audit.User.ID,
"username": audit.User.Username,
"nickname": audit.User.Nickname,
"avatar": audit.User.Avatar,
}
}
items = append(items, item)
}
response.Paginated(c, items, total, page, pageSize)
}
func (h *AdminProfileAuditHandler) ModerateAudit(c *gin.Context) {
auditID := c.Param("id")
if auditID == "" {
response.BadRequest(c, "audit id is required")
return
}
var req struct {
Status string `json:"status" binding:"required"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
if req.Status != string(model.UserProfileAuditStatusApproved) && req.Status != string(model.UserProfileAuditStatusRejected) {
response.BadRequest(c, "invalid status, must be 'approved' or 'rejected'")
return
}
reviewedBy := c.GetString("user_id")
if err := h.profileAuditService.ModerateAudit(c.Request.Context(), auditID, model.UserProfileAuditStatus(req.Status), req.Reason, reviewedBy); err != nil {
response.HandleError(c, err, "failed to moderate profile audit")
return
}
response.Success(c, map[string]any{"success": true})
}
func (h *AdminProfileAuditHandler) BatchModerate(c *gin.Context) {
var req struct {
IDs []string `json:"ids" binding:"required"`
Status string `json:"status" binding:"required"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
if req.Status != string(model.UserProfileAuditStatusApproved) && req.Status != string(model.UserProfileAuditStatusRejected) {
response.BadRequest(c, "invalid status, must be 'approved' or 'rejected'")
return
}
reviewedBy := c.GetString("user_id")
if err := h.profileAuditService.BatchModerateAudits(c.Request.Context(), req.IDs, model.UserProfileAuditStatus(req.Status), req.Reason, reviewedBy); err != nil {
response.InternalServerError(c, "failed to batch moderate profile audits")
return
}
response.Success(c, map[string]any{"success": true})
}

View File

@@ -0,0 +1,139 @@
package handler
import (
"with_you/internal/dto"
"with_you/internal/pkg/response"
"with_you/internal/query"
"with_you/internal/service"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// AdminReportHandler 管理端举报处理器
type AdminReportHandler struct {
adminReportService service.AdminReportService
}
// NewAdminReportHandler 创建管理端举报处理器
func NewAdminReportHandler(adminReportService service.AdminReportService) *AdminReportHandler {
return &AdminReportHandler{
adminReportService: adminReportService,
}
}
// List 获取举报列表
func (h *AdminReportHandler) List(c *gin.Context) {
// 解析查询参数
var query query.AdminReportListQuery
if err := c.ShouldBindQuery(&query); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
// 设置默认分页
if query.Page == 0 {
query.Page = 1
}
if query.PageSize == 0 {
query.PageSize = 20
}
// 查询列表
reports, total, err := h.adminReportService.GetReportList(c.Request.Context(), query)
if err != nil {
zap.L().Error("failed to get report list", zap.Error(err))
response.InternalServerError(c, "获取举报列表失败")
return
}
response.Paginated(c, reports, total, query.Page, query.PageSize)
}
// Detail 获取举报详情
func (h *AdminReportHandler) Detail(c *gin.Context) {
// 获取举报ID
id := c.Param("id")
if id == "" {
response.BadRequest(c, "举报ID不能为空")
return
}
// 查询详情
report, err := h.adminReportService.GetReportDetail(c.Request.Context(), id)
if err != nil {
zap.L().Error("failed to get report detail", zap.Error(err), zap.String("id", id))
response.NotFound(c, "举报不存在")
return
}
response.Success(c, report)
}
// Handle 处理举报
func (h *AdminReportHandler) Handle(c *gin.Context) {
// 获取处理人ID
handledBy := c.GetString("user_id")
if handledBy == "" {
response.Unauthorized(c, "未授权")
return
}
// 获取举报ID
id := c.Param("id")
if id == "" {
response.BadRequest(c, "举报ID不能为空")
return
}
// 解析请求
var req dto.HandleReportRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
// 处理举报
err := h.adminReportService.HandleReport(c.Request.Context(), id, req.Action, req.Result, handledBy)
if err != nil {
zap.L().Error("failed to handle report",
zap.Error(err),
zap.String("id", id),
zap.String("action", req.Action),
)
response.Error(c, 500, err.Error())
return
}
response.Success(c, gin.H{"message": "处理成功"})
}
// BatchHandle 批量处理举报
func (h *AdminReportHandler) BatchHandle(c *gin.Context) {
// 获取处理人ID
handledBy := c.GetString("user_id")
if handledBy == "" {
response.Unauthorized(c, "未授权")
return
}
// 解析请求
var req dto.BatchHandleReportRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
// 批量处理
result, err := h.adminReportService.BatchHandle(c.Request.Context(), req.IDs, req.Action, req.Result, handledBy)
if err != nil {
zap.L().Error("failed to batch handle reports",
zap.Error(err),
zap.Int("count", len(req.IDs)),
)
response.Error(c, 500, err.Error())
return
}
response.Success(c, result)
}

View File

@@ -3,10 +3,10 @@ package handler
import (
"strconv"
"carrot_bbs/internal/dto"
"carrot_bbs/internal/model"
"carrot_bbs/internal/pkg/response"
"carrot_bbs/internal/service"
"with_you/internal/dto"
"with_you/internal/model"
"with_you/internal/pkg/response"
"with_you/internal/service"
"github.com/gin-gonic/gin"
)
@@ -14,12 +14,14 @@ import (
// AdminUserHandler 管理端用户处理器
type AdminUserHandler struct {
adminUserService service.AdminUserService
pushService service.PushService
}
// NewAdminUserHandler 创建管理端用户处理器
func NewAdminUserHandler(adminUserService service.AdminUserService) *AdminUserHandler {
func NewAdminUserHandler(adminUserService service.AdminUserService, pushService service.PushService) *AdminUserHandler {
return &AdminUserHandler{
adminUserService: adminUserService,
pushService: pushService,
}
}
@@ -103,3 +105,22 @@ func (h *AdminUserHandler) UpdateUserStatus(c *gin.Context) {
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

@@ -0,0 +1,187 @@
package handler
import (
"context"
"strconv"
"with_you/internal/dto"
"with_you/internal/model"
"with_you/internal/pkg/response"
"with_you/internal/service"
"github.com/gin-gonic/gin"
)
type AdminVerificationHandler struct {
adminVerificationService service.AdminVerificationService
userService service.UserService
}
func NewAdminVerificationHandler(
adminVerificationService service.AdminVerificationService,
userService service.UserService,
) *AdminVerificationHandler {
return &AdminVerificationHandler{
adminVerificationService: adminVerificationService,
userService: userService,
}
}
func (h *AdminVerificationHandler) ListVerifications(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
status := c.Query("status")
keyword := c.Query("keyword")
if page <= 0 {
page = 1
}
if pageSize <= 0 || pageSize > 100 {
pageSize = 20
}
if status != "" && status != string(model.VerificationStatusPending) &&
status != string(model.VerificationStatusApproved) &&
status != string(model.VerificationStatusRejected) {
response.BadRequest(c, "无效的状态值")
return
}
records, total, err := h.adminVerificationService.ListVerifications(c.Request.Context(), page, pageSize, status, keyword)
if err != nil {
response.InternalServerError(c, "获取认证列表失败")
return
}
responses := make([]*dto.AdminVerificationListResponse, len(records))
for i, record := range records {
responses[i] = h.convertToAdminListResponse(c.Request.Context(), record)
}
response.Paginated(c, responses, total, page, pageSize)
}
func (h *AdminVerificationHandler) GetVerificationDetail(c *gin.Context) {
id := c.Param("id")
if id == "" {
response.BadRequest(c, "认证ID不能为空")
return
}
record, err := h.adminVerificationService.GetVerificationDetail(c.Request.Context(), id)
if err != nil {
response.NotFound(c, "认证记录不存在")
return
}
response.Success(c, h.convertToAdminDetailResponse(c.Request.Context(), record))
}
func (h *AdminVerificationHandler) ReviewVerification(c *gin.Context) {
reviewerID, exists := c.Get("user_id")
if !exists {
response.Unauthorized(c, "未授权")
return
}
id := c.Param("id")
if id == "" {
response.BadRequest(c, "认证ID不能为空")
return
}
var req dto.AdminReviewVerificationRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求参数错误: "+err.Error())
return
}
if !req.Approve && req.RejectReason == "" {
response.BadRequest(c, "拒绝时必须填写拒绝原因")
return
}
record, err := h.adminVerificationService.ReviewVerification(
c.Request.Context(),
reviewerID.(string),
id,
req.Approve,
req.RejectReason,
)
if err != nil {
response.HandleError(c, err, "审核失败")
return
}
response.Success(c, h.convertToAdminDetailResponse(c.Request.Context(), record))
}
func (h *AdminVerificationHandler) convertToAdminListResponse(ctx context.Context, record *model.VerificationRecord) *dto.AdminVerificationListResponse {
resp := &dto.AdminVerificationListResponse{
ID: record.ID,
UserID: record.UserID,
Identity: string(record.Identity),
Status: string(record.Status),
RealName: record.RealName,
StudentID: record.StudentID,
EmployeeID: record.EmployeeID,
Department: record.Department,
CreatedAt: dto.FormatTime(record.CreatedAt),
}
user, err := h.userService.GetUserByID(ctx, record.UserID)
if err == nil && user != nil {
resp.Username = user.Username
resp.Nickname = user.Nickname
resp.Avatar = user.Avatar
}
if record.ReviewedAt != nil {
resp.ReviewedAt = dto.FormatTime(*record.ReviewedAt)
if record.ReviewedBy != nil {
reviewer, err := h.userService.GetUserByID(ctx, *record.ReviewedBy)
if err == nil && reviewer != nil {
resp.ReviewerName = reviewer.Nickname
}
}
}
return resp
}
func (h *AdminVerificationHandler) convertToAdminDetailResponse(ctx context.Context, record *model.VerificationRecord) *dto.AdminVerificationDetailResponse {
resp := &dto.AdminVerificationDetailResponse{
ID: record.ID,
UserID: record.UserID,
Identity: string(record.Identity),
Status: string(record.Status),
RealName: record.RealName,
StudentID: record.StudentID,
EmployeeID: record.EmployeeID,
Department: record.Department,
ProofMaterials: record.ProofMaterials,
CreatedAt: dto.FormatTime(record.CreatedAt),
}
user, err := h.userService.GetUserByID(ctx, record.UserID)
if err == nil && user != nil {
resp.Username = user.Username
resp.Nickname = user.Nickname
resp.Avatar = user.Avatar
}
if record.ReviewedAt != nil {
resp.ReviewedAt = dto.FormatTime(*record.ReviewedAt)
}
if record.ReviewedBy != nil {
resp.ReviewedBy = *record.ReviewedBy
if reviewer, err := h.userService.GetUserByID(ctx, *record.ReviewedBy); err == nil && reviewer != nil {
resp.ReviewerName = reviewer.Nickname
}
}
if record.RejectReason != "" {
resp.RejectReason = record.RejectReason
}
return resp
}

View File

@@ -0,0 +1,51 @@
package handler
import (
"strconv"
"github.com/gin-gonic/gin"
"with_you/internal/pkg/response"
"with_you/internal/service"
)
// CallHandler 通话记录处理器
type CallHandler struct {
callService service.CallService
}
// NewCallHandler 创建通话处理器
func NewCallHandler(callService service.CallService) *CallHandler {
return &CallHandler{callService: callService}
}
// GetCallHistory 获取通话记录
// GET /api/v1/calls/history?page=1&page_size=20
func (h *CallHandler) GetCallHistory(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
response.Unauthorized(c, "unauthorized")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 50 {
pageSize = 20
}
calls, total, err := h.callService.GetCallHistory(c.Request.Context(), userID.(string), page, pageSize)
if err != nil {
response.InternalServerError(c, "failed to get call history")
return
}
response.Success(c, gin.H{
"items": calls,
"total": total,
"page": page,
})
}

View File

@@ -0,0 +1,122 @@
package handler
import (
"with_you/internal/model"
"with_you/internal/pkg/response"
"with_you/internal/service"
"github.com/gin-gonic/gin"
)
type ChannelHandler struct {
channelService service.ChannelService
}
func NewChannelHandler(channelService service.ChannelService) *ChannelHandler {
return &ChannelHandler{channelService: channelService}
}
type channelResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description,omitempty"`
SortOrder int `json:"sort_order"`
IsActive bool `json:"is_active"`
}
func toChannelResponse(c *model.Channel) *channelResponse {
if c == nil {
return nil
}
return &channelResponse{
ID: c.ID,
Name: c.Name,
Slug: c.Slug,
Description: c.Description,
SortOrder: c.SortOrder,
IsActive: c.IsActive,
}
}
// ListPublic 公开频道列表(仅启用)
func (h *ChannelHandler) ListPublic(c *gin.Context) {
items, err := h.channelService.ListActive()
if err != nil {
response.InternalServerError(c, "failed to get channels")
return
}
result := make([]*channelResponse, 0, len(items))
for _, item := range items {
result = append(result, toChannelResponse(item))
}
response.Success(c, gin.H{"list": result})
}
// AdminList 管理端频道列表(全部)
func (h *ChannelHandler) AdminList(c *gin.Context) {
items, err := h.channelService.ListAll()
if err != nil {
response.InternalServerError(c, "failed to get channels")
return
}
result := make([]*channelResponse, 0, len(items))
for _, item := range items {
result = append(result, toChannelResponse(item))
}
response.Success(c, gin.H{"list": result})
}
type adminChannelUpsertRequest struct {
Name string `json:"name" binding:"required,max=100"`
Slug string `json:"slug" binding:"required,max=100"`
Description string `json:"description" binding:"max=255"`
SortOrder int `json:"sort_order"`
IsActive *bool `json:"is_active"`
}
func (h *ChannelHandler) AdminCreate(c *gin.Context) {
var req adminChannelUpsertRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
isActive := true
if req.IsActive != nil {
isActive = *req.IsActive
}
item, err := h.channelService.Create(req.Name, req.Slug, req.Description, req.SortOrder, isActive)
if err != nil {
response.InternalServerError(c, "failed to create channel")
return
}
response.Success(c, toChannelResponse(item))
}
func (h *ChannelHandler) AdminUpdate(c *gin.Context) {
id := c.Param("id")
var req adminChannelUpsertRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
isActive := true
if req.IsActive != nil {
isActive = *req.IsActive
}
item, err := h.channelService.Update(id, req.Name, req.Slug, req.Description, req.SortOrder, isActive)
if err != nil {
response.InternalServerError(c, "failed to update channel")
return
}
response.Success(c, toChannelResponse(item))
}
func (h *ChannelHandler) AdminDelete(c *gin.Context) {
id := c.Param("id")
if err := h.channelService.Delete(id); err != nil {
response.InternalServerError(c, "failed to delete channel")
return
}
response.Success(c, gin.H{"success": true})
}

View File

@@ -2,23 +2,24 @@ package handler
import (
"encoding/json"
"errors"
"strconv"
"github.com/gin-gonic/gin"
"carrot_bbs/internal/dto"
"carrot_bbs/internal/pkg/response"
"carrot_bbs/internal/service"
"with_you/internal/dto"
"with_you/internal/model"
"with_you/internal/pkg/cursor"
"with_you/internal/pkg/response"
"with_you/internal/service"
)
// CommentHandler 评论处理器
type CommentHandler struct {
commentService *service.CommentService
commentService service.CommentService
}
// NewCommentHandler 创建评论处理器
func NewCommentHandler(commentService *service.CommentService) *CommentHandler {
func NewCommentHandler(commentService service.CommentService) *CommentHandler {
return &CommentHandler{
commentService: commentService,
}
@@ -33,10 +34,11 @@ func (h *CommentHandler) Create(c *gin.Context) {
}
type CreateRequest struct {
PostID string `json:"post_id" binding:"required"`
Content string `json:"content"` // 内容可选,允许只发图片
ParentID *string `json:"parent_id"`
Images []string `json:"images"` // 图片URL列表
PostID string `json:"post_id" binding:"required"`
Content string `json:"content"`
Segments model.MessageSegments `json:"segments"`
ParentID *string `json:"parent_id"`
Images []string `json:"images"`
}
var req CreateRequest
@@ -45,12 +47,18 @@ func (h *CommentHandler) Create(c *gin.Context) {
return
}
// 验证:评论必须有内容或图片
if req.Content == "" && len(req.Images) == 0 {
// 验证:评论必须有内容或图片或segments
if req.Content == "" && len(req.Images) == 0 && len(req.Segments) == 0 {
response.BadRequest(c, "评论内容或图片不能同时为空")
return
}
// 如果没有content但有segments从segments提取文本
content := req.Content
if content == "" && len(req.Segments) > 0 {
content = dto.ExtractTextContent(req.Segments)
}
// 将图片列表转换为JSON字符串
var imagesJSON string
if len(req.Images) > 0 {
@@ -58,13 +66,8 @@ func (h *CommentHandler) Create(c *gin.Context) {
imagesJSON = string(imagesBytes)
}
comment, err := h.commentService.Create(c.Request.Context(), req.PostID, userID, req.Content, req.ParentID, imagesJSON, req.Images)
comment, err := h.commentService.Create(c.Request.Context(), req.PostID, userID, content, req.Segments, req.ParentID, imagesJSON, req.Images)
if err != nil {
var moderationErr *service.CommentModerationRejectedError
if errors.As(err, &moderationErr) {
response.BadRequest(c, moderationErr.UserMessage())
return
}
response.InternalServerError(c, "failed to create comment")
return
}
@@ -175,9 +178,9 @@ func (h *CommentHandler) Update(c *gin.Context) {
comment.Content = req.Content
err = h.commentService.Update(c.Request.Context(), comment)
err = h.commentService.Update(c.Request.Context(), userID, comment)
if err != nil {
response.InternalServerError(c, "failed to update comment")
response.HandleError(c, err, "failed to update comment")
return
}
@@ -205,9 +208,9 @@ func (h *CommentHandler) Delete(c *gin.Context) {
return
}
err = h.commentService.Delete(c.Request.Context(), id)
err = h.commentService.Delete(c.Request.Context(), userID, id)
if err != nil {
response.InternalServerError(c, "failed to delete comment")
response.HandleError(c, err, "failed to delete comment")
return
}
@@ -251,3 +254,70 @@ func (h *CommentHandler) Unlike(c *gin.Context) {
response.SuccessWithMessage(c, "unliked", nil)
}
// GetByPostIDByCursor 游标分页获取帖子评论
func (h *CommentHandler) GetByPostIDByCursor(c *gin.Context) {
userID := c.GetString("user_id")
postID := c.Param("id")
// 解析游标分页请求
req := h.parseCursorRequest(c)
// 调用游标分页服务
result, err := h.commentService.GetCommentsByCursor(c.Request.Context(), postID, req)
if err != nil {
response.InternalServerError(c, "failed to get comments")
return
}
// 转换为响应结构,检查每个评论的点赞状态
commentResponses := dto.ConvertCommentsToResponseWithUser(result.Items, userID, h.commentService)
// 构建游标分页响应
cursorResp := &dto.CommentCursorPageResponse{
Items: commentResponses,
NextCursor: result.NextCursor,
PrevCursor: result.PrevCursor,
HasMore: result.HasMore,
}
response.Success(c, cursorResp)
}
// GetRepliesByRootIDByCursor 游标分页获取根评论的回复
func (h *CommentHandler) GetRepliesByRootIDByCursor(c *gin.Context) {
userID := c.GetString("user_id")
rootID := c.Param("id")
// 解析游标分页请求
req := h.parseCursorRequest(c)
// 调用游标分页服务
result, err := h.commentService.GetRepliesByCursor(c.Request.Context(), rootID, req)
if err != nil {
response.InternalServerError(c, "failed to get replies")
return
}
// 转换为响应结构,检查每个回复的点赞状态
replyResponses := dto.ConvertCommentsToResponseWithUser(result.Items, userID, h.commentService)
// 构建游标分页响应
cursorResp := &dto.CommentCursorPageResponse{
Items: replyResponses,
NextCursor: result.NextCursor,
PrevCursor: result.PrevCursor,
HasMore: result.HasMore,
}
response.Success(c, cursorResp)
}
// parseCursorRequest 解析游标分页请求参数
func (h *CommentHandler) parseCursorRequest(c *gin.Context) *cursor.PageRequest {
cursorStr := c.Query("cursor")
direction := cursor.Direction(c.DefaultQuery("direction", "forward"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
return cursor.NewPageRequest(cursorStr, direction, pageSize)
}

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

@@ -1,255 +0,0 @@
package handler
import (
"context"
"strings"
"time"
"carrot_bbs/internal/config"
"carrot_bbs/internal/model"
"carrot_bbs/internal/pkg/gorse"
"carrot_bbs/internal/pkg/response"
"github.com/gin-gonic/gin"
gorseio "github.com/gorse-io/gorse-go"
"go.uber.org/zap"
"gorm.io/gorm"
)
// GorseHandler Gorse推荐处理器
type GorseHandler struct {
importPassword string
gorseConfig config.GorseConfig
db *gorm.DB
}
// NewGorseHandler 创建Gorse处理器
func NewGorseHandler(cfg config.GorseConfig, db *gorm.DB) *GorseHandler {
return &GorseHandler{
importPassword: cfg.ImportPassword,
gorseConfig: cfg,
db: db,
}
}
// ImportRequest 导入请求
type ImportRequest struct {
Password string `json:"password"`
}
// ImportData 导入数据到Gorse
// POST /api/v1/gorse/import
func (h *GorseHandler) ImportData(c *gin.Context) {
// 验证密码
if h.importPassword == "" {
response.BadRequest(c, "Gorse import is disabled")
return
}
var req ImportRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "invalid request body")
return
}
if req.Password != h.importPassword {
response.Unauthorized(c, "invalid password")
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Minute)
defer cancel()
stats, err := h.importAllData(ctx)
if err != nil {
zap.L().Error("gorse import failed", zap.Error(err))
response.InternalServerError(c, "gorse import failed: "+err.Error())
return
}
response.Success(c, gin.H{
"message": "import completed",
"status": "done",
"stats": stats,
})
}
// GetStatus 获取Gorse状态
// GET /api/v1/gorse/status
func (h *GorseHandler) GetStatus(c *gin.Context) {
hasPassword := h.importPassword != ""
response.Success(c, gin.H{
"enabled": h.gorseConfig.Enabled,
"has_password": hasPassword,
})
}
func (h *GorseHandler) importAllData(ctx context.Context) (gin.H, error) {
gorseClient := gorseio.NewGorseClient(h.gorseConfig.Address, h.gorseConfig.APIKey)
gorse.InitEmbeddingWithConfig(h.gorseConfig.EmbeddingAPIKey, h.gorseConfig.EmbeddingURL, h.gorseConfig.EmbeddingModel)
stats := gin.H{
"items": 0,
"users": 0,
"likes": 0,
"favorites": 0,
"comments": 0,
}
// 导入帖子
var posts []model.Post
if err := h.db.Find(&posts).Error; err != nil {
return nil, err
}
for _, post := range posts {
embedding, err := gorse.GetEmbedding(strings.TrimSpace(post.Title + " " + post.Content))
if err != nil {
zap.L().Warn("get embedding failed for post",
zap.String("postID", post.ID),
zap.Error(err),
)
embedding = make([]float64, 1024)
}
_, err = gorseClient.InsertItem(ctx, gorseio.Item{
ItemId: post.ID,
IsHidden: post.DeletedAt.Valid,
Categories: buildPostCategories(&post),
Comment: post.Title,
Timestamp: post.CreatedAt.UTC().Truncate(time.Second),
Labels: map[string]any{
"embedding": embedding,
},
})
if err != nil {
zap.L().Warn("import item failed",
zap.String("postID", post.ID),
zap.Error(err),
)
continue
}
stats["items"] = stats["items"].(int) + 1
}
// 导入用户
var users []model.User
if err := h.db.Find(&users).Error; err != nil {
return nil, err
}
for _, user := range users {
_, err := gorseClient.InsertUser(ctx, gorseio.User{
UserId: user.ID,
Labels: map[string]any{
"posts_count": user.PostsCount,
"followers_count": user.FollowersCount,
"following_count": user.FollowingCount,
},
Comment: user.Nickname,
})
if err != nil {
zap.L().Warn("import user failed",
zap.String("userID", user.ID),
zap.Error(err),
)
continue
}
stats["users"] = stats["users"].(int) + 1
}
// 导入点赞
var likes []model.PostLike
if err := h.db.Find(&likes).Error; err != nil {
return nil, err
}
for _, like := range likes {
_, err := gorseClient.InsertFeedback(ctx, []gorseio.Feedback{{
FeedbackType: string(gorse.FeedbackTypeLike),
UserId: like.UserID,
ItemId: like.PostID,
Timestamp: like.CreatedAt.UTC().Truncate(time.Second),
}})
if err != nil {
zap.L().Warn("import like failed",
zap.String("userID", like.UserID),
zap.String("postID", like.PostID),
zap.Error(err),
)
continue
}
stats["likes"] = stats["likes"].(int) + 1
}
// 导入收藏
var favorites []model.Favorite
if err := h.db.Find(&favorites).Error; err != nil {
return nil, err
}
for _, fav := range favorites {
_, err := gorseClient.InsertFeedback(ctx, []gorseio.Feedback{{
FeedbackType: string(gorse.FeedbackTypeStar),
UserId: fav.UserID,
ItemId: fav.PostID,
Timestamp: fav.CreatedAt.UTC().Truncate(time.Second),
}})
if err != nil {
zap.L().Warn("import favorite failed",
zap.String("userID", fav.UserID),
zap.String("postID", fav.PostID),
zap.Error(err),
)
continue
}
stats["favorites"] = stats["favorites"].(int) + 1
}
// 导入评论(按用户-帖子去重)
var comments []model.Comment
if err := h.db.Where("status = ?", model.CommentStatusPublished).Find(&comments).Error; err != nil {
return nil, err
}
seen := make(map[string]struct{})
for _, cm := range comments {
key := cm.UserID + ":" + cm.PostID
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
_, err := gorseClient.InsertFeedback(ctx, []gorseio.Feedback{{
FeedbackType: string(gorse.FeedbackTypeComment),
UserId: cm.UserID,
ItemId: cm.PostID,
Timestamp: cm.CreatedAt.UTC().Truncate(time.Second),
}})
if err != nil {
zap.L().Warn("import comment failed",
zap.String("userID", cm.UserID),
zap.String("postID", cm.PostID),
zap.Error(err),
)
continue
}
stats["comments"] = stats["comments"].(int) + 1
}
return stats, nil
}
func buildPostCategories(post *model.Post) []string {
var categories []string
if post.ViewsCount > 1000 {
categories = append(categories, "hot_high")
} else if post.ViewsCount > 100 {
categories = append(categories, "hot_medium")
}
if post.LikesCount > 100 {
categories = append(categories, "likes_100+")
} else if post.LikesCount > 10 {
categories = append(categories, "likes_10+")
}
age := time.Since(post.CreatedAt)
if age < 24*time.Hour {
categories = append(categories, "today")
} else if age < 7*24*time.Hour {
categories = append(categories, "this_week")
}
return categories
}

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,15 +1,17 @@
package handler
import (
"errors"
"strconv"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"carrot_bbs/internal/dto"
"carrot_bbs/internal/model"
"carrot_bbs/internal/pkg/response"
"carrot_bbs/internal/service"
"with_you/internal/dto"
"with_you/internal/model"
"with_you/internal/pkg/cursor"
"with_you/internal/pkg/response"
"with_you/internal/service"
)
// GroupHandler 群组处理器
@@ -89,7 +91,7 @@ func (h *GroupHandler) GetGroup(c *gin.Context) {
group, err := h.groupService.GetGroupByID(groupID)
if err != nil {
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -128,7 +130,7 @@ func (h *GroupHandler) UpdateGroup(c *gin.Context) {
return
}
updates := make(map[string]interface{})
updates := make(map[string]any)
if req.Name != "" {
updates["name"] = req.Name
}
@@ -144,7 +146,7 @@ func (h *GroupHandler) UpdateGroup(c *gin.Context) {
response.Forbidden(c, "没有权限修改群组信息")
return
}
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -177,7 +179,7 @@ func (h *GroupHandler) DissolveGroup(c *gin.Context) {
response.Forbidden(c, "只有群主可以解散群组")
return
}
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -214,7 +216,7 @@ func (h *GroupHandler) TransferOwner(c *gin.Context) {
response.Forbidden(c, "只有群主可以转让群主")
return
}
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -278,7 +280,7 @@ func (h *GroupHandler) InviteMembers(c *gin.Context) {
response.Forbidden(c, "只有群成员可以邀请他人")
return
}
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -313,7 +315,7 @@ func (h *GroupHandler) JoinGroup(c *gin.Context) {
response.Forbidden(c, "该群不允许加入")
return
}
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -352,7 +354,7 @@ func (h *GroupHandler) LeaveGroup(c *gin.Context) {
response.BadRequest(c, "不是群成员")
return
}
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -389,7 +391,7 @@ func (h *GroupHandler) RemoveMember(c *gin.Context) {
response.Forbidden(c, "只有群主或管理员可以移除成员")
return
}
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -428,7 +430,7 @@ func (h *GroupHandler) GetMembers(c *gin.Context) {
members, total, err := h.groupService.GetMembers(groupID, page, pageSize)
if err != nil {
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -516,7 +518,7 @@ func (h *GroupHandler) HandleGetMyMemberInfo(c *gin.Context) {
// 获取群组信息
group, err := h.groupService.GetGroupByID(groupID)
if err != nil {
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -541,7 +543,7 @@ func (h *GroupHandler) HandleGetMyMemberInfo(c *gin.Context) {
}
// 添加群组禁言状态信息
response.Success(c, map[string]interface{}{
response.Success(c, map[string]any{
"member": memberResp,
"mute_all": group.MuteAll,
"is_muted": member.Muted || group.MuteAll,
@@ -569,7 +571,7 @@ func (h *GroupHandler) HandleDissolveGroup(c *gin.Context) {
response.Forbidden(c, "只有群主可以解散群组")
return
}
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -611,7 +613,7 @@ func (h *GroupHandler) HandleTransferOwner(c *gin.Context) {
response.Forbidden(c, "只有群主可以转让群主")
return
}
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -656,7 +658,7 @@ func (h *GroupHandler) HandleInviteMembers(c *gin.Context) {
response.Forbidden(c, "只有群主或管理员可以邀请他人")
return
}
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -695,7 +697,7 @@ func (h *GroupHandler) HandleJoinGroup(c *gin.Context) {
response.Forbidden(c, "该群不允许加入")
return
}
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -740,7 +742,7 @@ func (h *GroupHandler) HandleSetNickname(c *gin.Context) {
response.BadRequest(c, "不是群成员")
return
}
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -777,7 +779,7 @@ func (h *GroupHandler) HandleSetJoinType(c *gin.Context) {
response.Forbidden(c, "只有群主可以设置加群方式")
return
}
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -823,7 +825,7 @@ func (h *GroupHandler) HandleCreateAnnouncement(c *gin.Context) {
response.Forbidden(c, "只有群主或管理员可以发布公告")
return
}
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -836,6 +838,8 @@ func (h *GroupHandler) HandleCreateAnnouncement(c *gin.Context) {
// HandleGetAnnouncements 获取群公告列表
// GET /api/v1/groups/:id/announcements
//
// 可见性策略:仅群成员可见。非成员返回 403 NOT_GROUP_MEMBER。
func (h *GroupHandler) HandleGetAnnouncements(c *gin.Context) {
userID := parseUserID(c)
if userID == "" {
@@ -849,12 +853,18 @@ func (h *GroupHandler) HandleGetAnnouncements(c *gin.Context) {
return
}
// 成员校验:公告仅群成员可见。
if !h.groupService.IsGroupMember(userID, groupID) {
response.Forbidden(c, "不是群成员")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
announcements, total, err := h.groupService.GetAnnouncements(groupID, page, pageSize)
if err != nil {
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -915,7 +925,7 @@ func (h *GroupHandler) GetMyMemberInfo(c *gin.Context) {
// 获取群组信息
group, err := h.groupService.GetGroupByID(groupID)
if err != nil {
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -940,7 +950,7 @@ func (h *GroupHandler) GetMyMemberInfo(c *gin.Context) {
}
// 添加群组禁言状态信息
response.Success(c, map[string]interface{}{
response.Success(c, map[string]any{
"member": memberResp,
"mute_all": group.MuteAll,
"is_muted": member.Muted || group.MuteAll,
@@ -980,7 +990,7 @@ func (h *GroupHandler) SetMemberRole(c *gin.Context) {
response.Forbidden(c, "只有群主可以设置成员角色")
return
}
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -1021,7 +1031,7 @@ func (h *GroupHandler) SetNickname(c *gin.Context) {
response.BadRequest(c, "不是群成员")
return
}
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -1064,7 +1074,7 @@ func (h *GroupHandler) MuteMember(c *gin.Context) {
response.Forbidden(c, "只有群主或管理员可以禁言成员")
return
}
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -1115,7 +1125,7 @@ func (h *GroupHandler) SetMuteAll(c *gin.Context) {
response.Forbidden(c, "只有群主可以设置全员禁言")
return
}
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -1156,7 +1166,7 @@ func (h *GroupHandler) SetJoinType(c *gin.Context) {
response.Forbidden(c, "只有群主可以设置加群方式")
return
}
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -1204,7 +1214,7 @@ func (h *GroupHandler) CreateAnnouncement(c *gin.Context) {
response.Forbidden(c, "只有群主或管理员可以发布公告")
return
}
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -1235,7 +1245,7 @@ func (h *GroupHandler) GetAnnouncements(c *gin.Context) {
announcements, total, err := h.groupService.GetAnnouncements(groupID, page, pageSize)
if err != nil {
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -1279,6 +1289,171 @@ func (h *GroupHandler) DeleteAnnouncement(c *gin.Context) {
response.SuccessWithMessage(c, "公告已删除", nil)
}
// ==================== 游标分页方法 ====================
// GetGroupsByCursor 游标分页获取群组列表
// GET /api/groups/cursor
func (h *GroupHandler) GetGroupsByCursor(c *gin.Context) {
// 解析游标分页请求
req := h.parseCursorRequest(c)
// 调用游标分页服务
result, err := h.groupService.GetGroupsByCursor(c.Request.Context(), req)
if err != nil {
response.InternalServerError(c, err.Error())
return
}
// 转换为响应结构
groupResponses := dto.GroupsToResponseFromPtr(result.Items)
// 构建游标分页响应
cursorResp := &dto.GroupCursorPageResponse{
Items: groupResponses,
NextCursor: result.NextCursor,
PrevCursor: result.PrevCursor,
HasMore: result.HasMore,
}
response.Success(c, cursorResp)
}
// GetUserGroupsByCursor 游标分页获取用户加入的群组列表
// GET /api/users/:id/groups/cursor
func (h *GroupHandler) GetUserGroupsByCursor(c *gin.Context) {
userID := parseUserIDFromPath(c)
if userID == "" {
response.Unauthorized(c, "")
return
}
// 解析游标分页请求
req := h.parseCursorRequest(c)
// 调用游标分页服务
result, err := h.groupService.GetUserGroupsByCursor(c.Request.Context(), userID, req)
if err != nil {
response.InternalServerError(c, err.Error())
return
}
// 转换为响应结构
groupResponses := dto.GroupsToResponseFromPtr(result.Items)
// 构建游标分页响应
cursorResp := &dto.GroupCursorPageResponse{
Items: groupResponses,
NextCursor: result.NextCursor,
PrevCursor: result.PrevCursor,
HasMore: result.HasMore,
}
response.Success(c, cursorResp)
}
// GetMembersByCursor 游标分页获取群成员列表
// GET /api/groups/:id/members/cursor
func (h *GroupHandler) GetMembersByCursor(c *gin.Context) {
userID := parseUserID(c)
if userID == "" {
response.Unauthorized(c, "")
return
}
groupID := parseGroupID(c)
if groupID == "" {
response.BadRequest(c, "invalid group id")
return
}
// 解析游标分页请求
req := h.parseCursorRequest(c)
// 调用游标分页服务
result, err := h.groupService.GetMembersByCursor(c.Request.Context(), groupID, req)
if err != nil {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
response.InternalServerError(c, err.Error())
return
}
// 转换为响应结构
memberResponses := dto.GroupMembersToResponseFromPtr(result.Items)
// 构建游标分页响应
cursorResp := &dto.GroupMemberCursorPageResponse{
Items: memberResponses,
NextCursor: result.NextCursor,
PrevCursor: result.PrevCursor,
HasMore: result.HasMore,
}
response.Success(c, cursorResp)
}
// GetAnnouncementsByCursor 游标分页获取群公告列表
// GET /api/v1/groups/:id/announcements/cursor
//
// 可见性策略:仅群成员可见。非成员返回 403 NOT_GROUP_MEMBER。
func (h *GroupHandler) GetAnnouncementsByCursor(c *gin.Context) {
userID := parseUserID(c)
if userID == "" {
response.Unauthorized(c, "")
return
}
groupID := parseGroupID(c)
if groupID == "" {
response.BadRequest(c, "invalid group id")
return
}
// 成员校验
if !h.groupService.IsGroupMember(userID, groupID) {
response.Forbidden(c, "不是群成员")
return
}
// 解析游标分页请求
req := h.parseCursorRequest(c)
// 调用游标分页服务
result, err := h.groupService.GetAnnouncementsByCursor(c.Request.Context(), groupID, req)
if err != nil {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
response.InternalServerError(c, err.Error())
return
}
// 转换为响应结构
announcementResponses := dto.GroupAnnouncementsToResponseFromPtr(result.Items)
// 构建游标分页响应
cursorResp := &dto.GroupAnnouncementCursorPageResponse{
Items: announcementResponses,
NextCursor: result.NextCursor,
PrevCursor: result.PrevCursor,
HasMore: result.HasMore,
}
response.Success(c, cursorResp)
}
// parseCursorRequest 解析游标分页请求参数
func (h *GroupHandler) parseCursorRequest(c *gin.Context) *cursor.PageRequest {
cursorStr := c.Query("cursor")
direction := cursor.Direction(c.DefaultQuery("direction", "forward"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
return cursor.NewPageRequest(cursorStr, direction, pageSize)
}
// ==================== RESTful Action 端点 ====================
// HandleSetGroupKick 群组踢人
@@ -1314,7 +1489,7 @@ func (h *GroupHandler) HandleSetGroupKick(c *gin.Context) {
response.Forbidden(c, "只有群主或管理员可以移除成员")
return
}
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -1385,7 +1560,7 @@ func (h *GroupHandler) HandleSetGroupBan(c *gin.Context) {
response.Forbidden(c, "只有群主或管理员可以禁言成员")
return
}
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -1435,7 +1610,7 @@ func (h *GroupHandler) HandleSetGroupWholeBan(c *gin.Context) {
response.Forbidden(c, "只有群主可以设置全员禁言")
return
}
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -1488,7 +1663,7 @@ func (h *GroupHandler) HandleSetGroupAdmin(c *gin.Context) {
response.Forbidden(c, "只有群主可以设置管理员")
return
}
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -1533,7 +1708,7 @@ func (h *GroupHandler) HandleSetGroupName(c *gin.Context) {
return
}
updates := map[string]interface{}{
updates := map[string]any{
"name": params.GroupName,
}
@@ -1543,7 +1718,7 @@ func (h *GroupHandler) HandleSetGroupName(c *gin.Context) {
response.Forbidden(c, "没有权限修改群组信息")
return
}
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -1582,7 +1757,7 @@ func (h *GroupHandler) HandleSetGroupAvatar(c *gin.Context) {
return
}
updates := map[string]interface{}{
updates := map[string]any{
"avatar": params.Avatar,
}
@@ -1592,7 +1767,51 @@ func (h *GroupHandler) HandleSetGroupAvatar(c *gin.Context) {
response.Forbidden(c, "没有权限修改群组信息")
return
}
if err == service.ErrGroupNotFound {
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))
}
// 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
}
@@ -1626,7 +1845,7 @@ func (h *GroupHandler) HandleSetGroupLeave(c *gin.Context) {
response.BadRequest(c, "不是群成员")
return
}
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -1734,6 +1953,8 @@ func (h *GroupHandler) HandleRespondInvite(c *gin.Context) {
// HandleGetGroupInfo 获取群信息
// GET /api/v1/groups/:id
//
// 可见性策略成员返回完整详情非成员仅返回公开字段name/avatar/description/member_count
func (h *GroupHandler) HandleGetGroupInfo(c *gin.Context) {
userID := parseUserID(c)
if userID == "" {
@@ -1749,7 +1970,7 @@ func (h *GroupHandler) HandleGetGroupInfo(c *gin.Context) {
group, err := h.groupService.GetGroupByID(groupID)
if err != nil {
if err == service.ErrGroupNotFound {
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
return
}
@@ -1764,11 +1985,26 @@ func (h *GroupHandler) HandleGetGroupInfo(c *gin.Context) {
resp := dto.GroupToResponse(group)
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)
}
// HandleGetGroupMemberList 获取群成员列表
// GET /api/v1/groups/:id/members
//
// 可见性策略:仅群成员可见。非成员返回 403 NOT_GROUP_MEMBER。
func (h *GroupHandler) HandleGetGroupMemberList(c *gin.Context) {
userID := parseUserID(c)
if userID == "" {
@@ -1785,9 +2021,14 @@ func (h *GroupHandler) HandleGetGroupMemberList(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
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 == service.ErrGroupNotFound {
if errors.Is(err, service.ErrNotGroupMember) {
response.Forbidden(c, "不是群成员")
return
}
if errors.Is(err, service.ErrGroupNotFound) {
response.NotFound(c, "群组不存在")
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

@@ -0,0 +1,491 @@
package handler
import (
"errors"
"strconv"
"with_you/internal/model"
"with_you/internal/pkg/response"
"with_you/internal/query"
"with_you/internal/service"
"github.com/gin-gonic/gin"
)
// MaterialHandler 学习资料处理器
type MaterialHandler struct {
materialService service.MaterialService
}
// NewMaterialHandler 创建学习资料处理器
func NewMaterialHandler(materialService service.MaterialService) *MaterialHandler {
return &MaterialHandler{materialService: materialService}
}
// =====================================================
// 响应结构体
// =====================================================
type subjectResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
Color string `json:"color"`
Description string `json:"description,omitempty"`
SortOrder int `json:"sort_order"`
IsActive bool `json:"is_active"`
MaterialCount int `json:"material_count"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type materialResponse struct {
ID string `json:"id"`
SubjectID string `json:"subject_id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
FileType string `json:"file_type"`
FileSize int64 `json:"file_size"`
FileURL string `json:"file_url"`
FileName string `json:"file_name"`
DownloadCount int `json:"download_count"`
AuthorID string `json:"author_id,omitempty"`
Tags []string `json:"tags,omitempty"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Subject *subjectResponse `json:"subject,omitempty"`
}
// =====================================================
// 转换函数
// =====================================================
func toSubjectResponse(s *model.MaterialSubject, materialCount int64) *subjectResponse {
if s == nil {
return nil
}
return &subjectResponse{
ID: s.ID,
Name: s.Name,
Icon: s.Icon,
Color: s.Color,
Description: s.Description,
SortOrder: s.SortOrder,
IsActive: s.IsActive,
MaterialCount: int(materialCount),
CreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: s.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
}
func toMaterialResponse(f *model.MaterialFile) *materialResponse {
if f == nil {
return nil
}
var subjectResp *subjectResponse
if f.Subject != nil {
subjectResp = toSubjectResponse(f.Subject, 0)
}
return &materialResponse{
ID: f.ID,
SubjectID: f.SubjectID,
Title: f.Title,
Description: f.Description,
FileType: string(f.FileType),
FileSize: f.FileSize,
FileURL: f.FileURL,
FileName: f.FileName,
DownloadCount: f.DownloadCount,
AuthorID: f.AuthorID,
Tags: f.Tags,
Status: string(f.Status),
CreatedAt: f.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: f.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
Subject: subjectResp,
}
}
// =====================================================
// 公开API - 学科相关
// =====================================================
// ListSubjects 公开学科列表(仅启用)
func (h *MaterialHandler) ListSubjects(c *gin.Context) {
items, err := h.materialService.ListActiveSubjects()
if err != nil {
response.InternalServerError(c, "failed to get subjects")
return
}
result := make([]*subjectResponse, 0, len(items))
for _, item := range items {
_, count, _ := h.materialService.GetSubjectByIDWithMaterialCount(item.ID)
result = append(result, toSubjectResponse(item, count))
}
response.Success(c, gin.H{"list": result})
}
// GetSubject 获取学科详情
func (h *MaterialHandler) GetSubject(c *gin.Context) {
id := c.Param("id")
item, count, err := h.materialService.GetSubjectByIDWithMaterialCount(id)
if err != nil {
response.NotFound(c, "subject not found")
return
}
response.Success(c, toSubjectResponse(item, count))
}
// =====================================================
// 公开API - 资料相关
// =====================================================
// ListMaterials 资料列表
func (h *MaterialHandler) ListMaterials(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
subjectID := c.Query("subject_id")
fileType := c.Query("file_type")
keyword := c.Query("keyword")
params := query.MaterialFileQueryParams{
SubjectID: subjectID,
FileType: fileType,
Keyword: keyword,
Status: string(model.MaterialStatusActive),
Page: page,
PageSize: pageSize,
SortBy: "created_at",
SortOrder: "DESC",
}
items, total, err := h.materialService.ListMaterials(params)
if err != nil {
response.InternalServerError(c, "failed to get materials")
return
}
result := make([]*materialResponse, 0, len(items))
for _, item := range items {
result = append(result, toMaterialResponse(item))
}
response.Success(c, gin.H{
"list": result,
"total": total,
"page": page,
"page_size": pageSize,
"has_more": int64(page*pageSize) < total,
})
}
// GetMaterial 获取资料详情
func (h *MaterialHandler) GetMaterial(c *gin.Context) {
id := c.Param("id")
item, err := h.materialService.GetMaterialByIDWithSubject(id)
if err != nil {
response.NotFound(c, "material not found")
return
}
response.Success(c, toMaterialResponse(item))
}
// SearchMaterials 搜索资料
func (h *MaterialHandler) SearchMaterials(c *gin.Context) {
keyword := c.Query("keyword")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
items, total, err := h.materialService.SearchMaterials(keyword, page, pageSize)
if err != nil {
response.InternalServerError(c, "failed to search materials")
return
}
result := make([]*materialResponse, 0, len(items))
for _, item := range items {
result = append(result, toMaterialResponse(item))
}
response.Success(c, gin.H{
"list": result,
"total": total,
"page": page,
"page_size": pageSize,
"has_more": int64(page*pageSize) < total,
})
}
// DownloadMaterial 下载资料(增加下载次数)
func (h *MaterialHandler) DownloadMaterial(c *gin.Context) {
id := c.Param("id")
item, err := h.materialService.GetMaterialByID(id)
if err != nil {
response.NotFound(c, "material not found")
return
}
// 增加下载次数
_ = h.materialService.IncrementDownloadCount(id)
// 返回下载链接
response.Success(c, gin.H{
"id": item.ID,
"file_url": item.FileURL,
"file_name": item.FileName,
})
}
// =====================================================
// 管理API - 学科相关
// =====================================================
// AdminListSubjects 管理端学科列表(全部)
func (h *MaterialHandler) AdminListSubjects(c *gin.Context) {
items, err := h.materialService.ListAllSubjects()
if err != nil {
response.InternalServerError(c, "failed to get subjects")
return
}
result := make([]*subjectResponse, 0, len(items))
for _, item := range items {
_, count, _ := h.materialService.GetSubjectByIDWithMaterialCount(item.ID)
result = append(result, toSubjectResponse(item, count))
}
response.Success(c, gin.H{"list": result})
}
type adminSubjectUpsertRequest struct {
Name string `json:"name" binding:"required,max=100"`
Icon string `json:"icon" binding:"max=50"`
Color string `json:"color" binding:"max=20"`
Description string `json:"description" binding:"max=255"`
SortOrder int `json:"sort_order"`
IsActive *bool `json:"is_active"`
}
// AdminCreateSubject 创建学科
func (h *MaterialHandler) AdminCreateSubject(c *gin.Context) {
var req adminSubjectUpsertRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
isActive := true
if req.IsActive != nil {
isActive = *req.IsActive
}
item, err := h.materialService.CreateSubject(req.Name, req.Icon, req.Color, req.Description, req.SortOrder, isActive)
if err != nil {
response.InternalServerError(c, "failed to create subject")
return
}
response.Success(c, toSubjectResponse(item, 0))
}
// AdminUpdateSubject 更新学科
func (h *MaterialHandler) AdminUpdateSubject(c *gin.Context) {
id := c.Param("id")
var req adminSubjectUpsertRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
isActive := true
if req.IsActive != nil {
isActive = *req.IsActive
}
item, err := h.materialService.UpdateSubject(id, req.Name, req.Icon, req.Color, req.Description, req.SortOrder, isActive)
if err != nil {
response.InternalServerError(c, "failed to update subject")
return
}
_, count, _ := h.materialService.GetSubjectByIDWithMaterialCount(item.ID)
response.Success(c, toSubjectResponse(item, count))
}
// AdminDeleteSubject 删除学科
func (h *MaterialHandler) AdminDeleteSubject(c *gin.Context) {
id := c.Param("id")
if err := h.materialService.DeleteSubject(id); err != nil {
if errors.Is(err, service.ErrSubjectHasMaterials) {
response.BadRequest(c, "学科下存在资料,无法删除")
return
}
response.InternalServerError(c, "failed to delete subject")
return
}
response.Success(c, gin.H{"success": true})
}
// =====================================================
// 管理API - 资料相关
// =====================================================
// AdminListMaterials 管理端资料列表
func (h *MaterialHandler) AdminListMaterials(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
subjectID := c.Query("subject_id")
fileType := c.Query("file_type")
status := c.Query("status")
keyword := c.Query("keyword")
params := query.MaterialFileQueryParams{
SubjectID: subjectID,
FileType: fileType,
Status: status,
Keyword: keyword,
Page: page,
PageSize: pageSize,
SortBy: "created_at",
SortOrder: "DESC",
}
items, total, err := h.materialService.ListMaterials(params)
if err != nil {
response.InternalServerError(c, "failed to get materials")
return
}
result := make([]*materialResponse, 0, len(items))
for _, item := range items {
result = append(result, toMaterialResponse(item))
}
response.Success(c, gin.H{
"list": result,
"total": total,
"page": page,
"page_size": pageSize,
"total_pages": (total + int64(pageSize) - 1) / int64(pageSize),
})
}
type adminMaterialCreateRequest struct {
SubjectID string `json:"subject_id" binding:"required"`
Title string `json:"title" binding:"required,max=200"`
Description string `json:"description"`
FileType string `json:"file_type" binding:"required,oneof=pdf word ppt"`
FileSize int64 `json:"file_size"`
FileURL string `json:"file_url" binding:"required"`
FileName string `json:"file_name"`
Tags []string `json:"tags"`
}
type adminMaterialUpdateRequest struct {
SubjectID string `json:"subject_id"`
Title string `json:"title" binding:"max=200"`
Description string `json:"description"`
FileType string `json:"file_type" binding:"omitempty,oneof=pdf word ppt"`
FileSize int64 `json:"file_size"`
FileURL string `json:"file_url"`
FileName string `json:"file_name"`
Tags []string `json:"tags"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
}
// AdminGetMaterial 管理端获取资料详情
func (h *MaterialHandler) AdminGetMaterial(c *gin.Context) {
id := c.Param("id")
item, err := h.materialService.GetMaterialByIDWithSubject(id)
if err != nil {
response.NotFound(c, "material not found")
return
}
response.Success(c, toMaterialResponse(item))
}
// AdminCreateMaterial 创建资料
func (h *MaterialHandler) AdminCreateMaterial(c *gin.Context) {
var req adminMaterialCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
// 获取当前用户ID作为作者
userID, exists := c.Get("user_id")
var authorID string
if exists {
authorID = userID.(string)
}
file := &model.MaterialFile{
SubjectID: req.SubjectID,
Title: req.Title,
Description: req.Description,
FileType: model.MaterialFileType(req.FileType),
FileSize: req.FileSize,
FileURL: req.FileURL,
FileName: req.FileName,
Tags: req.Tags,
Status: model.MaterialStatusActive,
AuthorID: authorID,
}
if err := h.materialService.CreateMaterial(file); err != nil {
response.InternalServerError(c, "failed to create material")
return
}
response.Success(c, toMaterialResponse(file))
}
// AdminUpdateMaterial 更新资料
func (h *MaterialHandler) AdminUpdateMaterial(c *gin.Context) {
id := c.Param("id")
var req adminMaterialUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
file, err := h.materialService.GetMaterialByID(id)
if err != nil {
response.NotFound(c, "material not found")
return
}
// 更新字段
if req.SubjectID != "" {
file.SubjectID = req.SubjectID
}
if req.Title != "" {
file.Title = req.Title
}
if req.Description != "" {
file.Description = req.Description
}
if req.FileType != "" {
file.FileType = model.MaterialFileType(req.FileType)
}
if req.FileSize > 0 {
file.FileSize = req.FileSize
}
if req.FileURL != "" {
file.FileURL = req.FileURL
}
if req.FileName != "" {
file.FileName = req.FileName
}
if req.Tags != nil {
file.Tags = req.Tags
}
if req.Status != "" {
file.Status = model.MaterialStatus(req.Status)
}
if err := h.materialService.UpdateMaterial(file); err != nil {
response.InternalServerError(c, "failed to update material")
return
}
response.Success(c, toMaterialResponse(file))
}
// AdminDeleteMaterial 删除资料
func (h *MaterialHandler) AdminDeleteMaterial(c *gin.Context) {
id := c.Param("id")
if err := h.materialService.DeleteMaterial(id); err != nil {
response.InternalServerError(c, "failed to delete material")
return
}
response.Success(c, gin.H{"success": true})
}

View File

@@ -2,110 +2,123 @@ package handler
import (
"context"
"fmt"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"carrot_bbs/internal/dto"
"carrot_bbs/internal/model"
"carrot_bbs/internal/pkg/response"
"carrot_bbs/internal/pkg/sse"
"carrot_bbs/internal/service"
"with_you/internal/dto"
"with_you/internal/model"
"with_you/internal/pkg/cursor"
"with_you/internal/pkg/response"
"with_you/internal/pkg/ws"
"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 消息处理器
type MessageHandler struct {
chatService service.ChatService
messageService *service.MessageService
messageService service.MessageService
userService service.UserService
groupService service.GroupService
sseHub *sse.Hub
uploadService service.UploadService
wsPublisher ws.MessagePublisher
}
// NewMessageHandler 创建消息处理器
func NewMessageHandler(chatService service.ChatService, messageService *service.MessageService, userService service.UserService, groupService service.GroupService, sseHub *sse.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{
chatService: chatService,
messageService: messageService,
userService: userService,
groupService: groupService,
sseHub: sseHub,
uploadService: uploadService,
wsPublisher: wsPublisher,
}
}
// HandleSSE 实时消息订阅SSE
// GET /api/v1/realtime/sse
func (h *MessageHandler) HandleSSE(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "")
return
}
if h.sseHub == nil {
response.InternalServerError(c, "sse hub not available")
return
}
lastID := sse.ParseEventID(c.GetHeader("Last-Event-ID"))
if lastID == 0 {
lastID = sse.ParseEventID(c.Query("last_event_id"))
}
ch, cancel, replay := h.sseHub.Subscribe(userID, lastID)
defer cancel()
w := c.Writer
flusher, ok := w.(http.Flusher)
if !ok {
response.InternalServerError(c, "streaming unsupported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
c.Status(http.StatusOK)
flusher.Flush()
writeEvent := func(ev sse.Event) bool {
data, err := sse.EncodeData(ev)
if err != nil {
return false
}
if _, err := fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", ev.ID, ev.Event, data); err != nil {
return false
}
flusher.Flush()
return true
}
for _, ev := range replay {
if !writeEvent(ev) {
return
// 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
}
}
heartbeat := time.NewTicker(25 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-c.Request.Context().Done():
return
case ev, ok := <-ch:
if !ok || !writeEvent(ev) {
return
}
case <-heartbeat.C:
if _, err := fmt.Fprint(w, "event: heartbeat\ndata: {}\n\n"); err != nil {
return
}
flusher.Flush()
}
if len(expiredSet) == 0 {
return dto.ConvertMessagesToResponse(messages)
}
return dto.ConvertMessagesToResponseWithExpiry(messages, expiredSet)
}
// HandleTyping 输入状态上报
@@ -154,36 +167,8 @@ func (h *MessageHandler) GetConversations(c *gin.Context) {
}
}
// 转换为响应格式
result := make([]*dto.ConversationResponse, len(filteredConvs))
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
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)
resp.MemberCount = memberCount
} else {
// 私聊:获取参与者信息
participants, _ := h.getConversationParticipants(c.Request.Context(), conv.ID, userID)
resp = dto.ConvertConversationToResponse(conv, participants, int(unreadCount), lastMessage, isPinned)
}
result[i] = resp
}
// 批量填充会话数据(解决 N+1 问题)
result := h.enrichConversations(c.Request.Context(), filteredConvs, userID)
// 更新 total 为过滤后的数量
response.Paginated(c, result, int64(len(filteredConvs)), page, pageSize)
@@ -227,8 +212,9 @@ func (h *MessageHandler) CreateConversation(c *gin.Context) {
participants := []*model.User{targetUser}
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
isPinned := myParticipant != nil && myParticipant.IsPinned
notificationMuted := myParticipant != nil && myParticipant.NotificationMuted
response.Success(c, dto.ConvertConversationToResponse(conv, participants, 0, nil, isPinned))
response.Success(c, dto.ConvertConversationToResponse(conv, participants, 0, nil, isPinned, notificationMuted))
}
// GetConversationByID 获取会话详情
@@ -262,18 +248,26 @@ func (h *MessageHandler) GetConversationByID(c *gin.Context) {
// 获取当前用户的已读位置
myLastReadSeq := int64(0)
isPinned := false
notificationMuted := false
allParticipants, _ := h.messageService.GetConversationParticipants(conversationID)
for _, p := range allParticipants {
if p.UserID == userID {
myLastReadSeq = p.LastReadSeq
isPinned = p.IsPinned
notificationMuted = p.NotificationMuted
break
}
}
// 获取对方用户的已读位置
otherLastReadSeq := int64(0)
response.Success(c, dto.ConvertConversationToDetailResponse(conv, participants, unreadCount, nil, myLastReadSeq, otherLastReadSeq, isPinned))
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))
}
// GetMessages 获取消息列表
@@ -310,8 +304,8 @@ func (h *MessageHandler) GetMessages(c *gin.Context) {
return
}
// 转换为响应格式
result := dto.ConvertMessagesToResponse(messages)
// 转换为响应格式(标记已过期文件)
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
response.Success(c, &dto.MessageSyncResponse{
Messages: result,
@@ -338,8 +332,8 @@ func (h *MessageHandler) GetMessages(c *gin.Context) {
return
}
// 转换为响应格式
result := dto.ConvertMessagesToResponse(messages)
// 转换为响应格式(标记已过期文件)
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
response.Success(c, &dto.MessageSyncResponse{
Messages: result,
@@ -358,12 +352,43 @@ func (h *MessageHandler) GetMessages(c *gin.Context) {
return
}
// 转换为响应格式
result := dto.ConvertMessagesToResponse(messages)
// 转换为响应格式(标记已过期文件)
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
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 发送消息
// POST /api/conversations/:id/messages
func (h *MessageHandler) SendMessage(c *gin.Context) {
@@ -386,8 +411,7 @@ func (h *MessageHandler) SendMessage(c *gin.Context) {
return
}
// 直接使用 segments
msg, err := h.chatService.SendMessage(c.Request.Context(), userID, conversationID, req.Segments, req.ReplyToID)
msg, err := h.chatService.SendMessage(c.Request.Context(), userID, conversationID, req.Segments, req.ReplyToID, req.ClientMsgID)
if err != nil {
response.BadRequest(c, err.Error())
return
@@ -429,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 {
response.BadRequest(c, err.Error())
return
@@ -437,13 +461,14 @@ func (h *MessageHandler) HandleSendMessage(c *gin.Context) {
// 构建 WSEventResponse 格式响应
wsResponse := dto.WSEventResponse{
ID: msg.ID,
Time: msg.CreatedAt.UnixMilli(),
Type: "message",
DetailType: params.DetailType,
Seq: strconv.FormatInt(msg.Seq, 10),
Segments: params.Segments,
SenderID: userID,
ID: msg.ID,
Time: msg.CreatedAt.UnixMilli(),
Type: "message",
DetailType: params.DetailType,
ConversationID: conversationID,
Seq: msg.Seq,
Segments: params.Segments,
SenderID: userID,
}
response.Success(c, wsResponse)
@@ -507,36 +532,8 @@ func (h *MessageHandler) HandleGetConversationList(c *gin.Context) {
}
}
// 转换为响应格式
result := make([]*dto.ConversationResponse, len(filteredConvs))
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
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)
resp.MemberCount = memberCount
} else {
// 私聊:获取参与者信息
participants, _ := h.getConversationParticipants(c.Request.Context(), conv.ID, userID)
resp = dto.ConvertConversationToResponse(conv, participants, int(unreadCount), lastMessage, isPinned)
}
result[i] = resp
}
// 批量填充会话数据(解决 N+1 问题)
result := h.enrichConversations(c.Request.Context(), filteredConvs, userID)
response.Paginated(c, result, int64(len(filteredConvs)), page, pageSize)
}
@@ -616,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 获取单个会话的未读数
// GET /api/conversations/:id/unread/count
func (h *MessageHandler) GetConversationUnreadCount(c *gin.Context) {
@@ -684,16 +732,6 @@ func (h *MessageHandler) DeleteMessage(c *gin.Context) {
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) {
// 从repository获取参与者列表
@@ -777,8 +815,9 @@ func (h *MessageHandler) HandleCreateConversation(c *gin.Context) {
participants := []*model.User{targetUser}
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
isPinned := myParticipant != nil && myParticipant.IsPinned
notificationMuted := myParticipant != nil && myParticipant.NotificationMuted
response.Success(c, dto.ConvertConversationToResponse(conv, participants, 0, nil, isPinned))
response.Success(c, dto.ConvertConversationToResponse(conv, participants, 0, nil, isPinned, notificationMuted))
}
// HandleGetConversation 获取会话详情
@@ -808,21 +847,29 @@ func (h *MessageHandler) HandleGetConversation(c *gin.Context) {
// 获取参与者信息
participants, _ := h.getConversationParticipants(c.Request.Context(), conversationID, userID)
// 获取当前用户的已读位置
// 获取当前用户的已读位置(优先从 Redis 缓存读取)
myLastReadSeq := int64(0)
isPinned := false
notificationMuted := false
allParticipants, _ := h.messageService.GetConversationParticipants(conversationID)
for _, p := range allParticipants {
if p.UserID == userID {
myLastReadSeq = p.LastReadSeq
isPinned = p.IsPinned
notificationMuted = p.NotificationMuted
break
}
}
// 获取对方用户的已读位置
// 获取对方用户的已读位置(优先从 Redis 缓存读取)
otherLastReadSeq := int64(0)
response.Success(c, dto.ConvertConversationToDetailResponse(conv, participants, unreadCount, nil, myLastReadSeq, otherLastReadSeq, isPinned))
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))
}
// HandleGetMessages 获取会话消息
@@ -858,8 +905,8 @@ func (h *MessageHandler) HandleGetMessages(c *gin.Context) {
return
}
// 转换为响应格式
result := dto.ConvertMessagesToResponse(messages)
// 转换为响应格式(标记已过期文件)
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
response.Success(c, &dto.MessageSyncResponse{
Messages: result,
@@ -886,8 +933,8 @@ func (h *MessageHandler) HandleGetMessages(c *gin.Context) {
return
}
// 转换为响应格式
result := dto.ConvertMessagesToResponse(messages)
// 转换为响应格式(标记已过期文件)
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
response.Success(c, &dto.MessageSyncResponse{
Messages: result,
@@ -906,8 +953,8 @@ func (h *MessageHandler) HandleGetMessages(c *gin.Context) {
return
}
// 转换为响应格式
result := dto.ConvertMessagesToResponse(messages)
// 转换为响应格式(标记已过期文件)
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
response.Paginated(c, result, total, page, pageSize)
}
@@ -942,6 +989,33 @@ func (h *MessageHandler) HandleMarkRead(c *gin.Context) {
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 设置会话置顶
// PUT /api/v1/conversations/:id/pinned
func (h *MessageHandler) HandleSetConversationPinned(c *gin.Context) {
@@ -975,3 +1049,129 @@ func (h *MessageHandler) HandleSetConversationPinned(c *gin.Context) {
"is_pinned": req.IsPinned,
})
}
// HandleSetConversationNotificationMuted 设置会话免打扰状态
// PUT /api/v1/conversations/:id/notification_muted
func (h *MessageHandler) HandleSetConversationNotificationMuted(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "")
return
}
conversationID := getIDParam(c, "id")
if conversationID == "" {
response.BadRequest(c, "conversation id is required")
return
}
var req struct {
NotificationMuted bool `json:"notification_muted"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := h.chatService.SetConversationNotificationMuted(c.Request.Context(), conversationID, userID, req.NotificationMuted); err != nil {
response.BadRequest(c, err.Error())
return
}
response.SuccessWithMessage(c, "conversation notification_muted status updated", gin.H{
"conversation_id": conversationID,
"notification_muted": req.NotificationMuted,
})
}
// ==================== Cursor Pagination Handlers ====================
// GetMessagesByCursor 游标分页获取会话消息
// GET /api/v1/conversations/:id/messages/cursor
// 请求参数:
// - cursor: 游标字符串(可选)
// - direction: 分页方向 forward 或 backward默认 forward
// - page_size: 每页数量(默认 20最大 100
func (h *MessageHandler) GetMessagesByCursor(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "")
return
}
conversationID := getIDParam(c, "id")
if conversationID == "" {
response.BadRequest(c, "conversation_id is required")
return
}
// 解析游标分页参数
pageReq := parseCursorPageRequest(c)
// 调用服务层(内部会校验 currentUserID 是否是会话参与者,否则拒绝)
result, err := h.messageService.GetMessagesByCursor(c.Request.Context(), conversationID, userID, pageReq)
if err != nil {
response.HandleError(c, err, "failed to get messages")
return
}
// 转换为响应格式(标记已过期文件)
items := h.convertMessagesWithExpiry(c.Request.Context(), result.Items)
response.Success(c, &dto.MessageCursorPageResponse{
Items: items,
NextCursor: result.NextCursor,
PrevCursor: result.PrevCursor,
HasMore: result.HasMore,
})
}
// GetConversationsByCursor 游标分页获取会话列表
// GET /api/v1/conversations/cursor
// 请求参数:
// - cursor: 游标字符串(可选)
// - direction: 分页方向 forward 或 backward默认 forward
// - page_size: 每页数量(默认 20最大 100
func (h *MessageHandler) GetConversationsByCursor(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "")
return
}
// 解析游标分页参数
pageReq := parseCursorPageRequest(c)
// 调用服务层
result, err := h.messageService.GetConversationsByCursor(c.Request.Context(), userID, pageReq)
if err != nil {
response.InternalServerError(c, "failed to get conversations")
return
}
// 过滤掉系统会话
filteredItems := make([]*model.Conversation, 0)
for _, conv := range result.Items {
if conv.ID != model.SystemConversationID {
filteredItems = append(filteredItems, conv)
}
}
// 批量填充会话数据(解决 N+1 问题)
items := h.enrichConversations(c.Request.Context(), filteredItems, userID)
response.Success(c, &dto.ConversationCursorPageResponse{
Items: items,
NextCursor: result.NextCursor,
PrevCursor: result.PrevCursor,
HasMore: result.HasMore,
})
}
// parseCursorPageRequest 解析游标分页请求参数
func parseCursorPageRequest(c *gin.Context) *cursor.PageRequest {
cursorStr := c.Query("cursor")
direction := cursor.Direction(c.DefaultQuery("direction", "forward"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
return cursor.NewPageRequest(cursorStr, direction, pageSize)
}

View File

@@ -5,17 +5,19 @@ import (
"github.com/gin-gonic/gin"
"carrot_bbs/internal/pkg/response"
"carrot_bbs/internal/service"
"with_you/internal/dto"
"with_you/internal/pkg/cursor"
"with_you/internal/pkg/response"
"with_you/internal/service"
)
// NotificationHandler 通知处理器
type NotificationHandler struct {
notificationService *service.NotificationService
notificationService service.NotificationService
}
// NewNotificationHandler 创建通知处理器
func NewNotificationHandler(notificationService *service.NotificationService) *NotificationHandler {
func NewNotificationHandler(notificationService service.NotificationService) *NotificationHandler {
return &NotificationHandler{
notificationService: notificationService,
}
@@ -54,7 +56,7 @@ func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
err := h.notificationService.MarkAsReadWithUserID(c.Request.Context(), id, userID)
if err != nil {
response.InternalServerError(c, "failed to mark as read")
response.HandleError(c, err, "failed to mark as read")
return
}
@@ -107,7 +109,7 @@ func (h *NotificationHandler) DeleteNotification(c *gin.Context) {
err := h.notificationService.DeleteNotification(c.Request.Context(), id, userID)
if err != nil {
response.InternalServerError(c, "failed to delete notification")
response.HandleError(c, err, "failed to delete notification")
return
}
@@ -130,3 +132,46 @@ func (h *NotificationHandler) ClearAllNotifications(c *gin.Context) {
response.Success(c, gin.H{"success": true})
}
// GetNotificationsByCursor 游标分页获取通知列表
func (h *NotificationHandler) GetNotificationsByCursor(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "")
return
}
// 解析游标分页请求
req := h.parseCursorRequest(c)
unreadOnly := c.Query("unread_only") == "true"
// 调用游标分页服务
result, err := h.notificationService.GetNotificationsByCursor(c.Request.Context(), userID, unreadOnly, req)
if err != nil {
response.InternalServerError(c, "failed to get notifications")
return
}
// 转换为响应结构
notificationResponses := dto.ConvertNotificationsToResponse(result.Items)
// 构建游标分页响应
cursorResp := &dto.NotificationCursorPageResponse{
Items: notificationResponses,
NextCursor: result.NextCursor,
PrevCursor: result.PrevCursor,
HasMore: result.HasMore,
}
response.Success(c, cursorResp)
}
// parseCursorRequest 解析游标分页请求参数
func (h *NotificationHandler) parseCursorRequest(c *gin.Context) *cursor.PageRequest {
cursorStr := c.Query("cursor")
direction := cursor.Direction(c.DefaultQuery("direction", "forward"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
return cursor.NewPageRequest(cursorStr, direction, pageSize)
}

View File

@@ -1,32 +1,89 @@
package handler
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"carrot_bbs/internal/dto"
"carrot_bbs/internal/model"
"carrot_bbs/internal/pkg/response"
"carrot_bbs/internal/service"
"with_you/internal/dto"
"with_you/internal/model"
"with_you/internal/pkg/cursor"
"with_you/internal/pkg/response"
"with_you/internal/service"
)
// PostHandler 帖子处理器
type PostHandler struct {
postService service.PostService
userService service.UserService
postService service.PostService
postRefService service.PostRefService
userService service.UserService
channelService service.ChannelService
}
// NewPostHandler 创建帖子处理器
func NewPostHandler(postService service.PostService, userService service.UserService) *PostHandler {
func NewPostHandler(postService service.PostService, postRefService service.PostRefService, userService service.UserService, channelService service.ChannelService) *PostHandler {
return &PostHandler{
postService: postService,
userService: userService,
postService: postService,
postRefService: postRefService,
userService: userService,
channelService: channelService,
}
}
// collectAuthorIDs 从帖子列表中收集去重的作者ID
func collectAuthorIDs(posts []*model.Post) []string {
seen := make(map[string]struct{})
ids := make([]string, 0, len(posts))
for _, p := range posts {
if _, ok := seen[p.UserID]; !ok {
seen[p.UserID] = struct{}{}
ids = append(ids, p.UserID)
}
}
return ids
}
// filterBlockedPosts 过滤掉被当前用户拉黑的作者发布的帖子
func (h *PostHandler) filterBlockedPosts(ctx context.Context, posts []*model.Post, currentUserID string) []*model.Post {
if currentUserID == "" || len(posts) == 0 {
return posts
}
authorIDs := collectAuthorIDs(posts)
blockedMap, err := h.userService.IsBlockedBatch(ctx, currentUserID, authorIDs)
if err != nil {
return posts
}
filtered := make([]*model.Post, 0, len(posts))
for _, p := range posts {
if blockedMap[p.UserID] {
continue
}
filtered = append(filtered, p)
}
return filtered
}
// channelMapForPosts 批量解析帖子中的频道 ID用于填充响应里的 channel 名称
func (h *PostHandler) channelMapForPosts(posts []*model.Post) map[string]*model.Channel {
ids := dto.CollectPostChannelIDs(posts)
if len(ids) == 0 {
return nil
}
m, err := h.channelService.MapByIDs(ids)
if err != nil {
return nil
}
return m
}
// Create 创建帖子
func (h *PostHandler) Create(c *gin.Context) {
userID := c.GetString("user_id")
@@ -35,30 +92,42 @@ func (h *PostHandler) Create(c *gin.Context) {
return
}
type CreateRequest struct {
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
Images []string `json:"images"`
}
var req CreateRequest
var req dto.CreatePostWithSegmentsRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
post, err := h.postService.Create(c.Request.Context(), userID, req.Title, req.Content, req.Images)
content := req.Content
segments := req.Segments
if len(segments) == 0 && content == "" {
response.BadRequest(c, "content or segments is required")
return
}
post, err := h.postService.Create(c.Request.Context(), userID, req.Title, content, segments, req.Images, req.ChannelID, req.ClientRequestID)
if err != nil {
var moderationErr *service.PostModerationRejectedError
if errors.As(err, &moderationErr) {
response.BadRequest(c, moderationErr.UserMessage())
// 幂等命中"进行中"占位:提示客户端稍候,避免重复提交
if errors.Is(err, service.ErrDuplicatePostRequest) {
response.ErrorWithStatusCode(c, http.StatusTooManyRequests, 429, err.Error())
return
}
response.InternalServerError(c, "failed to create post")
return
}
response.Success(c, dto.ConvertPostToResponse(post, false, false))
if h.postRefService != nil && len(segments) > 0 {
go func() {
bgCtx := context.Background()
if err := h.postRefService.ProcessPostReferences(bgCtx, post.ID, segments); err != nil {
log.Printf("[WARN] process post references failed for post %s: %v", post.ID, err)
}
}()
}
chMap := h.channelMapForPosts([]*model.Post{post})
response.Success(c, dto.ConvertPostToResponse(post, chMap, false, false))
}
// GetByID 获取帖子(不增加浏览量)
@@ -80,44 +149,60 @@ func (h *PostHandler) GetByID(c *gin.Context) {
// 注意:不再自动增加浏览量,浏览量通过 RecordView 端点单独记录
// 增强 post_ref segments
enrichedSegments := dto.SegmentsOrDefault(post.Segments, post.Content)
if h.postRefService != nil && len(enrichedSegments) > 0 {
if enriched, err := h.postRefService.EnrichPostRefSegments(c.Request.Context(), enrichedSegments, currentUserID); err == nil {
enrichedSegments = enriched
}
}
// 获取交互状态
isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), id, currentUserID)
// 如果有当前用户,检查与帖子作者的相互关注状态
var authorWithFollowStatus *dto.UserResponse
if currentUserID != "" && post.User != nil {
_, isFollowing, isFollowingMe, err := h.userService.GetUserByIDWithMutualFollowStatus(c.Request.Context(), post.UserID, currentUserID)
if err == nil {
authorWithFollowStatus = dto.ConvertUserToResponseWithMutualFollow(post.User, isFollowing, isFollowingMe)
if post.User != nil {
if currentUserID != "" {
_, isFollowing, isFollowingMe, err := h.userService.GetUserByIDWithMutualFollowStatus(c.Request.Context(), post.UserID, currentUserID)
if err == nil {
authorWithFollowStatus = dto.ConvertUserToResponse(post.User, dto.WithFollowing(isFollowing, isFollowingMe))
} else {
authorWithFollowStatus = dto.ConvertUserToResponse(post.User)
}
} else {
// 如果出错使用默认的author
authorWithFollowStatus = dto.ConvertUserToResponse(post.User)
}
}
// 构建响应
responseData := &dto.PostResponse{
ID: post.ID,
UserID: post.UserID,
Title: post.Title,
Content: post.Content,
Images: dto.ConvertPostImagesToResponse(post.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: dto.FormatTime(post.CreatedAt),
UpdatedAt: dto.FormatTime(post.UpdatedAt),
Author: authorWithFollowStatus,
IsLiked: isLiked,
IsFavorited: isFavorited,
ID: post.ID,
UserID: post.UserID,
ChannelID: post.ChannelID,
Title: post.Title,
Content: post.Content,
Segments: enrichedSegments,
Images: dto.ConvertPostImagesToResponse(post.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: dto.FormatTime(post.CreatedAt),
UpdatedAt: dto.FormatTime(post.UpdatedAt),
ContentEditedAt: dto.FormatTimePointer(post.ContentEditedAt),
Author: authorWithFollowStatus,
IsLiked: isLiked,
IsFavorited: isFavorited,
}
chMap := h.channelMapForPosts([]*model.Post{post})
responseData.Channel = dto.PostChannelBriefForPost(post, chMap)
response.Success(c, responseData)
}
@@ -143,13 +228,61 @@ func (h *PostHandler) RecordView(c *gin.Context) {
response.Success(c, gin.H{"success": true})
}
// List 获取帖子列表
// RecordShare 记录帖子分享shares_count +1仅已发布
// 支持可选 JSON{"channel":"wechat"|"qq"|"copy_link"|"system"|...},用于后续统计
func (h *PostHandler) RecordShare(c *gin.Context) {
id := c.Param("id")
_ = c.GetString("user_id") // 预留:登录用户维度统计
type shareBody struct {
Channel string `json:"channel"`
}
var body shareBody
if c.Request.ContentLength > 0 {
if err := c.ShouldBindJSON(&body); err != nil {
response.BadRequest(c, "invalid json body")
return
}
}
sharesCount, err := h.postService.RecordShare(c.Request.Context(), id, c.GetString("user_id"))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.NotFound(c, "post not found")
return
}
fmt.Printf("[ERROR] RecordShare post %s: %v\n", id, err)
response.InternalServerError(c, "failed to record share")
return
}
data := gin.H{"success": true, "shares_count": sharesCount}
if body.Channel != "" {
data["channel"] = body.Channel
}
response.Success(c, data)
}
// List 获取帖子列表(支持游标分页和偏移分页)
func (h *PostHandler) List(c *gin.Context) {
// 检查是否使用游标分页
// 当 cursor 参数存在时(包括空字符串),使用游标分页
_, cursorExists := c.GetQuery("cursor")
if cursorExists {
h.ListByCursor(c)
return
}
// 偏移分页(向后兼容)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
page, pageSize = normalizePagination(page, pageSize)
userID := c.Query("user_id")
tab := c.Query("tab")
var channelID *string
if cid := c.Query("channel_id"); cid != "" {
channelID = &cid
}
// 获取当前用户ID
currentUserID := c.GetString("user_id")
@@ -161,31 +294,24 @@ func (h *PostHandler) List(c *gin.Context) {
// 根据 tab 参数选择不同的获取方式
switch tab {
case "follow":
// 获取关注用户的帖子,需要登录
if currentUserID == "" {
response.Unauthorized(c, "请先登录")
return
}
posts, total, err = h.postService.GetFollowingPosts(c.Request.Context(), currentUserID, page, pageSize)
posts, total, err = h.postService.GetFollowingPosts(c.Request.Context(), currentUserID, page, pageSize, channelID)
case "hot":
// 获取热门帖子
posts, total, err = h.postService.GetHotPosts(c.Request.Context(), page, pageSize)
case "recommend":
// 推荐帖子从Gorse获取个性化推荐
posts, total, err = h.postService.GetRecommendedPosts(c.Request.Context(), currentUserID, page, pageSize)
posts, total, err = h.postService.GetHotPosts(c.Request.Context(), page, pageSize, channelID)
case "latest":
// 最新帖子
if userID != "" && userID == currentUserID {
posts, total, err = h.postService.GetLatestPostsForOwner(c.Request.Context(), page, pageSize, userID)
posts, total, err = h.postService.GetLatestPostsForOwner(c.Request.Context(), page, pageSize, userID, channelID)
} else {
posts, total, err = h.postService.GetLatestPosts(c.Request.Context(), page, pageSize, userID)
posts, total, err = h.postService.GetLatestPosts(c.Request.Context(), page, pageSize, userID, channelID)
}
default:
// 默认获取最新帖子
if userID != "" && userID == currentUserID {
posts, total, err = h.postService.GetLatestPostsForOwner(c.Request.Context(), page, pageSize, userID)
posts, total, err = h.postService.GetLatestPostsForOwner(c.Request.Context(), page, pageSize, userID, channelID)
} else {
posts, total, err = h.postService.GetLatestPosts(c.Request.Context(), page, pageSize, userID)
posts, total, err = h.postService.GetLatestPosts(c.Request.Context(), page, pageSize, userID, channelID)
}
}
@@ -194,6 +320,8 @@ func (h *PostHandler) List(c *gin.Context) {
return
}
posts = h.filterBlockedPosts(c.Request.Context(), posts, currentUserID)
// 批量获取交互状态
postIDs := make([]string, len(posts))
for i, post := range posts {
@@ -201,12 +329,77 @@ func (h *PostHandler) List(c *gin.Context) {
}
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
// 转换为响应结构
postResponses := dto.BuildPostsWithInteractionResponse(posts, isLikedMap, isFavoritedMap)
chMap := h.channelMapForPosts(posts)
postResponses := dto.BuildPostsWithInteractionResponse(posts, chMap, isLikedMap, isFavoritedMap)
response.Paginated(c, postResponses, total, page, pageSize)
}
// ListByCursor 游标分页获取帖子列表
func (h *PostHandler) ListByCursor(c *gin.Context) {
userID := c.Query("user_id")
currentUserID := c.GetString("user_id")
tab := c.Query("tab")
var channelID *string
if cid := c.Query("channel_id"); cid != "" {
channelID = &cid
}
// 解析游标分页请求
req := h.parseCursorRequest(c)
// 判断是否包含待审核帖子
includePending := userID != "" && userID == currentUserID
var result *cursor.CursorPageResult[*model.Post]
var err error
// 根据 tab 参数选择不同的获取方式
switch tab {
case "follow":
if currentUserID == "" {
response.Unauthorized(c, "请先登录")
return
}
result, err = h.postService.GetFollowingPostsByCursor(c.Request.Context(), currentUserID, channelID, req)
case "hot":
result, err = h.postService.GetHotPostsByCursor(c.Request.Context(), channelID, req)
case "latest":
// 最新帖子
result, err = h.postService.ListByCursor(c.Request.Context(), userID, includePending, channelID, req)
default:
// 默认获取最新帖子
result, err = h.postService.ListByCursor(c.Request.Context(), userID, includePending, channelID, req)
}
if err != nil {
response.InternalServerError(c, "failed to get posts")
return
}
result.Items = h.filterBlockedPosts(c.Request.Context(), result.Items, currentUserID)
// 批量获取交互状态
postIDs := make([]string, len(result.Items))
for i, post := range result.Items {
postIDs[i] = post.ID
}
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
chMap := h.channelMapForPosts(result.Items)
postResponses := dto.BuildPostsWithInteractionResponse(result.Items, chMap, isLikedMap, isFavoritedMap)
// 构建游标分页响应
cursorResp := &dto.PostCursorPageResponse{
Items: postResponses,
NextCursor: result.NextCursor,
PrevCursor: result.PrevCursor,
HasMore: result.HasMore,
}
response.Success(c, cursorResp)
}
// Update 更新帖子
func (h *PostHandler) Update(c *gin.Context) {
userID := c.GetString("user_id")
@@ -229,9 +422,10 @@ func (h *PostHandler) Update(c *gin.Context) {
}
type UpdateRequest struct {
Title string `json:"title"`
Content string `json:"content"`
Images *[]string `json:"images"`
Title string `json:"title"`
Content string `json:"content"`
Segments model.MessageSegments `json:"segments"`
Images *[]string `json:"images"`
}
var req UpdateRequest
@@ -246,6 +440,12 @@ func (h *PostHandler) Update(c *gin.Context) {
if req.Content != "" {
post.Content = req.Content
}
if len(req.Segments) > 0 {
post.Segments = req.Segments
if req.Content == "" {
post.Content = dto.ExtractTextContent(req.Segments)
}
}
err = h.postService.UpdateWithImages(c.Request.Context(), post, req.Images)
if err != nil {
@@ -253,6 +453,15 @@ func (h *PostHandler) Update(c *gin.Context) {
return
}
if h.postRefService != nil && len(req.Segments) > 0 {
go func() {
bgCtx := context.Background()
if err := h.postRefService.ProcessPostReferences(bgCtx, post.ID, post.Segments); err != nil {
log.Printf("[WARN] process post references failed for post %s: %v", post.ID, err)
}
}()
}
post, err = h.postService.GetByID(c.Request.Context(), post.ID)
if err != nil {
response.InternalServerError(c, "failed to get updated post")
@@ -263,7 +472,8 @@ func (h *PostHandler) Update(c *gin.Context) {
currentUserID := c.GetString("user_id")
isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), post.ID, currentUserID)
response.Success(c, dto.BuildPostResponse(post, isLiked, isFavorited))
chMap := h.channelMapForPosts([]*model.Post{post})
response.Success(c, dto.BuildPostResponse(post, chMap, isLiked, isFavorited))
}
// Delete 删除帖子
@@ -287,12 +497,21 @@ func (h *PostHandler) Delete(c *gin.Context) {
return
}
err = h.postService.Delete(c.Request.Context(), id)
err = h.postService.Delete(c.Request.Context(), userID, id)
if err != nil {
response.InternalServerError(c, "failed to delete post")
response.HandleError(c, err, "failed to delete post")
return
}
if h.postRefService != nil {
go func() {
bgCtx := context.Background()
if err := h.postRefService.HandlePostDeleted(bgCtx, id); err != nil {
log.Printf("[WARN] mark dead post references failed for post %s: %v", id, err)
}
}()
}
response.SuccessWithMessage(c, "post deleted", nil)
}
@@ -322,7 +541,8 @@ func (h *PostHandler) Like(c *gin.Context) {
// 获取交互状态
isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), id, userID)
response.Success(c, dto.BuildPostResponse(post, isLiked, isFavorited))
chMap := h.channelMapForPosts([]*model.Post{post})
response.Success(c, dto.BuildPostResponse(post, chMap, isLiked, isFavorited))
}
// Unlike 取消点赞
@@ -351,7 +571,8 @@ func (h *PostHandler) Unlike(c *gin.Context) {
// 获取交互状态
isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), id, userID)
response.Success(c, dto.BuildPostResponse(post, isLiked, isFavorited))
chMap := h.channelMapForPosts([]*model.Post{post})
response.Success(c, dto.BuildPostResponse(post, chMap, isLiked, isFavorited))
}
// Favorite 收藏帖子
@@ -380,7 +601,8 @@ func (h *PostHandler) Favorite(c *gin.Context) {
// 获取交互状态
isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), id, userID)
response.Success(c, dto.BuildPostResponse(post, isLiked, isFavorited))
chMap := h.channelMapForPosts([]*model.Post{post})
response.Success(c, dto.BuildPostResponse(post, chMap, isLiked, isFavorited))
}
// Unfavorite 取消收藏
@@ -409,17 +631,63 @@ func (h *PostHandler) Unfavorite(c *gin.Context) {
// 获取交互状态
isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), id, userID)
response.Success(c, dto.BuildPostResponse(post, isLiked, isFavorited))
chMap := h.channelMapForPosts([]*model.Post{post})
response.Success(c, dto.BuildPostResponse(post, chMap, isLiked, isFavorited))
}
// GetUserPosts 获取用户帖子列表
// GetUserPosts 获取用户帖子列表(支持游标分页和偏移分页)
func (h *PostHandler) GetUserPosts(c *gin.Context) {
userID := c.Param("id")
currentUserID := c.GetString("user_id")
canView, err := h.userService.CanViewPosts(c.Request.Context(), currentUserID, userID)
if err != nil {
response.InternalServerError(c, "failed to check privacy settings")
return
}
if !canView {
response.Forbidden(c, "该用户的帖子列表不对外公开")
return
}
// 检查是否拉黑了对方
if currentUserID != "" && currentUserID != userID {
isBlocked, _ := h.userService.IsBlocked(c.Request.Context(), currentUserID, userID)
if isBlocked {
// 检查是否使用游标分页
cursorStr := c.Query("cursor")
if cursorStr != "" {
response.Success(c, &dto.PostCursorPageResponse{
Items: []*dto.PostResponse{},
HasMore: false,
IsBlocked: true,
})
} else {
response.Success(c, gin.H{
"list": []*dto.PostResponse{},
"total": 0,
"page": 1,
"page_size": 20,
"total_pages": 0,
"is_blocked": true,
})
}
return
}
}
// 检查是否使用游标分页
cursorStr := c.Query("cursor")
if cursorStr != "" {
h.GetUserPostsByCursor(c)
return
}
// 偏移分页(向后兼容)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
page, pageSize = normalizePagination(page, pageSize)
currentUserID := c.GetString("user_id")
includePending := currentUserID != "" && currentUserID == userID
posts, total, err := h.postService.GetUserPosts(c.Request.Context(), userID, page, pageSize, includePending)
if err != nil {
@@ -434,15 +702,66 @@ func (h *PostHandler) GetUserPosts(c *gin.Context) {
}
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
// 转换为响应结构
postResponses := dto.BuildPostsWithInteractionResponse(posts, isLikedMap, isFavoritedMap)
chMap := h.channelMapForPosts(posts)
postResponses := dto.BuildPostsWithInteractionResponse(posts, chMap, isLikedMap, isFavoritedMap)
response.Paginated(c, postResponses, total, page, pageSize)
}
// GetUserPostsByCursor 游标分页获取用户帖子列表
func (h *PostHandler) GetUserPostsByCursor(c *gin.Context) {
userID := c.Param("id")
currentUserID := c.GetString("user_id")
// 解析游标分页请求
req := h.parseCursorRequest(c)
// 判断是否包含待审核帖子
includePending := currentUserID != "" && currentUserID == userID
// 调用游标分页服务
result, err := h.postService.GetUserPostsByCursor(c.Request.Context(), userID, includePending, req)
if err != nil {
response.InternalServerError(c, "failed to get user posts")
return
}
// 批量获取交互状态
postIDs := make([]string, len(result.Items))
for i, post := range result.Items {
postIDs[i] = post.ID
}
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
chMap := h.channelMapForPosts(result.Items)
postResponses := dto.BuildPostsWithInteractionResponse(result.Items, chMap, isLikedMap, isFavoritedMap)
// 构建游标分页响应
cursorResp := &dto.PostCursorPageResponse{
Items: postResponses,
NextCursor: result.NextCursor,
PrevCursor: result.PrevCursor,
HasMore: result.HasMore,
}
response.Success(c, cursorResp)
}
// GetFavorites 获取收藏列表
func (h *PostHandler) GetFavorites(c *gin.Context) {
userID := c.Param("id")
currentUserID := c.GetString("user_id")
canView, err := h.userService.CanViewFavorites(c.Request.Context(), currentUserID, userID)
if err != nil {
response.InternalServerError(c, "failed to check privacy settings")
return
}
if !canView {
response.Forbidden(c, "该用户的收藏列表不对外公开")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
page, pageSize = normalizePagination(page, pageSize)
@@ -453,16 +772,14 @@ func (h *PostHandler) GetFavorites(c *gin.Context) {
return
}
// 批量获取交互状态
currentUserID := c.GetString("user_id")
postIDs := make([]string, len(posts))
for i, post := range posts {
postIDs[i] = post.ID
}
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
// 转换为响应结构
postResponses := dto.BuildPostsWithInteractionResponse(posts, isLikedMap, isFavoritedMap)
chMap := h.channelMapForPosts(posts)
postResponses := dto.BuildPostsWithInteractionResponse(posts, chMap, isLikedMap, isFavoritedMap)
response.Paginated(c, postResponses, total, page, pageSize)
}
@@ -470,6 +787,15 @@ func (h *PostHandler) GetFavorites(c *gin.Context) {
// Search 搜索帖子
func (h *PostHandler) Search(c *gin.Context) {
keyword := c.Query("keyword")
// 检查是否使用游标分页
cursorStr := c.Query("cursor")
if cursorStr != "" {
h.SearchByCursor(c)
return
}
// 偏移分页(向后兼容)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
page, pageSize = normalizePagination(page, pageSize)
@@ -482,14 +808,172 @@ func (h *PostHandler) Search(c *gin.Context) {
// 批量获取交互状态
currentUserID := c.GetString("user_id")
posts = h.filterBlockedPosts(c.Request.Context(), posts, currentUserID)
postIDs := make([]string, len(posts))
for i, post := range posts {
postIDs[i] = post.ID
}
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
// 转换为响应结构
postResponses := dto.BuildPostsWithInteractionResponse(posts, isLikedMap, isFavoritedMap)
chMap := h.channelMapForPosts(posts)
postResponses := dto.BuildPostsWithInteractionResponse(posts, chMap, isLikedMap, isFavoritedMap)
response.Paginated(c, postResponses, total, page, pageSize)
}
// SearchByCursor 游标分页搜索帖子
func (h *PostHandler) SearchByCursor(c *gin.Context) {
keyword := c.Query("keyword")
currentUserID := c.GetString("user_id")
// 解析游标分页请求
req := h.parseCursorRequest(c)
// 调用游标分页服务
result, err := h.postService.SearchByCursor(c.Request.Context(), keyword, req)
if err != nil {
response.InternalServerError(c, "failed to search posts")
return
}
result.Items = h.filterBlockedPosts(c.Request.Context(), result.Items, currentUserID)
// 批量获取交互状态
postIDs := make([]string, len(result.Items))
for i, post := range result.Items {
postIDs[i] = post.ID
}
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
chMap := h.channelMapForPosts(result.Items)
postResponses := dto.BuildPostsWithInteractionResponse(result.Items, chMap, isLikedMap, isFavoritedMap)
// 构建游标分页响应
cursorResp := &dto.PostCursorPageResponse{
Items: postResponses,
NextCursor: result.NextCursor,
PrevCursor: result.PrevCursor,
HasMore: result.HasMore,
}
response.Success(c, cursorResp)
}
// RecordRefClick 记录内链点击
func (h *PostHandler) RecordRefClick(c *gin.Context) {
id := c.Param("id")
type RefClickRequest struct {
TargetPostID string `json:"target_post_id" binding:"required"`
}
var req RefClickRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "target_post_id is required")
return
}
if h.postRefService != nil {
if err := h.postRefService.RecordClick(c.Request.Context(), id, req.TargetPostID); err != nil {
log.Printf("[WARN] record ref click failed: %v", err)
}
}
response.Success(c, gin.H{"success": true})
}
// GetRelatedPosts 获取引用当前帖子的相关帖子
func (h *PostHandler) GetRelatedPosts(c *gin.Context) {
id := c.Param("id")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
page, pageSize = normalizePagination(page, pageSize)
if h.postRefService == nil {
response.Paginated(c, []*dto.PostResponse{}, 0, page, pageSize)
return
}
posts, total, err := h.postRefService.GetRelatedPosts(c.Request.Context(), id, page, pageSize)
if err != nil {
response.InternalServerError(c, "failed to get related posts")
return
}
currentUserID := c.GetString("user_id")
posts = h.filterBlockedPosts(c.Request.Context(), posts, currentUserID)
postIDs := make([]string, len(posts))
for i, p := range posts {
postIDs[i] = p.ID
}
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
chMap := h.channelMapForPosts(posts)
postResponses := dto.BuildPostsWithInteractionResponse(posts, chMap, isLikedMap, isFavoritedMap)
response.Paginated(c, postResponses, total, page, pageSize)
}
// GetReferencedPosts 获取当前帖子引用的目标帖子
func (h *PostHandler) GetReferencedPosts(c *gin.Context) {
id := c.Param("id")
if h.postRefService == nil {
response.Success(c, []*dto.PostResponse{})
return
}
posts, err := h.postRefService.GetReferencedPosts(c.Request.Context(), id)
if err != nil {
response.InternalServerError(c, "failed to get referenced posts")
return
}
currentUserID := c.GetString("user_id")
posts = h.filterBlockedPosts(c.Request.Context(), posts, currentUserID)
postIDs := make([]string, len(posts))
for i, p := range posts {
postIDs[i] = p.ID
}
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
chMap := h.channelMapForPosts(posts)
postResponses := dto.BuildPostsWithInteractionResponse(posts, chMap, isLikedMap, isFavoritedMap)
response.Success(c, postResponses)
}
// Suggest 帖子搜索联想
func (h *PostHandler) Suggest(c *gin.Context) {
q := c.Query("q")
if q == "" {
response.Success(c, []dto.PostRefBrief{})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
if limit <= 0 || limit > 50 {
limit = 10
}
briefs, err := h.postRefService.SuggestPosts(c.Request.Context(), q, limit)
if err != nil {
response.InternalServerError(c, "failed to search posts")
return
}
response.Success(c, briefs)
}
// parseCursorRequest 解析游标分页请求参数
func (h *PostHandler) parseCursorRequest(c *gin.Context) *cursor.PageRequest {
cursorStr := c.Query("cursor")
direction := cursor.Direction(c.DefaultQuery("direction", "forward"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
return cursor.NewPageRequest(cursorStr, direction, pageSize)
}

View File

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

View File

@@ -0,0 +1,230 @@
package handler
import (
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"with_you/internal/pkg/response"
"with_you/internal/pkg/ws"
"with_you/internal/service"
)
// QRCodeHandler 二维码登录处理器
type QRCodeHandler struct {
qrcodeService service.QRCodeLoginService
}
// NewQRCodeHandler 创建二维码登录处理器
func NewQRCodeHandler(qrcodeService service.QRCodeLoginService) *QRCodeHandler {
return &QRCodeHandler{
qrcodeService: qrcodeService,
}
}
// GetQRCode 获取二维码
// GET /api/v1/auth/qrcode
func (h *QRCodeHandler) GetQRCode(c *gin.Context) {
session, err := h.qrcodeService.CreateSession(c.Request.Context())
if err != nil {
response.InternalServerError(c, "failed to create qrcode session")
return
}
// 生成二维码URL (deeplink格式)
qrcodeURL := fmt.Sprintf("carrotbbs://qrcode/login?session_id=%s", session.SessionID)
response.Success(c, gin.H{
"session_id": session.SessionID,
"qrcode_url": qrcodeURL,
"expires_in": 300, // 5分钟
"expires_at": session.ExpiresAt,
})
}
// WSEvents WebSocket事件流
// GET /api/v1/auth/qrcode/events?session_id=xxx
func (h *QRCodeHandler) WSEvents(c *gin.Context) {
sessionID := c.Query("session_id")
if sessionID == "" {
response.BadRequest(c, "session_id is required")
return
}
ch, cancel := h.qrcodeService.GetWSHub().Subscribe(sessionID, 0)
defer cancel()
w := c.Writer
flusher, ok := w.(http.Flusher)
if !ok {
response.InternalServerError(c, "streaming unsupported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
c.Status(http.StatusOK)
flusher.Flush()
writeEvent := func(ev ws.Event) bool {
data, err := ws.EncodeData(ev)
if err != nil {
return false
}
if _, err := fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", ev.ID, ev.Event, data); err != nil {
return false
}
flusher.Flush()
return true
}
// 心跳
heartbeat := time.NewTicker(25 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-c.Request.Context().Done():
return
case ev, ok := <-ch:
if !ok || !writeEvent(ev) {
return
}
case <-heartbeat.C:
if _, err := fmt.Fprint(w, "event: heartbeat\ndata: {}\n\n"); err != nil {
return
}
flusher.Flush()
}
}
}
// Scan 扫描二维码
// POST /api/v1/auth/qrcode/scan
func (h *QRCodeHandler) Scan(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "")
return
}
type ScanRequest struct {
SessionID string `json:"session_id" binding:"required"`
}
var req ScanRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := h.qrcodeService.Scan(c.Request.Context(), req.SessionID, userID); err != nil {
if err.Error() == "qrcode already scanned" {
response.BadRequest(c, "qrcode already scanned by others")
return
}
if err.Error() == "session not found or expired" {
response.NotFound(c, "qrcode expired")
return
}
response.InternalServerError(c, "failed to scan qrcode")
return
}
// 获取用户信息返回
user, err := h.qrcodeService.GetUserService().GetUserByID(c.Request.Context(), userID)
if err != nil {
response.InternalServerError(c, "failed to get user info")
return
}
response.Success(c, gin.H{
"user": gin.H{
"id": user.ID,
"nickname": user.Nickname,
"avatar": user.Avatar,
},
})
}
// Confirm 确认登录
// POST /api/v1/auth/qrcode/confirm
func (h *QRCodeHandler) Confirm(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "")
return
}
type ConfirmRequest struct {
SessionID string `json:"session_id" binding:"required"`
}
var req ConfirmRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
ip := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
result, err := h.qrcodeService.Confirm(c.Request.Context(), req.SessionID, userID, ip, userAgent)
if err != nil {
if err.Error() == "unauthorized" {
response.Unauthorized(c, "not authorized to confirm this login")
return
}
if err.Error() == "session not found or expired" {
response.NotFound(c, "qrcode expired")
return
}
response.InternalServerError(c, "failed to confirm login")
return
}
response.Success(c, gin.H{
"success": true,
"user": gin.H{
"id": result.User.ID,
"username": result.User.Username,
"nickname": result.User.Nickname,
"avatar": result.User.Avatar,
},
})
}
// Cancel 取消登录
// POST /api/v1/auth/qrcode/cancel
func (h *QRCodeHandler) Cancel(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "")
return
}
type CancelRequest struct {
SessionID string `json:"session_id" binding:"required"`
}
var req CancelRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := h.qrcodeService.Cancel(c.Request.Context(), req.SessionID, userID); err != nil {
if err.Error() == "unauthorized" {
response.Unauthorized(c, "not authorized to cancel this login")
return
}
response.InternalServerError(c, "failed to cancel login")
return
}
response.Success(c, gin.H{"success": true})
}

View File

@@ -0,0 +1,61 @@
package handler
import (
"with_you/internal/dto"
"with_you/internal/pkg/response"
"with_you/internal/service"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// ReportHandler 举报处理器
type ReportHandler struct {
reportService service.ReportService
}
// NewReportHandler 创建举报处理器
func NewReportHandler(reportService service.ReportService) *ReportHandler {
return &ReportHandler{
reportService: reportService,
}
}
// Create 创建举报
func (h *ReportHandler) Create(c *gin.Context) {
// 获取用户ID
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "未授权")
return
}
// 解析请求
var req dto.CreateReportRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
// 创建举报
report, err := h.reportService.CreateReport(
c.Request.Context(),
userID,
req.TargetType,
req.TargetID,
req.Reason,
req.Description,
)
if err != nil {
zap.L().Error("failed to create report",
zap.Error(err),
zap.String("user_id", userID),
zap.String("target_type", req.TargetType),
zap.String("target_id", req.TargetID),
)
response.Error(c, 500, err.Error())
return
}
response.Success(c, dto.ConvertReportToResponse(report))
}

View File

@@ -4,9 +4,9 @@ import (
"errors"
"net/http"
apperrors "carrot_bbs/internal/errors"
"carrot_bbs/internal/model"
"carrot_bbs/internal/service"
apperrors "with_you/internal/errors"
"with_you/internal/model"
"with_you/internal/service"
"github.com/gin-gonic/gin"
)

View File

@@ -5,8 +5,8 @@ import (
"github.com/gin-gonic/gin"
"carrot_bbs/internal/pkg/response"
"carrot_bbs/internal/service"
"with_you/internal/pkg/response"
"with_you/internal/service"
)
type ScheduleHandler struct {

View File

@@ -0,0 +1,38 @@
package handler
import (
"with_you/internal/pkg/response"
"with_you/internal/service"
"github.com/gin-gonic/gin"
)
type SetupHandler struct {
setupService service.SetupService
}
func NewSetupHandler(setupService service.SetupService) *SetupHandler {
return &SetupHandler{
setupService: setupService,
}
}
func (h *SetupHandler) SetupSuperAdmin(c *gin.Context) {
type SetupSuperAdminRequest struct {
UserID string `json:"user_id" binding:"required"`
SetupSecret string `json:"setup_secret" binding:"required"`
}
var req SetupSuperAdminRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "user_id and setup_secret are required")
return
}
if err := h.setupService.SetupSuperAdmin(c.Request.Context(), req.UserID, req.SetupSecret); err != nil {
response.HandleError(c, err, "failed to setup super admin")
return
}
response.SuccessWithMessage(c, "超级管理员设置成功", nil)
}

View File

@@ -1,12 +1,13 @@
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"carrot_bbs/internal/pkg/response"
"carrot_bbs/internal/service"
"with_you/internal/pkg/response"
"with_you/internal/service"
)
// StickerHandler 自定义表情处理器
@@ -81,11 +82,11 @@ func (h *StickerHandler) AddSticker(c *gin.Context) {
sticker, err := h.stickerService.AddSticker(userID, req.URL, req.Width, req.Height)
if err != nil {
if err == service.ErrStickerAlreadyExists {
if errors.Is(err, service.ErrStickerAlreadyExists) {
response.Error(c, http.StatusConflict, "sticker already exists")
return
}
if err == service.ErrInvalidStickerURL {
if errors.Is(err, service.ErrInvalidStickerURL) {
response.BadRequest(c, "invalid sticker url, only http/https is allowed")
return
}

View File

@@ -3,33 +3,24 @@ package handler
import (
"strconv"
"carrot_bbs/internal/cache"
"github.com/gin-gonic/gin"
"carrot_bbs/internal/dto"
"carrot_bbs/internal/pkg/response"
"carrot_bbs/internal/repository"
"carrot_bbs/internal/service"
"with_you/internal/dto"
"with_you/internal/pkg/response"
"with_you/internal/service"
)
// SystemMessageHandler 系统消息处理器
type SystemMessageHandler struct {
systemMsgService service.SystemMessageService
notifyRepo *repository.SystemNotificationRepository
cache cache.Cache
}
// NewSystemMessageHandler 创建系统消息处理器
func NewSystemMessageHandler(
systemMsgService service.SystemMessageService,
notifyRepo *repository.SystemNotificationRepository,
cacheBackend cache.Cache,
) *SystemMessageHandler {
return &SystemMessageHandler{
systemMsgService: systemMsgService,
notifyRepo: notifyRepo,
cache: cacheBackend,
}
}
@@ -42,11 +33,19 @@ func (h *SystemMessageHandler) GetSystemMessages(c *gin.Context) {
return
}
// 检查是否使用游标分页
_, cursorExists := c.GetQuery("cursor")
if cursorExists {
h.GetSystemMessagesByCursor(c)
return
}
// 偏移分页(向后兼容)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
// 获取当前用户的系统通知(从独立表中获取)
notifications, total, err := h.notifyRepo.GetByReceiverID(userID, page, pageSize)
// 获取当前用户的系统通知
notifications, total, err := h.systemMsgService.GetNotifications(userID, page, pageSize)
if err != nil {
response.InternalServerError(c, "failed to get system messages")
return
@@ -62,6 +61,43 @@ func (h *SystemMessageHandler) GetSystemMessages(c *gin.Context) {
response.Paginated(c, result, total, page, pageSize)
}
// GetSystemMessagesByCursor 游标分页获取系统消息列表
func (h *SystemMessageHandler) GetSystemMessagesByCursor(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "")
return
}
// 解析游标分页请求
cursorStr := c.Query("cursor")
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
// 获取当前用户的系统通知(使用兼容旧版接口)
notifications, nextCursor, hasMore, err := h.systemMsgService.GetNotificationsByCursor(userID, cursorStr, pageSize)
if err != nil {
response.InternalServerError(c, "failed to get system messages")
return
}
// 转换为响应格式
items := make([]*dto.SystemMessageResponse, 0)
for _, n := range notifications {
resp := dto.SystemNotificationToResponse(n)
items = append(items, resp)
}
// 构建游标分页响应
cursorResp := &dto.SystemMessageCursorPageResponse{
Items: items,
NextCursor: nextCursor,
PrevCursor: "",
HasMore: hasMore,
}
response.Success(c, cursorResp)
}
// GetUnreadCount 获取系统消息未读数
// GET /api/v1/messages/system/unread-count
func (h *SystemMessageHandler) GetUnreadCount(c *gin.Context) {
@@ -72,7 +108,7 @@ func (h *SystemMessageHandler) GetUnreadCount(c *gin.Context) {
}
// 获取当前用户的未读通知数
unreadCount, err := h.notifyRepo.GetUnreadCount(userID)
unreadCount, err := h.systemMsgService.GetUnreadCount(userID)
if err != nil {
response.InternalServerError(c, "failed to get unread count")
return
@@ -100,12 +136,11 @@ func (h *SystemMessageHandler) MarkAsRead(c *gin.Context) {
}
// 标记为已读
err = h.notifyRepo.MarkAsRead(notificationID, userID)
err = h.systemMsgService.MarkAsRead(notificationID, userID)
if err != nil {
response.InternalServerError(c, "failed to mark as read")
return
}
cache.InvalidateUnreadSystem(h.cache, userID)
response.SuccessWithMessage(c, "marked as read", nil)
}
@@ -120,12 +155,11 @@ func (h *SystemMessageHandler) MarkAllAsRead(c *gin.Context) {
}
// 标记当前用户所有通知为已读
err := h.notifyRepo.MarkAllAsRead(userID)
err := h.systemMsgService.MarkAllAsRead(userID)
if err != nil {
response.InternalServerError(c, "failed to mark all as read")
return
}
cache.InvalidateUnreadSystem(h.cache, userID)
response.SuccessWithMessage(c, "all messages marked as read", nil)
}
@@ -147,12 +181,11 @@ func (h *SystemMessageHandler) DeleteSystemMessage(c *gin.Context) {
}
// 删除通知
err = h.notifyRepo.Delete(notificationID, userID)
err = h.systemMsgService.DeleteNotification(notificationID, userID)
if err != nil {
response.InternalServerError(c, "failed to delete notification")
return
}
cache.InvalidateUnreadSystem(h.cache, userID)
response.SuccessWithMessage(c, "notification deleted", nil)
}

View File

@@ -0,0 +1,220 @@
package handler
import (
"strconv"
"github.com/gin-gonic/gin"
"with_you/internal/dto"
"with_you/internal/model"
"with_you/internal/pkg/response"
"with_you/internal/service"
)
type TradeHandler struct {
tradeService service.TradeService
}
func NewTradeHandler(tradeService service.TradeService) *TradeHandler {
return &TradeHandler{tradeService: tradeService}
}
func (h *TradeHandler) List(c *gin.Context) {
currentUserID := c.GetString("user_id")
if currentUserID == "" {
currentUserID = ""
}
var tradeType *model.TradeType
if v := c.Query("trade_type"); v != "" {
t := model.TradeType(v)
tradeType = &t
}
var category *model.TradeCategory
if v := c.Query("category"); v != "" {
cat := model.TradeCategory(v)
category = &cat
}
cursorStr := c.Query("cursor")
pageSize := 20
if v := c.Query("page_size"); v != "" {
if ps, err := strconv.Atoi(v); err == nil && ps > 0 && ps <= 100 {
pageSize = ps
}
}
keyword := c.Query("keyword")
var currentUserIDPtr *string
if currentUserID != "" {
currentUserIDPtr = &currentUserID
}
var resp *dto.TradeCursorPageResponse
var err error
if keyword != "" {
resp, err = h.tradeService.Search(c.Request.Context(), keyword, tradeType, category, cursorStr, pageSize, currentUserIDPtr)
} else {
resp, err = h.tradeService.List(c.Request.Context(), tradeType, category, cursorStr, pageSize, currentUserIDPtr)
}
if err != nil {
response.HandleError(c, err, "获取交易列表失败")
return
}
response.Success(c, resp)
}
func (h *TradeHandler) GetByID(c *gin.Context) {
id := c.Param("id")
currentUserID := c.GetString("user_id")
var currentUserIDPtr *string
if currentUserID != "" {
currentUserIDPtr = &currentUserID
}
resp, err := h.tradeService.GetByID(c.Request.Context(), id, currentUserIDPtr)
if err != nil {
response.HandleError(c, err, "获取交易详情失败")
return
}
response.Success(c, resp)
}
func (h *TradeHandler) Create(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "请先登录")
return
}
var req dto.CreateTradeItemRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
item, err := h.tradeService.Create(c.Request.Context(), userID, req)
if err != nil {
response.HandleError(c, err, "创建交易失败")
return
}
var currentUserIDPtr *string
if userID != "" {
currentUserIDPtr = &userID
}
detail, err := h.tradeService.GetByID(c.Request.Context(), item.ID, currentUserIDPtr)
if err != nil {
response.Success(c, item)
return
}
response.Success(c, detail)
}
func (h *TradeHandler) Update(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "请先登录")
return
}
id := c.Param("id")
var req dto.UpdateTradeItemRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := h.tradeService.Update(c.Request.Context(), userID, id, req); err != nil {
response.HandleError(c, err, "更新交易失败")
return
}
var currentUserIDPtr *string
if userID != "" {
currentUserIDPtr = &userID
}
detail, err := h.tradeService.GetByID(c.Request.Context(), id, currentUserIDPtr)
if err != nil {
response.Success(c, nil)
return
}
response.Success(c, detail)
}
func (h *TradeHandler) Delete(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "请先登录")
return
}
id := c.Param("id")
if err := h.tradeService.Delete(c.Request.Context(), userID, id); err != nil {
response.HandleError(c, err, "删除交易失败")
return
}
response.Success(c, nil)
}
func (h *TradeHandler) UpdateStatus(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "请先登录")
return
}
id := c.Param("id")
var req dto.UpdateTradeItemStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := h.tradeService.UpdateStatus(c.Request.Context(), userID, id, model.TradeItemStatus(req.Status)); err != nil {
response.HandleError(c, err, "更新交易状态失败")
return
}
response.Success(c, nil)
}
func (h *TradeHandler) RecordView(c *gin.Context) {
id := c.Param("id")
_ = h.tradeService.RecordView(c.Request.Context(), id)
response.Success(c, nil)
}
func (h *TradeHandler) Favorite(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "请先登录")
return
}
id := c.Param("id")
if err := h.tradeService.Favorite(c.Request.Context(), userID, id); err != nil {
response.HandleError(c, err, "收藏失败")
return
}
response.Success(c, dto.TradeFavoriteResponse{Favorited: true})
}
func (h *TradeHandler) Unfavorite(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "请先登录")
return
}
id := c.Param("id")
if err := h.tradeService.Unfavorite(c.Request.Context(), userID, id); err != nil {
response.HandleError(c, err, "取消收藏失败")
return
}
response.Success(c, dto.TradeFavoriteResponse{Favorited: false})
}

View File

@@ -3,17 +3,17 @@ package handler
import (
"github.com/gin-gonic/gin"
"carrot_bbs/internal/pkg/response"
"carrot_bbs/internal/service"
"with_you/internal/pkg/response"
"with_you/internal/service"
)
// UploadHandler 上传处理器
type UploadHandler struct {
uploadService *service.UploadService
uploadService service.UploadService
}
// NewUploadHandler 创建上传处理器
func NewUploadHandler(uploadService *service.UploadService) *UploadHandler {
func NewUploadHandler(uploadService service.UploadService) *UploadHandler {
return &UploadHandler{
uploadService: uploadService,
}
@@ -92,3 +92,43 @@ func (h *UploadHandler) UploadCover(c *gin.Context) {
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,
})
}

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