material_manage_system/internal/service/websocket_ticket.go
文睿 张 5021b039c9 feat: 按公司隔离 WebSocket 订阅与事件投递
- 新增 POST /api/auth/websocket-ticket:需 JWT 与公司头,签发 30 秒有效的一次性随机票据
- /ws 握手消费票据并以数据库当前状态复核身份与公司授权,JWT 不进 URL
- Hub 注册/注销/投递选人统一由事件循环串行管理,消除 RLock 内 delete 的并发缺陷
- 事件携带 organization_id,仅投递同公司订阅者,投递前复核失败即关闭连接
- 统一广播签名为 (organizationID, action, orderID, data)
- 新增前端 companySocket 模块:按当前公司取票建连、切换/登出关旧连重连、消息校验组织与上下文版本
2026-10-09 13:39:45 +08:00

123 lines
3.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"crypto/rand"
"encoding/hex"
"errors"
"sync"
"time"
)
// WebSocket 票据默认有效期:短期、一次性,把 /ws 握手从长期 JWT 中解耦。
// 浏览器不能把 JWT 放 URL,也不能仅凭 URL 里的公司 ID 订阅,所以先用受保护的接口
// 换取一张短暂票据,再凭它建连。
const WebSocketTicketTTL = 30 * time.Second
var (
// ErrWebSocketTicketInvalid 表示票据不存在、格式非法或已被消费(重放)。
ErrWebSocketTicketInvalid = errors.New("websocket ticket invalid or already used")
// ErrWebSocketTicketExpired 表示票据已超过有效期。
ErrWebSocketTicketExpired = errors.New("websocket ticket expired")
)
// WebSocketTicket 绑定一次握手所需的身份与公司:票据 ID 本身即一次性随机标识,
// 连同 userID、organizationID 与签发时间一起记录。本系统当前没有会话 ID,
// 因此用“随机票据 + 签发时间 + 数据库实时复核”共同承担“会话标识”的职责。
type WebSocketTicket struct {
Ticket string
UserID int64
OrganizationID int64
IssuedAt time.Time
ExpiresAt time.Time
}
// WebSocketTicketService 维护未消费的票据。票据只驻留内存,进程重启即全部失效。
type WebSocketTicketService struct {
ttl time.Duration
now func() time.Time
mu sync.Mutex
tickets map[string]*WebSocketTicket
}
// NewWebSocketTicketService 构造票据服务。ttl 可选,测试用它构造“已过期”票据,
// 不传则使用默认 30 秒。
func NewWebSocketTicketService(ttl ...time.Duration) *WebSocketTicketService {
effective := WebSocketTicketTTL
if len(ttl) > 0 {
effective = ttl[0]
}
return &WebSocketTicketService{
ttl: effective,
now: time.Now,
tickets: make(map[string]*WebSocketTicket),
}
}
// Issue 为指定用户与公司签发一张一次性票据。
func (s *WebSocketTicketService) Issue(userID, organizationID int64) (*WebSocketTicket, error) {
if userID <= 0 || organizationID <= 0 {
return nil, ErrInvalidOrganizationID
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return nil, err
}
now := s.now()
ticket := &WebSocketTicket{
Ticket: hex.EncodeToString(raw),
UserID: userID,
OrganizationID: organizationID,
IssuedAt: now,
ExpiresAt: now.Add(s.ttl),
}
s.mu.Lock()
defer s.mu.Unlock()
// 顺手清理已过期票据,避免长期运行后 map 无限增长。
s.purgeExpiredLocked(now)
s.tickets[ticket.Ticket] = ticket
return ticket, nil
}
// Consume 校验并一次性消费票据。无论是否过期都先删除:过期票据、已消费票据
// 和重放请求都无法再次连接。返回的票据已通过有效期检查。
func (s *WebSocketTicketService) Consume(value string) (*WebSocketTicket, error) {
if value == "" {
return nil, ErrWebSocketTicketInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
ticket, ok := s.tickets[value]
if !ok {
return nil, ErrWebSocketTicketInvalid
}
delete(s.tickets, value)
if s.now().After(ticket.ExpiresAt) {
return nil, ErrWebSocketTicketExpired
}
return ticket, nil
}
func (s *WebSocketTicketService) purgeExpiredLocked(now time.Time) {
for key, ticket := range s.tickets {
if now.After(ticket.ExpiresAt) {
delete(s.tickets, key)
}
}
}
// defaultTicketService 是进程内共享的票据服务,签发接口与握手共用同一实例。
var defaultTicketService = NewWebSocketTicketService()
// GetWebSocketTicketService 返回共享票据服务。
func GetWebSocketTicketService() *WebSocketTicketService {
return defaultTicketService
}
// SetDefaultWebSocketTicketService 仅供测试替换共享票据服务,返回恢复函数。
// 生产代码请使用 GetWebSocketTicketService。
func SetDefaultWebSocketTicketService(s *WebSocketTicketService) func() {
previous := defaultTicketService
defaultTicketService = s
return func() { defaultTicketService = previous }
}