- 新增 POST /api/auth/websocket-ticket:需 JWT 与公司头,签发 30 秒有效的一次性随机票据 - /ws 握手消费票据并以数据库当前状态复核身份与公司授权,JWT 不进 URL - Hub 注册/注销/投递选人统一由事件循环串行管理,消除 RLock 内 delete 的并发缺陷 - 事件携带 organization_id,仅投递同公司订阅者,投递前复核失败即关闭连接 - 统一广播签名为 (organizationID, action, orderID, data) - 新增前端 companySocket 模块:按当前公司取票建连、切换/登出关旧连重连、消息校验组织与上下文版本
123 lines
3.9 KiB
Go
123 lines
3.9 KiB
Go
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 }
|
||
}
|