feat(websocket): integrate WebSocket support and refactor SSE handling
- 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.
This commit is contained in:
353
ARCHITECTURE.md
Normal file
353
ARCHITECTURE.md
Normal file
@@ -0,0 +1,353 @@
|
||||
# 后端架构设计规范
|
||||
|
||||
## 1. 分层架构原则
|
||||
|
||||
### 层级定义
|
||||
```
|
||||
Handler (控制器层) → Service (业务逻辑层) → Repository (数据访问层) → Infrastructure (基础设施层)
|
||||
```
|
||||
|
||||
### 层级职责
|
||||
|
||||
| 层级 | 职责 | 依赖关系 |
|
||||
|------|------|----------|
|
||||
| Handler | 接收HTTP请求、参数校验、调用Service、返回响应 | 仅依赖Service |
|
||||
| Service | 实现业务逻辑、事务管理、组合多个Repository | 仅依赖Repository接口 |
|
||||
| Repository | 数据CRUD操作、数据库事务管理 | 仅依赖Model和GORM |
|
||||
| Infrastructure | 缓存、消息队列、RPC等外部服务 | 被Service调用 |
|
||||
|
||||
### 关键约束
|
||||
- **Handler禁止直接依赖Repository或直接操作数据库**
|
||||
- **Service禁止直接依赖`gorm.DB`,必须通过Repository间接操作**
|
||||
- **所有外部依赖通过接口抽象**
|
||||
|
||||
---
|
||||
|
||||
## 2. Repository 层规范
|
||||
|
||||
### 2.1 接口 + 私有实现模式
|
||||
|
||||
所有Repository必须采用接口定义 + 私有实现结构体的模式:
|
||||
|
||||
```go
|
||||
// ✅ 正确:接口 + 私有实现
|
||||
type UserRepository interface {
|
||||
Create(user *model.User) error
|
||||
GetByID(id string) (*model.User, error)
|
||||
}
|
||||
|
||||
type userRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserRepository(db *gorm.DB) UserRepository {
|
||||
return &userRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *userRepository) Create(user *model.User) error {
|
||||
return r.db.Create(user).Error
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// ❌ 错误:直接暴露结构体指针
|
||||
type UserRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserRepository(db *gorm.DB) *UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Repository方法设计原则
|
||||
|
||||
- 方法签名中**使用接口类型而非指针**:`func (r *userRepository) GetByID(...) (*model.User, error)`
|
||||
- **避免在Repository层直接返回指针切片**,使用值切片更安全
|
||||
- **事务操作在Repository内部封装**,Service不应直接调用`db.Transaction()`
|
||||
|
||||
### 2.3 新增Repository流程
|
||||
|
||||
1. 在`repository/`目录下创建`xxx_repo.go`文件
|
||||
2. 定义`XxxRepository`接口,包含所有数据访问方法
|
||||
3. 创建私有实现结构体`xxxRepository`
|
||||
4. 创建构造函数`NewXxxRepository(db *gorm.DB) XxxRepository`
|
||||
5. 在`wire/repository.go`的`RepositorySet`中注册
|
||||
|
||||
---
|
||||
|
||||
## 3. Service 层规范
|
||||
|
||||
### 3.1 接口 + 私有实现模式
|
||||
|
||||
```go
|
||||
// ✅ 正确
|
||||
type UserService interface {
|
||||
CreateUser(ctx context.Context, req *dto.CreateUserRequest) (*model.User, error)
|
||||
GetUserByID(ctx context.Context, id string) (*model.User, error)
|
||||
}
|
||||
|
||||
type userService struct {
|
||||
userRepo repository.UserRepository
|
||||
// 其他Repo都使用接口类型
|
||||
}
|
||||
|
||||
func NewUserService(userRepo repository.UserRepository) UserService {
|
||||
return &userService{userRepo: userRepo}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Service依赖管理
|
||||
|
||||
- **所有Repository依赖必须使用接口类型**,禁止使用`*repository.XxxRepository`指针
|
||||
- **禁止在Service中直接持有`gorm.DB`**,如有数据库操作必须下沉到Repository
|
||||
- **如需事务支持**,在Repository中封装事务方法,Service调用封装好的方法
|
||||
|
||||
### 3.3 Service间依赖
|
||||
|
||||
- Service之间通过接口依赖,避免循环依赖
|
||||
- 如Service A需要调用Service B的方法,确保该方法在接口中定义
|
||||
|
||||
---
|
||||
|
||||
## 4. Handler 层规范
|
||||
|
||||
### 4.1 Handler只依赖Service
|
||||
|
||||
```go
|
||||
// ✅ 正确
|
||||
type UserHandler struct {
|
||||
userService service.UserService // 仅依赖Service接口
|
||||
}
|
||||
|
||||
func NewUserHandler(userService service.UserService) *UserHandler {
|
||||
return &UserHandler{userService: userService}
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// ❌ 错误:直接依赖Repository或Cache
|
||||
type UserHandler struct {
|
||||
userRepo *repository.UserRepository // 禁止
|
||||
cache cache.Cache // 禁止
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 参数和类型分离
|
||||
|
||||
- **Query参数和Filter类型**应定义在`dto/`包中,而非`repository/`包
|
||||
- Handler负责将HTTP参数转换为DTO后传递给Service
|
||||
|
||||
---
|
||||
|
||||
## 5. DTO/Filter 类型规范
|
||||
|
||||
### 5.1 放置位置
|
||||
|
||||
- **所有查询参数、过滤条件、分页参数**定义在`internal/dto/`目录下
|
||||
- **禁止在`repository/`包中定义供Handler使用的Filter类型**
|
||||
|
||||
### 5.2 文件组织
|
||||
|
||||
```
|
||||
internal/dto/
|
||||
├── filter.go # 通用过滤条件(LogFilter, LoginFilter等)
|
||||
├── user_converter.go # 用户相关转换
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 5.3 Filter类型定义示例
|
||||
|
||||
```go
|
||||
// internal/dto/filter.go
|
||||
|
||||
type LogFilter struct {
|
||||
UserID string
|
||||
Operation string
|
||||
TargetType string
|
||||
StartTime string
|
||||
EndTime string
|
||||
}
|
||||
|
||||
type PaginationParams struct {
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Wire 依赖注入规范
|
||||
|
||||
### 6.1 Provider函数设计
|
||||
|
||||
```go
|
||||
// ✅ 正确:Provider返回接口类型
|
||||
func ProvideUserService(
|
||||
userRepo repository.UserRepository, // 接口类型
|
||||
cacheBackend cache.Cache,
|
||||
) service.UserService {
|
||||
return service.NewUserService(userRepo, cacheBackend)
|
||||
}
|
||||
|
||||
// ❌ 错误:返回指针类型
|
||||
func ProvideUserService(userRepo *repository.UserRepository) *service.UserService
|
||||
```
|
||||
|
||||
### 6.2 NewXxxService构造函数签名
|
||||
|
||||
- **构造函数参数使用接口类型**
|
||||
- **返回值使用接口类型**
|
||||
|
||||
```go
|
||||
// ✅ 正确
|
||||
func NewUserService(
|
||||
userRepo repository.UserRepository, // 接口
|
||||
cache cache.Cache,
|
||||
) UserService { // 返回接口
|
||||
return &userService{userRepo: userRepo}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 禁止的直接注入
|
||||
|
||||
- **禁止在Handler的Provider中直接注入Repository和Cache**,应仅注入Service
|
||||
- **禁止在Service的Provider中直接注入`gorm.DB`**
|
||||
|
||||
---
|
||||
|
||||
## 7. 事务管理规范
|
||||
|
||||
### 7.1 事务边界在Repository层
|
||||
|
||||
```go
|
||||
// ✅ 正确:事务封装在Repository内部
|
||||
func (r *userRepository) CreateUserWithProfile(user *model.User, profile *model.Profile) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
profile.UserID = user.ID
|
||||
return tx.Create(profile).Error
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 Service调用事务方法
|
||||
|
||||
```go
|
||||
// ✅ Service通过调用Repository的封装方法使用事务
|
||||
func (s *userService) CreateUser(ctx context.Context, req *dto.CreateUserRequest) error {
|
||||
user := &model.User{Name: req.Name}
|
||||
profile := &model.Profile{Bio: req.Bio}
|
||||
return s.userRepo.CreateUserWithProfile(user, profile)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 常见错误示例
|
||||
|
||||
### 错误1:Handler直接依赖Repository
|
||||
|
||||
```go
|
||||
// ❌ 错误
|
||||
type PostHandler struct {
|
||||
postRepo *repository.PostRepository
|
||||
}
|
||||
|
||||
// ✅ 正确
|
||||
type PostHandler struct {
|
||||
postService service.PostService
|
||||
}
|
||||
```
|
||||
|
||||
### 错误2:Service直接使用gorm.DB
|
||||
|
||||
```go
|
||||
// ❌ 错误
|
||||
type postService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func (s *postService) CreatePost(post *model.Post) error {
|
||||
return s.db.Create(post).Error
|
||||
}
|
||||
|
||||
// ✅ 正确:使用Repository
|
||||
func (s *postService) CreatePost(post *model.Post) error {
|
||||
return s.postRepo.Create(post)
|
||||
}
|
||||
```
|
||||
|
||||
### 错误3:Repository返回指针类型
|
||||
|
||||
```go
|
||||
// ❌ 错误
|
||||
func NewUserRepository(db *gorm.DB) *UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
|
||||
// ✅ 正确
|
||||
func NewUserRepository(db *gorm.DB) UserRepository {
|
||||
return &userRepository{db: db}
|
||||
}
|
||||
```
|
||||
|
||||
### 错误4:Filter类型放在repository包
|
||||
|
||||
```go
|
||||
// ❌ 错误:repository包不应该被handler直接引用
|
||||
package repository
|
||||
type LogFilter struct {
|
||||
UserID string
|
||||
}
|
||||
|
||||
// ✅ 正确:放在dto包
|
||||
package dto
|
||||
type LogFilter struct {
|
||||
UserID string
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 新功能开发 Checklist
|
||||
|
||||
开发新功能时,确保满足以下规范:
|
||||
|
||||
- [ ] **Repository定义**
|
||||
- [ ] 接口 + 私有实现结构体
|
||||
- [ ] 构造函数返回接口类型
|
||||
- [ ] 方法参数使用值类型而非指针
|
||||
- [ ] 在`wire/repository.go`中注册
|
||||
|
||||
- [ ] **Service定义**
|
||||
- [ ] 接口 + 私有实现结构体
|
||||
- [ ] 所有Repository依赖使用接口类型
|
||||
- [ ] 不直接持有`gorm.DB`
|
||||
- [ ] 事务操作下沉到Repository
|
||||
|
||||
- [ ] **Handler定义**
|
||||
- [ ] 仅依赖Service接口
|
||||
- [ ] 不直接依赖Repository或Cache
|
||||
- [ ] Query/Filter参数使用dto包类型
|
||||
|
||||
- [ ] **Wire Provider**
|
||||
- [ ] 参数使用接口类型
|
||||
- [ ] 返回值使用接口类型
|
||||
- [ ] 不直接注入`gorm.DB`到Service
|
||||
|
||||
---
|
||||
|
||||
## 10. 相关文件位置参考
|
||||
|
||||
| 类别 | 目录 |
|
||||
|------|------|
|
||||
| Handler | `internal/handler/` |
|
||||
| Service | `internal/service/` |
|
||||
| Repository | `internal/repository/` |
|
||||
| DTO/Filter | `internal/dto/` |
|
||||
| Model | `internal/model/` |
|
||||
| Wire | `internal/wire/` |
|
||||
| Cache | `internal/cache/` |
|
||||
@@ -50,6 +50,7 @@ func ProvideRouter(
|
||||
logService *service.LogService,
|
||||
activityService service.UserActivityService,
|
||||
casbinService service.CasbinService,
|
||||
wsHandler *handler.WSHandler,
|
||||
) *router.Router {
|
||||
return router.New(
|
||||
userHandler,
|
||||
@@ -78,5 +79,6 @@ func ProvideRouter(
|
||||
logService,
|
||||
activityService,
|
||||
casbinService,
|
||||
wsHandler,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func InitializeApp() (*App, error) {
|
||||
pushRecordRepository := repository.NewPushRecordRepository(db)
|
||||
deviceTokenRepository := repository.NewDeviceTokenRepository(db)
|
||||
messageRepository := repository.NewMessageRepository(db)
|
||||
hub := wire.ProvideSSEHub()
|
||||
hub := wire.ProvideWSHub()
|
||||
pushService := wire.ProvidePushService(pushRecordRepository, deviceTokenRepository, messageRepository, hub)
|
||||
postRepository := repository.NewPostRepository(db)
|
||||
client := wire.ProvideRedisClient(config)
|
||||
@@ -115,11 +115,12 @@ func InitializeApp() (*App, error) {
|
||||
adminLogHandler := handler.NewAdminLogHandler(operationLogService, loginLogService, dataChangeLogService)
|
||||
qrCodeLoginService := wire.ProvideQRCodeLoginService(client, hub, jwtService, userService, userActivityService, logService)
|
||||
qrCodeHandler := handler.NewQRCodeHandler(qrCodeLoginService)
|
||||
wsHandler := handler.NewWSHandler(hub, chatService, groupService, jwtService)
|
||||
materialSubjectRepository := repository.NewMaterialSubjectRepository(db)
|
||||
materialFileRepository := repository.NewMaterialFileRepository(db)
|
||||
materialService := wire.ProvideMaterialService(materialSubjectRepository, materialFileRepository)
|
||||
materialHandler := handler.NewMaterialHandler(materialService)
|
||||
router := ProvideRouter(userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, pushHandler, systemMessageHandler, groupHandler, stickerHandler, voteHandler, channelHandler, scheduleHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, qrCodeHandler, materialHandler, logService, userActivityService, casbinService)
|
||||
router := ProvideRouter(userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, pushHandler, systemMessageHandler, groupHandler, stickerHandler, voteHandler, channelHandler, scheduleHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, qrCodeHandler, materialHandler, logService, userActivityService, casbinService, wsHandler)
|
||||
hotRankWorker := wire.ProvideHotRankWorker(config, postRepository, cache)
|
||||
app := NewApp(config, db, router, pushService, hotRankWorker, server, logger)
|
||||
return app, nil
|
||||
@@ -155,6 +156,7 @@ func ProvideRouter(
|
||||
logService *service.LogService,
|
||||
activityService service.UserActivityService,
|
||||
casbinService service.CasbinService,
|
||||
wsHandler *handler.WSHandler,
|
||||
) *router.Router {
|
||||
return router.New(
|
||||
userHandler,
|
||||
@@ -183,5 +185,6 @@ func ProvideRouter(
|
||||
logService,
|
||||
activityService,
|
||||
casbinService,
|
||||
wsHandler,
|
||||
)
|
||||
}
|
||||
|
||||
1
go.mod
1
go.mod
@@ -51,6 +51,7 @@ 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/gorilla/websocket v1.5.3 // 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
|
||||
|
||||
2
go.sum
2
go.sum
@@ -124,6 +124,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/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=
|
||||
|
||||
@@ -2,10 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -13,7 +10,7 @@ import (
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/cursor"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/pkg/sse"
|
||||
"carrot_bbs/internal/pkg/ws"
|
||||
"carrot_bbs/internal/service"
|
||||
)
|
||||
|
||||
@@ -23,95 +20,17 @@ type MessageHandler struct {
|
||||
messageService *service.MessageService
|
||||
userService service.UserService
|
||||
groupService service.GroupService
|
||||
sseHub *sse.Hub
|
||||
wsHub *ws.Hub
|
||||
}
|
||||
|
||||
// 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, wsHub *ws.Hub) *MessageHandler {
|
||||
return &MessageHandler{
|
||||
chatService: chatService,
|
||||
messageService: messageService,
|
||||
userService: userService,
|
||||
groupService: groupService,
|
||||
sseHub: sseHub,
|
||||
}
|
||||
}
|
||||
|
||||
// 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, no-transform")
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Use a shorter heartbeat interval to survive common 50-60s proxy idle timeout.
|
||||
heartbeat := time.NewTicker(10 * 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:
|
||||
// Comment keepalive line for SSE intermediaries/proxies.
|
||||
if _, err := fmt.Fprint(w, ": keepalive\n\n"); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
if _, err := fmt.Fprint(w, "event: heartbeat\ndata: {}\n\n"); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
wsHub: wsHub,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/pkg/sse"
|
||||
"carrot_bbs/internal/pkg/ws"
|
||||
"carrot_bbs/internal/service"
|
||||
)
|
||||
|
||||
@@ -44,16 +44,16 @@ func (h *QRCodeHandler) GetQRCode(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// SSEEvents SSE事件流
|
||||
// WSEvents WebSocket事件流
|
||||
// GET /api/v1/auth/qrcode/events?session_id=xxx
|
||||
func (h *QRCodeHandler) SSEEvents(c *gin.Context) {
|
||||
func (h *QRCodeHandler) WSEvents(c *gin.Context) {
|
||||
sessionID := c.Query("session_id")
|
||||
if sessionID == "" {
|
||||
response.BadRequest(c, "session_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
ch, cancel, replay := h.qrcodeService.GetSSEHub().Subscribe(sessionID, 0)
|
||||
ch, cancel, replay := h.qrcodeService.GetWSHub().Subscribe(sessionID, 0)
|
||||
defer cancel()
|
||||
|
||||
w := c.Writer
|
||||
@@ -70,8 +70,8 @@ func (h *QRCodeHandler) SSEEvents(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
flusher.Flush()
|
||||
|
||||
writeEvent := func(ev sse.Event) bool {
|
||||
data, err := sse.EncodeData(ev)
|
||||
writeEvent := func(ev ws.Event) bool {
|
||||
data, err := ws.EncodeData(ev)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
389
internal/handler/ws_handler.go
Normal file
389
internal/handler/ws_handler.go
Normal file
@@ -0,0 +1,389 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/pkg/ws"
|
||||
"carrot_bbs/internal/service"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 4096,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // 允许所有来源,实际生产环境应该限制
|
||||
},
|
||||
HandshakeTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
// WSHandler WebSocket处理器
|
||||
type WSHandler struct {
|
||||
wsHub *ws.Hub
|
||||
chatService service.ChatService
|
||||
groupService service.GroupService
|
||||
jwtService *service.JWTService
|
||||
clientSeq uint64
|
||||
}
|
||||
|
||||
// NewWSHandler 创建WebSocket处理器
|
||||
func NewWSHandler(
|
||||
wsHub *ws.Hub,
|
||||
chatService service.ChatService,
|
||||
groupService service.GroupService,
|
||||
jwtService *service.JWTService,
|
||||
) *WSHandler {
|
||||
return &WSHandler{
|
||||
wsHub: wsHub,
|
||||
chatService: chatService,
|
||||
groupService: groupService,
|
||||
jwtService: jwtService,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleWebSocket WebSocket连接处理
|
||||
// GET /api/v1/realtime/ws?token=xxx
|
||||
func (h *WSHandler) HandleWebSocket(c *gin.Context) {
|
||||
// 1. 认证 - 支持Query参数和Header两种方式
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader != "" {
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) == 2 && parts[0] == "Bearer" {
|
||||
token = parts[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
response.Unauthorized(c, "token is required")
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := h.jwtService.ParseToken(token)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "invalid token")
|
||||
return
|
||||
}
|
||||
|
||||
userID := claims.UserID
|
||||
|
||||
// 2. 升级HTTP连接为WebSocket
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
zap.L().Error("Failed to upgrade WebSocket connection",
|
||||
zap.String("user_id", userID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 创建客户端
|
||||
clientID := atomic.AddUint64(&h.clientSeq, 1)
|
||||
client := &ws.Client{
|
||||
ID: clientID,
|
||||
UserID: userID,
|
||||
Send: make(chan []byte, defaultUserBufferSize),
|
||||
Quit: make(chan struct{}),
|
||||
}
|
||||
|
||||
// 4. 注册客户端
|
||||
replayEvents := h.wsHub.Register(client)
|
||||
defer h.wsHub.Unregister(client)
|
||||
|
||||
// 5. 发送历史回放消息
|
||||
go func() {
|
||||
for _, ev := range replayEvents {
|
||||
msg := ws.ResponseMessage{
|
||||
EventID: ev.ID,
|
||||
Type: ev.Type,
|
||||
TS: ev.TS,
|
||||
Payload: ev.Payload,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
select {
|
||||
case <-client.Quit:
|
||||
return
|
||||
case client.Send <- data:
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 6. 启动读写goroutine
|
||||
go h.writePump(conn, client)
|
||||
h.readPump(conn, client)
|
||||
}
|
||||
|
||||
// readPump 读取客户端消息
|
||||
func (h *WSHandler) readPump(conn *websocket.Conn, client *ws.Client) {
|
||||
defer func() {
|
||||
conn.Close()
|
||||
close(client.Quit)
|
||||
}()
|
||||
|
||||
conn.SetReadLimit(64 * 1024) // 64KB
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
|
||||
for {
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||
zap.L().Debug("WebSocket read error",
|
||||
zap.String("user_id", client.UserID),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// 解析消息
|
||||
var msg ws.Message
|
||||
if err := json.Unmarshal(message, &msg); err != nil {
|
||||
h.wsHub.SendError(client, "parse_error", "invalid message format")
|
||||
continue
|
||||
}
|
||||
|
||||
// 处理消息
|
||||
h.handleMessage(client, &msg)
|
||||
}
|
||||
}
|
||||
|
||||
// writePump 向客户端发送消息
|
||||
func (h *WSHandler) writePump(conn *websocket.Conn, client *ws.Client) {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-client.Quit:
|
||||
conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
case message, ok := <-client.Send:
|
||||
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if !ok {
|
||||
conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
|
||||
w, err := conn.NextWriter(websocket.TextMessage)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
w.Write(message)
|
||||
|
||||
// 批量发送缓冲区中的消息
|
||||
n := len(client.Send)
|
||||
for i := 0; i < n; i++ {
|
||||
w.Write([]byte{'\n'})
|
||||
w.Write(<-client.Send)
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleMessage 处理客户端消息
|
||||
func (h *WSHandler) handleMessage(client *ws.Client, msg *ws.Message) {
|
||||
ctx := context.Background()
|
||||
|
||||
switch msg.Type {
|
||||
case "ping":
|
||||
h.handlePing(client)
|
||||
case "chat":
|
||||
h.handleChat(ctx, client, msg.Payload)
|
||||
case "typing":
|
||||
h.handleTyping(ctx, client, msg.Payload)
|
||||
case "read":
|
||||
h.handleRead(ctx, client, msg.Payload)
|
||||
case "recall":
|
||||
h.handleRecall(ctx, client, msg.Payload)
|
||||
default:
|
||||
h.wsHub.SendError(client, "unknown_type", fmt.Sprintf("unknown message type: %s", msg.Type))
|
||||
}
|
||||
}
|
||||
|
||||
// handlePing 处理ping消息
|
||||
func (h *WSHandler) handlePing(client *ws.Client) {
|
||||
pong := ws.ResponseMessage{
|
||||
EventID: h.wsHub.NextID(),
|
||||
Type: "pong",
|
||||
TS: time.Now().UnixMilli(),
|
||||
}
|
||||
data, _ := json.Marshal(pong)
|
||||
select {
|
||||
case <-client.Quit:
|
||||
case client.Send <- data:
|
||||
}
|
||||
}
|
||||
|
||||
// handleChat 处理聊天消息
|
||||
func (h *WSHandler) handleChat(ctx context.Context, client *ws.Client, payload json.RawMessage) {
|
||||
var req struct {
|
||||
ConversationID string `json:"conversation_id"`
|
||||
DetailType string `json:"detail_type"`
|
||||
Segments model.MessageSegments `json:"segments"`
|
||||
ReplyToID *string `json:"reply_to_id,omitempty"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(payload, &req); err != nil {
|
||||
h.wsHub.SendError(client, "parse_error", "invalid chat message format")
|
||||
return
|
||||
}
|
||||
|
||||
if req.ConversationID == "" {
|
||||
h.wsHub.SendError(client, "invalid_params", "conversation_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Segments) == 0 {
|
||||
h.wsHub.SendError(client, "invalid_params", "segments is required")
|
||||
return
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
message, err := h.chatService.SendMessage(ctx, client.UserID, req.ConversationID, req.Segments, req.ReplyToID)
|
||||
if err != nil {
|
||||
h.wsHub.SendError(client, "send_failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 发送成功响应
|
||||
detailType := "private"
|
||||
if conv, _ := h.chatService.GetConversationByID(ctx, req.ConversationID, client.UserID); conv != nil && conv.Type == model.ConversationTypeGroup {
|
||||
detailType = "group"
|
||||
}
|
||||
|
||||
resp := dto.WSEventResponse{
|
||||
ID: message.ID,
|
||||
Time: message.CreatedAt.UnixMilli(),
|
||||
Type: "message",
|
||||
DetailType: detailType,
|
||||
Seq: strconv.FormatInt(message.Seq, 10),
|
||||
Segments: req.Segments,
|
||||
SenderID: client.UserID,
|
||||
}
|
||||
|
||||
msg := ws.ResponseMessage{
|
||||
EventID: h.wsHub.NextID(),
|
||||
Type: "message_sent",
|
||||
TS: time.Now().UnixMilli(),
|
||||
Payload: resp,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
select {
|
||||
case <-client.Quit:
|
||||
case client.Send <- data:
|
||||
}
|
||||
}
|
||||
|
||||
// handleTyping 处理输入状态
|
||||
func (h *WSHandler) handleTyping(ctx context.Context, client *ws.Client, payload json.RawMessage) {
|
||||
var req struct {
|
||||
ConversationID string `json:"conversation_id"`
|
||||
IsTyping bool `json:"is_typing"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(payload, &req); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if req.ConversationID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// 输入状态通常不需要持久化,直接通过ChatService的SendTyping处理
|
||||
// 这里简化处理,直接推送
|
||||
h.chatService.SendTyping(ctx, client.UserID, req.ConversationID)
|
||||
}
|
||||
|
||||
// handleRead 处理已读标记
|
||||
func (h *WSHandler) handleRead(ctx context.Context, client *ws.Client, payload json.RawMessage) {
|
||||
var req struct {
|
||||
ConversationID string `json:"conversation_id"`
|
||||
Seq int64 `json:"seq"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(payload, &req); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if req.ConversationID == "" || req.Seq <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
err := h.chatService.MarkAsRead(ctx, req.ConversationID, client.UserID, req.Seq)
|
||||
if err != nil {
|
||||
zap.L().Debug("Failed to mark as read via WebSocket",
|
||||
zap.String("user_id", client.UserID),
|
||||
zap.String("conversation_id", req.ConversationID),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// handleRecall 处理消息撤回
|
||||
func (h *WSHandler) handleRecall(ctx context.Context, client *ws.Client, payload json.RawMessage) {
|
||||
var req struct {
|
||||
MessageID string `json:"message_id"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(payload, &req); err != nil {
|
||||
h.wsHub.SendError(client, "parse_error", "invalid recall message format")
|
||||
return
|
||||
}
|
||||
|
||||
if req.MessageID == "" {
|
||||
h.wsHub.SendError(client, "invalid_params", "message_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.chatService.RecallMessage(ctx, req.MessageID, client.UserID)
|
||||
if err != nil {
|
||||
h.wsHub.SendError(client, "recall_failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 发送撤回成功响应
|
||||
resp := ws.ResponseMessage{
|
||||
EventID: h.wsHub.NextID(),
|
||||
Type: "message_recalled",
|
||||
TS: time.Now().UnixMilli(),
|
||||
Payload: map[string]string{"message_id": req.MessageID},
|
||||
}
|
||||
data, _ := json.Marshal(resp)
|
||||
select {
|
||||
case <-client.Quit:
|
||||
case client.Send <- data:
|
||||
}
|
||||
}
|
||||
|
||||
const defaultUserBufferSize = 128
|
||||
113
internal/model/call.go
Normal file
113
internal/model/call.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"carrot_bbs/internal/pkg/utils"
|
||||
)
|
||||
|
||||
// CallType 通话类型
|
||||
type CallType string
|
||||
|
||||
const (
|
||||
CallTypePrivate CallType = "private" // 私聊通话
|
||||
CallTypeGroup CallType = "group" // 群聊通话
|
||||
)
|
||||
|
||||
// CallStatus 通话状态
|
||||
type CallStatus string
|
||||
|
||||
const (
|
||||
CallStatusCalling CallStatus = "calling" // 呼叫中
|
||||
CallStatusConnected CallStatus = "connected" // 已接通
|
||||
CallStatusEnded CallStatus = "ended" // 已结束
|
||||
CallStatusRejected CallStatus = "rejected" // 已拒绝
|
||||
CallStatusMissed CallStatus = "missed" // 未接听
|
||||
CallStatusCancelled CallStatus = "cancelled" // 已取消
|
||||
)
|
||||
|
||||
// ParticipantStatus 参与者状态
|
||||
type ParticipantStatus string
|
||||
|
||||
const (
|
||||
ParticipantStatusInvited ParticipantStatus = "invited" // 已邀请
|
||||
ParticipantStatusJoined ParticipantStatus = "joined" // 已加入
|
||||
ParticipantStatusLeft ParticipantStatus = "left" // 已离开
|
||||
ParticipantStatusRejected ParticipantStatus = "rejected" // 已拒绝
|
||||
)
|
||||
|
||||
// CallSession 通话会话
|
||||
type CallSession struct {
|
||||
ID string `gorm:"primaryKey;size:20" json:"id"` // 雪花算法ID
|
||||
ConversationID string `gorm:"not null;size:20;index" json:"conversation_id"` // 会话ID
|
||||
GroupID *string `gorm:"index" json:"group_id,omitempty"` // 群组ID(群聊时使用)
|
||||
CallerID string `gorm:"column:caller_id;type:varchar(50);index;not null" json:"caller_id"` // 发起者ID
|
||||
CallType CallType `gorm:"type:varchar(20);not null" json:"call_type"` // 通话类型
|
||||
Status CallStatus `gorm:"type:varchar(20);default:'calling'" json:"status"` // 通话状态
|
||||
StartedAt *time.Time `json:"started_at,omitempty"` // 接通时间
|
||||
EndedAt *time.Time `json:"ended_at,omitempty"` // 结束时间
|
||||
Duration int64 `gorm:"default:0" json:"duration"` // 通话时长(秒)
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// 关联
|
||||
Participants []CallParticipant `gorm:"foreignKey:CallID" json:"participants,omitempty"`
|
||||
}
|
||||
|
||||
// BeforeCreate 创建前生成雪花算法ID
|
||||
func (c *CallSession) BeforeCreate(tx *gorm.DB) error {
|
||||
if c.ID == "" {
|
||||
id, err := utils.GetSnowflake().GenerateID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.ID = strconv.FormatInt(id, 10)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (CallSession) TableName() string {
|
||||
return "call_sessions"
|
||||
}
|
||||
|
||||
// CallParticipant 通话参与者
|
||||
type CallParticipant struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CallID string `gorm:"not null;size:20;index" json:"call_id"` // 通话ID
|
||||
UserID string `gorm:"column:user_id;type:varchar(50);index;not null" json:"user_id"` // 用户ID
|
||||
Status ParticipantStatus `gorm:"type:varchar(20);default:'invited'" json:"status"` // 参与状态
|
||||
JoinedAt *time.Time `json:"joined_at,omitempty"` // 加入时间
|
||||
LeftAt *time.Time `json:"left_at,omitempty"` // 离开时间
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
func (CallParticipant) TableName() string {
|
||||
return "call_participants"
|
||||
}
|
||||
|
||||
// IsPrivate 是否私聊通话
|
||||
func (c *CallSession) IsPrivate() bool {
|
||||
return c.CallType == CallTypePrivate
|
||||
}
|
||||
|
||||
// IsGroup 是否群聊通话
|
||||
func (c *CallSession) IsGroup() bool {
|
||||
return c.CallType == CallTypeGroup
|
||||
}
|
||||
|
||||
// IsActive 是否活跃通话
|
||||
func (c *CallSession) IsActive() bool {
|
||||
return c.Status == CallStatusCalling || c.Status == CallStatusConnected
|
||||
}
|
||||
|
||||
// CalculateDuration 计算通话时长
|
||||
func (c *CallSession) CalculateDuration() {
|
||||
if c.StartedAt != nil && c.EndedAt != nil {
|
||||
c.Duration = int64(c.EndedAt.Sub(*c.StartedAt).Seconds())
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package sse
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultUserBufferSize = 128
|
||||
maxReplayEvents = 200
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
ID uint64 `json:"event_id"`
|
||||
Event string `json:"event"`
|
||||
TS int64 `json:"ts"`
|
||||
Payload interface{} `json:"payload"`
|
||||
}
|
||||
|
||||
type subscriber struct {
|
||||
id uint64
|
||||
ch chan Event
|
||||
quit chan struct{}
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
seq uint64
|
||||
|
||||
mu sync.RWMutex
|
||||
subscribers map[string]map[uint64]*subscriber
|
||||
history map[string][]Event
|
||||
}
|
||||
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
subscribers: make(map[string]map[uint64]*subscriber),
|
||||
history: make(map[string][]Event),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) NextID() uint64 {
|
||||
return atomic.AddUint64(&h.seq, 1)
|
||||
}
|
||||
|
||||
func ParseEventID(raw string) uint64 {
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
id, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (h *Hub) Subscribe(userID string, afterID uint64) (chan Event, func(), []Event) {
|
||||
subID := h.NextID()
|
||||
sub := &subscriber{
|
||||
id: subID,
|
||||
ch: make(chan Event, defaultUserBufferSize),
|
||||
quit: make(chan struct{}),
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
if _, ok := h.subscribers[userID]; !ok {
|
||||
h.subscribers[userID] = make(map[uint64]*subscriber)
|
||||
}
|
||||
h.subscribers[userID][subID] = sub
|
||||
|
||||
replay := make([]Event, 0)
|
||||
for _, e := range h.history[userID] {
|
||||
if e.ID > afterID {
|
||||
replay = append(replay, e)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
cancel := func() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if userSubs, ok := h.subscribers[userID]; ok {
|
||||
if s, exists := userSubs[subID]; exists {
|
||||
close(s.quit)
|
||||
delete(userSubs, subID)
|
||||
close(s.ch)
|
||||
}
|
||||
if len(userSubs) == 0 {
|
||||
delete(h.subscribers, userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sub.ch, cancel, replay
|
||||
}
|
||||
|
||||
func (h *Hub) HasSubscribers(userID string) bool {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.subscribers[userID]) > 0
|
||||
}
|
||||
|
||||
func (h *Hub) PublishToUser(userID string, eventName string, payload interface{}) Event {
|
||||
ev := Event{
|
||||
ID: h.NextID(),
|
||||
Event: eventName,
|
||||
TS: time.Now().UnixMilli(),
|
||||
Payload: payload,
|
||||
}
|
||||
h.publish(userID, ev)
|
||||
return ev
|
||||
}
|
||||
|
||||
func (h *Hub) PublishToUsers(userIDs []string, eventName string, payload interface{}) {
|
||||
for _, uid := range userIDs {
|
||||
h.PublishToUser(uid, eventName, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) publish(userID string, ev Event) {
|
||||
h.mu.Lock()
|
||||
history := append(h.history[userID], ev)
|
||||
if len(history) > maxReplayEvents {
|
||||
history = history[len(history)-maxReplayEvents:]
|
||||
}
|
||||
h.history[userID] = history
|
||||
|
||||
targets := make([]*subscriber, 0, len(h.subscribers[userID]))
|
||||
for _, s := range h.subscribers[userID] {
|
||||
targets = append(targets, s)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, s := range targets {
|
||||
select {
|
||||
case <-s.quit:
|
||||
case s.ch <- ev:
|
||||
default:
|
||||
// 慢消费者丢弃单条消息,客户端可通过 Last-Event-ID + HTTP 同步补偿
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func EncodeData(ev Event) (string, error) {
|
||||
body, err := json.Marshal(ev.Payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
318
internal/pkg/ws/hub.go
Normal file
318
internal/pkg/ws/hub.go
Normal file
@@ -0,0 +1,318 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultUserBufferSize = 128
|
||||
maxReplayEvents = 200
|
||||
)
|
||||
|
||||
// Event 表示一个WebSocket事件
|
||||
type Event struct {
|
||||
ID uint64 `json:"event_id"`
|
||||
Type string `json:"type"`
|
||||
Event string `json:"event"` // 事件名称(兼容多种协议)
|
||||
TS int64 `json:"ts"`
|
||||
Payload interface{} `json:"payload"`
|
||||
}
|
||||
|
||||
// Client 表示一个WebSocket客户端连接
|
||||
type Client struct {
|
||||
ID uint64
|
||||
UserID string
|
||||
Send chan []byte
|
||||
Quit chan struct{}
|
||||
}
|
||||
|
||||
// Message 表示WebSocket消息
|
||||
type Message struct {
|
||||
Type string `json:"type"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
// ResponseMessage 表示发送给客户端的消息
|
||||
type ResponseMessage struct {
|
||||
EventID uint64 `json:"event_id"`
|
||||
Type string `json:"type"`
|
||||
TS int64 `json:"ts"`
|
||||
Payload interface{} `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
// ErrorMessage 表示错误消息
|
||||
type ErrorMessage struct {
|
||||
Type string `json:"type"`
|
||||
Payload struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
|
||||
// subscriber 表示一个事件订阅者
|
||||
type subscriber struct {
|
||||
id uint64
|
||||
ch chan Event
|
||||
quit chan struct{}
|
||||
}
|
||||
|
||||
// Hub WebSocket连接管理器
|
||||
type Hub struct {
|
||||
seq uint64
|
||||
|
||||
mu sync.RWMutex
|
||||
clients map[string]map[uint64]*Client // userID -> clientID -> Client
|
||||
history map[string][]Event // userID -> []Event (用于断线重连历史回放)
|
||||
subscribers map[string]map[uint64]*subscriber // userID -> subscriberID -> subscriber
|
||||
}
|
||||
|
||||
// NewHub 创建WebSocket Hub
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
clients: make(map[string]map[uint64]*Client),
|
||||
history: make(map[string][]Event),
|
||||
subscribers: make(map[string]map[uint64]*subscriber),
|
||||
}
|
||||
}
|
||||
|
||||
// NextID 获取下一个事件ID
|
||||
func (h *Hub) NextID() uint64 {
|
||||
return atomic.AddUint64(&h.seq, 1)
|
||||
}
|
||||
|
||||
// Register 注册客户端连接
|
||||
func (h *Hub) Register(client *Client) []Event {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if _, ok := h.clients[client.UserID]; !ok {
|
||||
h.clients[client.UserID] = make(map[uint64]*Client)
|
||||
}
|
||||
h.clients[client.UserID][client.ID] = client
|
||||
|
||||
// 获取历史事件用于回放
|
||||
replay := make([]Event, 0)
|
||||
for _, e := range h.history[client.UserID] {
|
||||
replay = append(replay, e)
|
||||
}
|
||||
|
||||
zap.L().Debug("WebSocket client registered",
|
||||
zap.String("user_id", client.UserID),
|
||||
zap.Uint64("client_id", client.ID),
|
||||
zap.Int("total_clients", len(h.clients[client.UserID])),
|
||||
)
|
||||
|
||||
return replay
|
||||
}
|
||||
|
||||
// Unregister 注销客户端连接
|
||||
func (h *Hub) Unregister(client *Client) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if userClients, ok := h.clients[client.UserID]; ok {
|
||||
if _, exists := userClients[client.ID]; exists {
|
||||
delete(userClients, client.ID)
|
||||
close(client.Quit)
|
||||
|
||||
if len(userClients) == 0 {
|
||||
delete(h.clients, client.UserID)
|
||||
}
|
||||
|
||||
zap.L().Debug("WebSocket client unregistered",
|
||||
zap.String("user_id", client.UserID),
|
||||
zap.Uint64("client_id", client.ID),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HasClients 检查用户是否有活跃连接
|
||||
func (h *Hub) HasClients(userID string) bool {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.clients[userID]) > 0
|
||||
}
|
||||
|
||||
// GetClientCount 获取用户的连接数
|
||||
func (h *Hub) GetClientCount(userID string) int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.clients[userID])
|
||||
}
|
||||
|
||||
// PublishToUser 向指定用户发送事件
|
||||
func (h *Hub) PublishToUser(userID string, eventType string, payload interface{}) Event {
|
||||
ev := Event{
|
||||
ID: h.NextID(),
|
||||
Type: eventType,
|
||||
TS: time.Now().UnixMilli(),
|
||||
Payload: payload,
|
||||
}
|
||||
h.publish(userID, ev)
|
||||
return ev
|
||||
}
|
||||
|
||||
// PublishToUsers 向多个用户发送事件
|
||||
func (h *Hub) PublishToUsers(userIDs []string, eventType string, payload interface{}) {
|
||||
for _, uid := range userIDs {
|
||||
h.PublishToUser(uid, eventType, payload)
|
||||
}
|
||||
}
|
||||
|
||||
// publish 内部发布方法
|
||||
func (h *Hub) publish(userID string, ev Event) {
|
||||
h.mu.Lock()
|
||||
// 存储历史事件(最多保留200条)
|
||||
history := append(h.history[userID], ev)
|
||||
if len(history) > maxReplayEvents {
|
||||
history = history[len(history)-maxReplayEvents:]
|
||||
}
|
||||
h.history[userID] = history
|
||||
|
||||
// 复制客户端列表避免锁竞争
|
||||
targets := make([]*Client, 0, len(h.clients[userID]))
|
||||
for _, c := range h.clients[userID] {
|
||||
targets = append(targets, c)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
// 构建消息
|
||||
msg := ResponseMessage{
|
||||
EventID: ev.ID,
|
||||
Type: ev.Type,
|
||||
TS: ev.TS,
|
||||
Payload: ev.Payload,
|
||||
}
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
zap.L().Error("Failed to marshal WebSocket message",
|
||||
zap.String("user_id", userID),
|
||||
zap.String("event_type", ev.Type),
|
||||
zap.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// 非阻塞发送,慢消费者丢弃消息
|
||||
for _, c := range targets {
|
||||
select {
|
||||
case <-c.Quit:
|
||||
// 客户端已断开
|
||||
case c.Send <- data:
|
||||
// 消息已发送
|
||||
default:
|
||||
// 发送缓冲区满,丢弃消息
|
||||
zap.L().Warn("WebSocket client buffer full, dropping message",
|
||||
zap.String("user_id", userID),
|
||||
zap.Uint64("client_id", c.ID),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SendError 向客户端发送错误消息
|
||||
func (h *Hub) SendError(client *Client, code string, message string) {
|
||||
errMsg := ErrorMessage{
|
||||
Type: "error",
|
||||
}
|
||||
errMsg.Payload.Code = code
|
||||
errMsg.Payload.Message = message
|
||||
|
||||
data, err := json.Marshal(errMsg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-client.Quit:
|
||||
case client.Send <- data:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast 广播消息给所有连接
|
||||
func (h *Hub) Broadcast(eventType string, payload interface{}) {
|
||||
h.mu.RLock()
|
||||
userIDs := make([]string, 0, len(h.clients))
|
||||
for uid := range h.clients {
|
||||
userIDs = append(userIDs, uid)
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
h.PublishToUsers(userIDs, eventType, payload)
|
||||
}
|
||||
|
||||
// GetOnlineUsers 获取在线用户列表
|
||||
func (h *Hub) GetOnlineUsers() []string {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
users := make([]string, 0, len(h.clients))
|
||||
for uid := range h.clients {
|
||||
users = append(users, uid)
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
// GetOnlineCount 获取在线用户数
|
||||
func (h *Hub) GetOnlineCount() int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.clients)
|
||||
}
|
||||
|
||||
// Subscribe 订阅事件
|
||||
func (h *Hub) Subscribe(userID string, afterID uint64) (chan Event, func(), []Event) {
|
||||
subID := h.NextID()
|
||||
sub := &subscriber{
|
||||
id: subID,
|
||||
ch: make(chan Event, defaultUserBufferSize),
|
||||
quit: make(chan struct{}),
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
if _, ok := h.subscribers[userID]; !ok {
|
||||
h.subscribers[userID] = make(map[uint64]*subscriber)
|
||||
}
|
||||
h.subscribers[userID][subID] = sub
|
||||
|
||||
replay := make([]Event, 0)
|
||||
for _, e := range h.history[userID] {
|
||||
if e.ID > afterID {
|
||||
replay = append(replay, e)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
cancel := func() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if userSubs, ok := h.subscribers[userID]; ok {
|
||||
if s, exists := userSubs[subID]; exists {
|
||||
close(s.quit)
|
||||
delete(userSubs, subID)
|
||||
close(s.ch)
|
||||
}
|
||||
if len(userSubs) == 0 {
|
||||
delete(h.subscribers, userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sub.ch, cancel, replay
|
||||
}
|
||||
|
||||
// EncodeData 编码事件数据
|
||||
func EncodeData(ev Event) (string, error) {
|
||||
body, err := json.Marshal(ev.Payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
@@ -34,6 +34,7 @@ type Router struct {
|
||||
adminLogHandler *handler.AdminLogHandler
|
||||
qrcodeHandler *handler.QRCodeHandler
|
||||
materialHandler *handler.MaterialHandler
|
||||
wsHandler *handler.WSHandler
|
||||
logService *service.LogService
|
||||
jwtService *service.JWTService
|
||||
casbinService service.CasbinService
|
||||
@@ -67,6 +68,7 @@ func New(
|
||||
logService *service.LogService,
|
||||
activityService service.UserActivityService,
|
||||
casbinService service.CasbinService,
|
||||
wsHandler *handler.WSHandler,
|
||||
) *Router {
|
||||
// 设置JWT服务
|
||||
userHandler.SetJWTService(jwtService)
|
||||
@@ -100,6 +102,7 @@ func New(
|
||||
logService: logService,
|
||||
jwtService: jwtService,
|
||||
casbinService: casbinService,
|
||||
wsHandler: wsHandler,
|
||||
}
|
||||
|
||||
r.setupRoutes()
|
||||
@@ -194,7 +197,7 @@ func (r *Router) setupRoutes() {
|
||||
qrcode := v1.Group("/auth/qrcode")
|
||||
{
|
||||
qrcode.GET("", r.qrcodeHandler.GetQRCode)
|
||||
qrcode.GET("/events", r.qrcodeHandler.SSEEvents)
|
||||
qrcode.GET("/events", r.qrcodeHandler.WSEvents)
|
||||
}
|
||||
|
||||
// 二维码操作(需认证)
|
||||
@@ -327,9 +330,9 @@ func (r *Router) setupRoutes() {
|
||||
}
|
||||
|
||||
realtime := v1.Group("/realtime")
|
||||
realtime.Use(authMiddleware)
|
||||
{
|
||||
realtime.GET("/sse", r.messageHandler.HandleSSE)
|
||||
// WebSocket端点 - 在中间件之前处理认证(通过query参数)
|
||||
realtime.GET("/ws", r.wsHandler.HandleWebSocket)
|
||||
}
|
||||
|
||||
// 消息操作路由
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"carrot_bbs/internal/cache"
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/sse"
|
||||
"carrot_bbs/internal/pkg/ws"
|
||||
"carrot_bbs/internal/repository"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -59,7 +59,7 @@ type chatServiceImpl struct {
|
||||
repo repository.MessageRepository
|
||||
userRepo repository.UserRepository
|
||||
sensitive SensitiveService
|
||||
sseHub *sse.Hub
|
||||
wsHub *ws.Hub
|
||||
|
||||
// 缓存相关字段
|
||||
conversationCache *cache.ConversationCache
|
||||
@@ -71,7 +71,7 @@ func NewChatService(
|
||||
repo repository.MessageRepository,
|
||||
userRepo repository.UserRepository,
|
||||
sensitive SensitiveService,
|
||||
sseHub *sse.Hub,
|
||||
wsHub *ws.Hub,
|
||||
cacheBackend cache.Cache,
|
||||
uploadService *UploadService,
|
||||
) ChatService {
|
||||
@@ -91,17 +91,17 @@ func NewChatService(
|
||||
repo: repo,
|
||||
userRepo: userRepo,
|
||||
sensitive: sensitive,
|
||||
sseHub: sseHub,
|
||||
wsHub: wsHub,
|
||||
conversationCache: conversationCache,
|
||||
uploadService: uploadService,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *chatServiceImpl) publishSSEToUsers(userIDs []string, event string, payload interface{}) {
|
||||
if s.sseHub == nil || len(userIDs) == 0 {
|
||||
func (s *chatServiceImpl) publishToUsers(userIDs []string, event string, payload interface{}) {
|
||||
if s.wsHub == nil || len(userIDs) == 0 {
|
||||
return
|
||||
}
|
||||
s.sseHub.PublishToUsers(userIDs, event, payload)
|
||||
s.wsHub.PublishToUsers(userIDs, event, payload)
|
||||
}
|
||||
|
||||
// GetOrCreateConversation 获取或创建私聊会话
|
||||
@@ -314,12 +314,12 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
|
||||
}
|
||||
}()
|
||||
|
||||
// 获取会话中的参与者并发送 SSE
|
||||
// 获取会话中的参与者并发送消息
|
||||
participants, err := s.getParticipants(ctx, conversationID)
|
||||
if err == nil {
|
||||
targetIDs := make([]string, 0, len(participants))
|
||||
for _, p := range participants {
|
||||
// 私聊场景下,发送者已经从 HTTP 响应拿到消息,避免再通过 SSE 回推导致本端重复展示。
|
||||
// 私聊场景下,发送者已经从 HTTP 响应拿到消息,避免再通过 WebSocket 回推导致本端重复展示。
|
||||
if conv.Type == model.ConversationTypePrivate && p.UserID == senderID {
|
||||
continue
|
||||
}
|
||||
@@ -329,7 +329,7 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
|
||||
if conv.Type == model.ConversationTypeGroup {
|
||||
detailType = "group"
|
||||
}
|
||||
s.publishSSEToUsers(targetIDs, "chat_message", map[string]interface{}{
|
||||
s.publishToUsers(targetIDs, "chat_message", map[string]interface{}{
|
||||
"detail_type": detailType,
|
||||
"message": dto.ConvertMessageToResponse(message),
|
||||
})
|
||||
@@ -342,7 +342,7 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
|
||||
s.conversationCache.InvalidateUnreadCount(p.UserID, conversationID)
|
||||
}
|
||||
if totalUnread, uErr := s.repo.GetAllUnreadCount(p.UserID); uErr == nil {
|
||||
s.publishSSEToUsers([]string{p.UserID}, "conversation_unread", map[string]interface{}{
|
||||
s.publishToUsers([]string{p.UserID}, "conversation_unread", map[string]interface{}{
|
||||
"conversation_id": conversationID,
|
||||
"total_unread": totalUnread,
|
||||
})
|
||||
@@ -490,7 +490,7 @@ func (s *chatServiceImpl) MarkAsRead(ctx context.Context, conversationID string,
|
||||
for _, p := range participants {
|
||||
targetIDs = append(targetIDs, p.UserID)
|
||||
}
|
||||
s.publishSSEToUsers(targetIDs, "message_read", map[string]interface{}{
|
||||
s.publishToUsers(targetIDs, "message_read", map[string]interface{}{
|
||||
"detail_type": detailType,
|
||||
"conversation_id": conversationID,
|
||||
"group_id": groupID,
|
||||
@@ -499,7 +499,7 @@ func (s *chatServiceImpl) MarkAsRead(ctx context.Context, conversationID string,
|
||||
})
|
||||
}
|
||||
if totalUnread, uErr := s.repo.GetAllUnreadCount(userID); uErr == nil {
|
||||
s.publishSSEToUsers([]string{userID}, "conversation_unread", map[string]interface{}{
|
||||
s.publishToUsers([]string{userID}, "conversation_unread", map[string]interface{}{
|
||||
"conversation_id": conversationID,
|
||||
"total_unread": totalUnread,
|
||||
})
|
||||
@@ -579,7 +579,7 @@ func (s *chatServiceImpl) RecallMessage(ctx context.Context, messageID string, u
|
||||
for _, p := range participants {
|
||||
targetIDs = append(targetIDs, p.UserID)
|
||||
}
|
||||
s.publishSSEToUsers(targetIDs, "message_recall", map[string]interface{}{
|
||||
s.publishToUsers(targetIDs, "message_recall", map[string]interface{}{
|
||||
"detail_type": detailType,
|
||||
"conversation_id": message.ConversationID,
|
||||
"group_id": groupID,
|
||||
@@ -627,7 +627,7 @@ func (s *chatServiceImpl) DeleteMessage(ctx context.Context, messageID string, u
|
||||
|
||||
// SendTyping 发送正在输入状态
|
||||
func (s *chatServiceImpl) SendTyping(ctx context.Context, senderID string, conversationID string) {
|
||||
if s.sseHub == nil {
|
||||
if s.wsHub == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -651,8 +651,8 @@ func (s *chatServiceImpl) SendTyping(ctx context.Context, senderID string, conve
|
||||
if p.UserID == senderID {
|
||||
continue
|
||||
}
|
||||
if s.sseHub != nil {
|
||||
s.sseHub.PublishToUser(p.UserID, "typing", map[string]interface{}{
|
||||
if s.wsHub != nil {
|
||||
s.wsHub.PublishToUser(p.UserID, "typing", map[string]interface{}{
|
||||
"detail_type": detailType,
|
||||
"conversation_id": conversationID,
|
||||
"user_id": senderID,
|
||||
@@ -664,8 +664,8 @@ func (s *chatServiceImpl) SendTyping(ctx context.Context, senderID string, conve
|
||||
|
||||
// IsUserOnline 检查用户是否在线
|
||||
func (s *chatServiceImpl) IsUserOnline(userID string) bool {
|
||||
if s.sseHub != nil {
|
||||
return s.sseHub.HasSubscribers(userID)
|
||||
if s.wsHub != nil {
|
||||
return s.wsHub.HasClients(userID)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
apperrors "carrot_bbs/internal/errors"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/cursor"
|
||||
"carrot_bbs/internal/pkg/sse"
|
||||
"carrot_bbs/internal/pkg/utils"
|
||||
"carrot_bbs/internal/pkg/ws"
|
||||
"carrot_bbs/internal/repository"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -111,19 +111,19 @@ type groupService struct {
|
||||
messageRepo repository.MessageRepository
|
||||
requestRepo repository.GroupJoinRequestRepository
|
||||
notifyRepo repository.SystemNotificationRepository
|
||||
sseHub *sse.Hub
|
||||
wsHub *ws.Hub
|
||||
cache cache.Cache
|
||||
}
|
||||
|
||||
// NewGroupService 创建群组服务
|
||||
func NewGroupService(groupRepo repository.GroupRepository, userRepo repository.UserRepository, messageRepo repository.MessageRepository, requestRepo repository.GroupJoinRequestRepository, notifyRepo repository.SystemNotificationRepository, sseHub *sse.Hub, cacheBackend cache.Cache) GroupService {
|
||||
func NewGroupService(groupRepo repository.GroupRepository, userRepo repository.UserRepository, messageRepo repository.MessageRepository, requestRepo repository.GroupJoinRequestRepository, notifyRepo repository.SystemNotificationRepository, wsHub *ws.Hub, cacheBackend cache.Cache) GroupService {
|
||||
return &groupService{
|
||||
groupRepo: groupRepo,
|
||||
userRepo: userRepo,
|
||||
messageRepo: messageRepo,
|
||||
requestRepo: requestRepo,
|
||||
notifyRepo: notifyRepo,
|
||||
sseHub: sseHub,
|
||||
wsHub: wsHub,
|
||||
cache: cacheBackend,
|
||||
}
|
||||
}
|
||||
@@ -152,9 +152,9 @@ func (s *groupService) publishGroupNotice(groupID string, notice groupNoticeMess
|
||||
)
|
||||
return
|
||||
}
|
||||
if s.sseHub != nil {
|
||||
if s.wsHub != nil {
|
||||
for _, m := range members {
|
||||
s.sseHub.PublishToUser(m.UserID, "group_notice", notice)
|
||||
s.wsHub.PublishToUser(m.UserID, "group_notice", notice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/sse"
|
||||
"carrot_bbs/internal/pkg/ws"
|
||||
"carrot_bbs/internal/repository"
|
||||
)
|
||||
|
||||
@@ -65,7 +65,7 @@ type pushServiceImpl struct {
|
||||
pushRepo repository.PushRecordRepository
|
||||
deviceRepo repository.DeviceTokenRepository
|
||||
messageRepo repository.MessageRepository
|
||||
sseHub *sse.Hub
|
||||
wsHub *ws.Hub
|
||||
|
||||
// 推送队列
|
||||
pushQueue chan *pushTask
|
||||
@@ -84,13 +84,13 @@ func NewPushService(
|
||||
pushRepo repository.PushRecordRepository,
|
||||
deviceRepo repository.DeviceTokenRepository,
|
||||
messageRepo repository.MessageRepository,
|
||||
sseHub *sse.Hub,
|
||||
wsHub *ws.Hub,
|
||||
) PushService {
|
||||
return &pushServiceImpl{
|
||||
pushRepo: pushRepo,
|
||||
deviceRepo: deviceRepo,
|
||||
messageRepo: messageRepo,
|
||||
sseHub: sseHub,
|
||||
wsHub: wsHub,
|
||||
pushQueue: make(chan *pushTask, PushQueueSize),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
@@ -138,7 +138,7 @@ func (s *pushServiceImpl) PushToUser(ctx context.Context, userID string, message
|
||||
// pushViaWebSocket 通过WebSocket推送消息
|
||||
// 返回true表示推送成功,false表示用户不在线
|
||||
func (s *pushServiceImpl) pushViaWebSocket(ctx context.Context, userID string, message *model.Message) bool {
|
||||
if s.sseHub == nil || !s.sseHub.HasSubscribers(userID) {
|
||||
if s.wsHub == nil || !s.wsHub.HasClients(userID) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ func (s *pushServiceImpl) pushViaWebSocket(ctx context.Context, userID string, m
|
||||
}
|
||||
}
|
||||
}
|
||||
s.sseHub.PublishToUser(userID, "system_notification", notification)
|
||||
s.wsHub.PublishToUser(userID, "system_notification", notification)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ func (s *pushServiceImpl) pushViaWebSocket(ctx context.Context, userID string, m
|
||||
SenderID: message.SenderID,
|
||||
}
|
||||
|
||||
s.sseHub.PublishToUser(userID, "chat_message", map[string]interface{}{
|
||||
s.wsHub.PublishToUser(userID, "chat_message", map[string]interface{}{
|
||||
"detail_type": detailType,
|
||||
"message": event,
|
||||
})
|
||||
@@ -444,7 +444,7 @@ func (s *pushServiceImpl) PushSystemMessage(ctx context.Context, userID string,
|
||||
|
||||
// pushSystemViaWebSocket 通过WebSocket推送系统消息
|
||||
func (s *pushServiceImpl) pushSystemViaWebSocket(ctx context.Context, userID string, msgType, title, content string, data map[string]interface{}) bool {
|
||||
if s.sseHub == nil || !s.sseHub.HasSubscribers(userID) {
|
||||
if s.wsHub == nil || !s.wsHub.HasClients(userID) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -455,7 +455,7 @@ func (s *pushServiceImpl) pushSystemViaWebSocket(ctx context.Context, userID str
|
||||
"data": data,
|
||||
"created_at": time.Now().UnixMilli(),
|
||||
}
|
||||
s.sseHub.PublishToUser(userID, "system_notification", sysMsg)
|
||||
s.wsHub.PublishToUser(userID, "system_notification", sysMsg)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -472,11 +472,11 @@ func (s *pushServiceImpl) PushSystemNotification(ctx context.Context, userID str
|
||||
|
||||
// pushSystemNotificationViaWebSocket 通过WebSocket推送系统通知
|
||||
func (s *pushServiceImpl) pushSystemNotificationViaWebSocket(ctx context.Context, userID string, notification *model.SystemNotification) bool {
|
||||
if s.sseHub == nil || !s.sseHub.HasSubscribers(userID) {
|
||||
if s.wsHub == nil || !s.wsHub.HasClients(userID) {
|
||||
return false
|
||||
}
|
||||
|
||||
sseNotification := map[string]interface{}{
|
||||
wsNotification := map[string]interface{}{
|
||||
"id": fmt.Sprintf("%d", notification.ID),
|
||||
"type": string(notification.Type),
|
||||
"title": notification.Title,
|
||||
@@ -487,7 +487,7 @@ func (s *pushServiceImpl) pushSystemNotificationViaWebSocket(ctx context.Context
|
||||
|
||||
// 填充额外数据
|
||||
if notification.ExtraData != nil {
|
||||
extra := sseNotification["extra"].(map[string]interface{})
|
||||
extra := wsNotification["extra"].(map[string]interface{})
|
||||
extra["actor_id_str"] = notification.ExtraData.ActorIDStr
|
||||
extra["actor_name"] = notification.ExtraData.ActorName
|
||||
extra["avatar_url"] = notification.ExtraData.AvatarURL
|
||||
@@ -498,7 +498,7 @@ func (s *pushServiceImpl) pushSystemNotificationViaWebSocket(ctx context.Context
|
||||
|
||||
// 设置触发用户信息
|
||||
if notification.ExtraData.ActorIDStr != "" {
|
||||
sseNotification["trigger_user"] = map[string]interface{}{
|
||||
wsNotification["trigger_user"] = map[string]interface{}{
|
||||
"id": notification.ExtraData.ActorIDStr,
|
||||
"username": notification.ExtraData.ActorName,
|
||||
"avatar": notification.ExtraData.AvatarURL,
|
||||
@@ -506,6 +506,6 @@ func (s *pushServiceImpl) pushSystemNotificationViaWebSocket(ctx context.Context
|
||||
}
|
||||
}
|
||||
|
||||
s.sseHub.PublishToUser(userID, "system_notification", sseNotification)
|
||||
s.wsHub.PublishToUser(userID, "system_notification", wsNotification)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/redis"
|
||||
"carrot_bbs/internal/pkg/sse"
|
||||
"carrot_bbs/internal/pkg/ws"
|
||||
)
|
||||
|
||||
// Lua 脚本:原子性地检查状态并更新为 scanned
|
||||
@@ -54,7 +54,7 @@ type QRCodeSession struct {
|
||||
// QRCodeLoginService 二维码登录服务
|
||||
type QRCodeLoginService struct {
|
||||
redis *redis.Client
|
||||
sseHub *sse.Hub
|
||||
wsHub *ws.Hub
|
||||
jwtService *JWTService
|
||||
userService UserService
|
||||
activityService UserActivityService
|
||||
@@ -62,10 +62,10 @@ type QRCodeLoginService struct {
|
||||
}
|
||||
|
||||
// NewQRCodeLoginService 创建二维码登录服务
|
||||
func NewQRCodeLoginService(redis *redis.Client, sseHub *sse.Hub, jwtService *JWTService, userService UserService, activityService UserActivityService, logService *LogService) *QRCodeLoginService {
|
||||
func NewQRCodeLoginService(redis *redis.Client, wsHub *ws.Hub, jwtService *JWTService, userService UserService, activityService UserActivityService, logService *LogService) *QRCodeLoginService {
|
||||
return &QRCodeLoginService{
|
||||
redis: redis,
|
||||
sseHub: sseHub,
|
||||
wsHub: wsHub,
|
||||
jwtService: jwtService,
|
||||
userService: userService,
|
||||
activityService: activityService,
|
||||
@@ -170,7 +170,7 @@ func (s *QRCodeLoginService) Scan(ctx context.Context, sessionID, userID string)
|
||||
"avatar": user.Avatar,
|
||||
},
|
||||
}
|
||||
s.sseHub.PublishToUser(sessionID, "scanned", payload)
|
||||
s.wsHub.PublishToUser(sessionID, "scanned", payload)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -242,7 +242,7 @@ func (s *QRCodeLoginService) Confirm(ctx context.Context, sessionID, userID stri
|
||||
RefreshToken: refreshToken,
|
||||
User: user,
|
||||
}
|
||||
s.sseHub.PublishToUser(sessionID, "confirmed", response)
|
||||
s.wsHub.PublishToUser(sessionID, "confirmed", response)
|
||||
|
||||
return response, nil
|
||||
}
|
||||
@@ -275,14 +275,14 @@ func (s *QRCodeLoginService) Cancel(ctx context.Context, sessionID, userID strin
|
||||
}
|
||||
|
||||
// 推送取消事件
|
||||
s.sseHub.PublishToUser(sessionID, "cancelled", map[string]interface{}{})
|
||||
s.wsHub.PublishToUser(sessionID, "cancelled", map[string]interface{}{})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSSEHub 获取SSE Hub
|
||||
func (s *QRCodeLoginService) GetSSEHub() *sse.Hub {
|
||||
return s.sseHub
|
||||
// GetWSHub 获取WS Hub
|
||||
func (s *QRCodeLoginService) GetWSHub() *ws.Hub {
|
||||
return s.wsHub
|
||||
}
|
||||
|
||||
// GetUserService 获取用户服务
|
||||
|
||||
@@ -2,7 +2,7 @@ package wire
|
||||
|
||||
import (
|
||||
"carrot_bbs/internal/handler"
|
||||
"carrot_bbs/internal/pkg/sse"
|
||||
"carrot_bbs/internal/pkg/ws"
|
||||
"carrot_bbs/internal/service"
|
||||
|
||||
"github.com/google/wire"
|
||||
@@ -35,6 +35,7 @@ var HandlerSet = wire.NewSet(
|
||||
ProvideSystemMessageHandler,
|
||||
ProvideGroupHandler,
|
||||
ProvideScheduleHandler,
|
||||
ProvideWSHandler,
|
||||
)
|
||||
|
||||
// ProvideUserHandler 提供用户处理器
|
||||
@@ -91,9 +92,9 @@ func ProvideMessageHandler(
|
||||
messageService *service.MessageService,
|
||||
userService service.UserService,
|
||||
groupService service.GroupService,
|
||||
sseHub *sse.Hub,
|
||||
wsHub *ws.Hub,
|
||||
) *handler.MessageHandler {
|
||||
return handler.NewMessageHandler(chatService, messageService, userService, groupService, sseHub)
|
||||
return handler.NewMessageHandler(chatService, messageService, userService, groupService, wsHub)
|
||||
}
|
||||
|
||||
// ProvideSystemMessageHandler 提供系统消息处理器
|
||||
@@ -118,3 +119,13 @@ func ProvideScheduleHandler(
|
||||
) *handler.ScheduleHandler {
|
||||
return handler.NewScheduleHandler(scheduleService, scheduleSyncService)
|
||||
}
|
||||
|
||||
// ProvideWSHandler 提供WebSocket处理器
|
||||
func ProvideWSHandler(
|
||||
wsHub *ws.Hub,
|
||||
chatService service.ChatService,
|
||||
groupService service.GroupService,
|
||||
jwtService *service.JWTService,
|
||||
) *handler.WSHandler {
|
||||
return handler.NewWSHandler(wsHub, chatService, groupService, jwtService)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"carrot_bbs/internal/pkg/openai"
|
||||
"carrot_bbs/internal/pkg/redis"
|
||||
"carrot_bbs/internal/pkg/s3"
|
||||
"carrot_bbs/internal/pkg/sse"
|
||||
"carrot_bbs/internal/pkg/ws"
|
||||
"carrot_bbs/internal/repository"
|
||||
"carrot_bbs/internal/service"
|
||||
|
||||
@@ -44,8 +44,8 @@ var InfrastructureSet = wire.NewSet(
|
||||
ProvideBuiltinHooks,
|
||||
ProvideModerationHooks,
|
||||
|
||||
// SSE Hub
|
||||
ProvideSSEHub,
|
||||
// WebSocket Hub
|
||||
ProvideWSHub,
|
||||
|
||||
// 外部服务客户端
|
||||
ProvideOpenAIClient,
|
||||
@@ -156,9 +156,9 @@ func ProvideModerationHooks(
|
||||
return moderationHooks
|
||||
}
|
||||
|
||||
// ProvideSSEHub 提供 SSE Hub
|
||||
func ProvideSSEHub() *sse.Hub {
|
||||
return sse.NewHub()
|
||||
// ProvideWSHub 提供 WebSocket Hub
|
||||
func ProvideWSHub() *ws.Hub {
|
||||
return ws.NewHub()
|
||||
}
|
||||
|
||||
// ProvideOpenAIClient 提供 OpenAI 客户端
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"carrot_bbs/internal/pkg/openai"
|
||||
"carrot_bbs/internal/pkg/redis"
|
||||
"carrot_bbs/internal/pkg/s3"
|
||||
"carrot_bbs/internal/pkg/sse"
|
||||
"carrot_bbs/internal/pkg/ws"
|
||||
"carrot_bbs/internal/repository"
|
||||
"carrot_bbs/internal/service"
|
||||
|
||||
@@ -89,9 +89,9 @@ func ProvidePushService(
|
||||
pushRepo repository.PushRecordRepository,
|
||||
deviceTokenRepo repository.DeviceTokenRepository,
|
||||
messageRepo repository.MessageRepository,
|
||||
sseHub *sse.Hub,
|
||||
wsHub *ws.Hub,
|
||||
) service.PushService {
|
||||
return service.NewPushService(pushRepo, deviceTokenRepo, messageRepo, sseHub)
|
||||
return service.NewPushService(pushRepo, deviceTokenRepo, messageRepo, wsHub)
|
||||
}
|
||||
|
||||
// ProvideSystemMessageService 提供系统消息服务
|
||||
@@ -183,11 +183,11 @@ func ProvideChannelService(channelRepo repository.ChannelRepository, cacheBacken
|
||||
func ProvideChatService(
|
||||
messageRepo repository.MessageRepository,
|
||||
userRepo repository.UserRepository,
|
||||
sseHub *sse.Hub,
|
||||
wsHub *ws.Hub,
|
||||
cacheBackend cache.Cache,
|
||||
uploadService *service.UploadService,
|
||||
) service.ChatService {
|
||||
return service.NewChatService(messageRepo, userRepo, nil, sseHub, cacheBackend, uploadService)
|
||||
return service.NewChatService(messageRepo, userRepo, nil, wsHub, cacheBackend, uploadService)
|
||||
}
|
||||
|
||||
// ProvideScheduleService 提供日程服务
|
||||
@@ -213,10 +213,10 @@ func ProvideGroupService(
|
||||
messageRepo repository.MessageRepository,
|
||||
requestRepo repository.GroupJoinRequestRepository,
|
||||
notifyRepo repository.SystemNotificationRepository,
|
||||
sseHub *sse.Hub,
|
||||
wsHub *ws.Hub,
|
||||
cacheBackend cache.Cache,
|
||||
) service.GroupService {
|
||||
return service.NewGroupService(groupRepo, userRepo, messageRepo, requestRepo, notifyRepo, sseHub, cacheBackend)
|
||||
return service.NewGroupService(groupRepo, userRepo, messageRepo, requestRepo, notifyRepo, wsHub, cacheBackend)
|
||||
}
|
||||
|
||||
// ProvideUploadService 提供上传服务
|
||||
@@ -359,13 +359,13 @@ func ProvideHotRankWorker(cfg *config.Config, postRepo repository.PostRepository
|
||||
|
||||
func ProvideQRCodeLoginService(
|
||||
redisClient *redis.Client,
|
||||
sseHub *sse.Hub,
|
||||
wsHub *ws.Hub,
|
||||
jwtService *service.JWTService,
|
||||
userService service.UserService,
|
||||
activityService service.UserActivityService,
|
||||
logService *service.LogService,
|
||||
) *service.QRCodeLoginService {
|
||||
return service.NewQRCodeLoginService(redisClient, sseHub, jwtService, userService, activityService, logService)
|
||||
return service.NewQRCodeLoginService(redisClient, wsHub, jwtService, userService, activityService, logService)
|
||||
}
|
||||
|
||||
// ProvideMaterialService 提供学习资料服务
|
||||
|
||||
Reference in New Issue
Block a user