NestJS (6 services): implement @RequirePermission decorator with SetMetadata+Reflector, register APP_GUARD globally, fix as assertions to type guards, add explicit return types, fix import type for express, fix /metrics implicit any, replace native Error with ApplicationError, remove typeorm remnants, register LifecycleService. teacher-bff: add logger, ApplicationError, GlobalErrorFilter, forward real userId to downstream, log downstream failures, migrate health controller to shared/health. Go (2 services): interface to any, doc comments, CORS dev whitelist, JWT secret fail-fast, push-gateway internal API auth, metrics and readyz endpoints, remove dead code. Python (2 services): lifespan return type, dev_mode to bool, data-ana APIRouter, ai POST body model, ClickHouse async wrapping.
210 lines
5.7 KiB
Go
210 lines
5.7 KiB
Go
package ws
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"log"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"github.com/edu-cloud/push-gateway/internal/hub"
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/golang-jwt/jwt/v5"
|
||
"github.com/gorilla/websocket"
|
||
)
|
||
|
||
var (
|
||
errMissingToken = errors.New("missing token")
|
||
errInvalidToken = errors.New("invalid token")
|
||
errInvalidClaims = errors.New("invalid claims")
|
||
errMissingUserID = errors.New("missing user id")
|
||
)
|
||
|
||
var upgrader = websocket.Upgrader{
|
||
CheckOrigin: func(r *http.Request) bool {
|
||
return true // P5 骨架,生产环境需校验 origin
|
||
},
|
||
}
|
||
|
||
// internalAPIKeyHeader 是内部 API 鉴权请求头名称
|
||
const internalAPIKeyHeader = "X-Internal-Key"
|
||
|
||
// Handler 处理 WebSocket 升级与内部推送 API
|
||
type Handler struct {
|
||
hub *hub.Hub
|
||
jwtSecret string
|
||
devMode bool
|
||
internalAPIKey string
|
||
}
|
||
|
||
// NewHandler 创建 Handler 实例
|
||
func NewHandler(h *hub.Hub, jwtSecret string, devMode bool, internalAPIKey string) *Handler {
|
||
return &Handler{hub: h, jwtSecret: jwtSecret, devMode: devMode, internalAPIKey: internalAPIKey}
|
||
}
|
||
|
||
// HandleWebSocket 升级 HTTP 为 WebSocket,保持长连接并处理心跳
|
||
func (h *Handler) HandleWebSocket(c *gin.Context) {
|
||
userID, err := h.authenticate(c)
|
||
if err != nil {
|
||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||
if err != nil {
|
||
log.Printf("[ws] upgrade failed, user=%s: %v", userID, err)
|
||
return
|
||
}
|
||
defer conn.Close()
|
||
|
||
connPtr := h.hub.Register(userID, conn)
|
||
defer h.hub.Unregister(connPtr)
|
||
|
||
// 写协程:从 send chan 读取并写入 WebSocket,避免并发写
|
||
go func() {
|
||
for msg := range connPtr.Outgoing() {
|
||
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||
log.Printf("[ws] write failed, user=%s: %v", userID, err)
|
||
return
|
||
}
|
||
}
|
||
}()
|
||
|
||
// 读取循环(保持连接,处理心跳)
|
||
for {
|
||
_, msg, err := conn.ReadMessage()
|
||
if err != nil {
|
||
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
||
log.Printf("[ws] read failed, user=%s: %v", userID, err)
|
||
}
|
||
break
|
||
}
|
||
if strings.ToLower(string(msg)) == "ping" {
|
||
connPtr.Send([]byte("pong"))
|
||
}
|
||
}
|
||
}
|
||
|
||
// PushHandler 接收来自 Msg 服务的定向推送请求
|
||
func (h *Handler) PushHandler(c *gin.Context) {
|
||
if !h.checkInternalAPIKey(c) {
|
||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||
"success": false,
|
||
"error": gin.H{"code": "UNAUTHORIZED", "message": "invalid or missing internal api key"},
|
||
})
|
||
return
|
||
}
|
||
|
||
var req struct {
|
||
UserID string `json:"userId"`
|
||
Event string `json:"event"`
|
||
Data map[string]any `json:"data"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": gin.H{"code": "INVALID_REQUEST", "message": err.Error()}})
|
||
return
|
||
}
|
||
|
||
message, err := buildMessage(req.Event, req.Data)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": gin.H{"code": "INVALID_PAYLOAD", "message": err.Error()}})
|
||
return
|
||
}
|
||
|
||
if err := h.hub.SendToUser(req.UserID, message); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": gin.H{"code": "PUSH_FAILED", "message": err.Error()}})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||
}
|
||
|
||
// BroadcastHandler 接收来自 Msg 服务的广播请求
|
||
func (h *Handler) BroadcastHandler(c *gin.Context) {
|
||
if !h.checkInternalAPIKey(c) {
|
||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||
"success": false,
|
||
"error": gin.H{"code": "UNAUTHORIZED", "message": "invalid or missing internal api key"},
|
||
})
|
||
return
|
||
}
|
||
|
||
var req struct {
|
||
Event string `json:"event"`
|
||
Data map[string]any `json:"data"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": gin.H{"code": "INVALID_REQUEST", "message": err.Error()}})
|
||
return
|
||
}
|
||
|
||
message, err := buildMessage(req.Event, req.Data)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": gin.H{"code": "INVALID_PAYLOAD", "message": err.Error()}})
|
||
return
|
||
}
|
||
|
||
h.hub.Broadcast(message)
|
||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||
}
|
||
|
||
// checkInternalAPIKey 校验内部 API 请求头 X-Internal-Key。
|
||
// DevMode 下跳过校验;非 DevMode 下要求 INTERNAL_API_KEY 已配置且与请求头匹配。
|
||
func (h *Handler) checkInternalAPIKey(c *gin.Context) bool {
|
||
if h.devMode {
|
||
return true
|
||
}
|
||
if h.internalAPIKey == "" {
|
||
log.Println("[ws] internal api key not configured, rejecting request")
|
||
return false
|
||
}
|
||
return c.GetHeader(internalAPIKeyHeader) == h.internalAPIKey
|
||
}
|
||
|
||
// authenticate 校验 WebSocket 连接的 JWT 或 dev token
|
||
func (h *Handler) authenticate(c *gin.Context) (string, error) {
|
||
tokenStr := c.Query("token")
|
||
if tokenStr == "" {
|
||
if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") {
|
||
tokenStr = strings.TrimPrefix(auth, "Bearer ")
|
||
}
|
||
}
|
||
if tokenStr == "" {
|
||
return "", errMissingToken
|
||
}
|
||
|
||
// DEV_MODE 下接受 dev-token,便于本地联调
|
||
if h.devMode && tokenStr == "dev-token" {
|
||
return "dev-user", nil
|
||
}
|
||
|
||
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
|
||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||
return nil, jwt.ErrSignatureInvalid
|
||
}
|
||
return []byte(h.jwtSecret), nil
|
||
})
|
||
if err != nil || !token.Valid {
|
||
return "", errInvalidToken
|
||
}
|
||
|
||
claims, ok := token.Claims.(jwt.MapClaims)
|
||
if !ok {
|
||
return "", errInvalidClaims
|
||
}
|
||
|
||
userID, ok := claims["sub"].(string)
|
||
if !ok {
|
||
return "", errMissingUserID
|
||
}
|
||
return userID, nil
|
||
}
|
||
|
||
// buildMessage 构造推送 JSON 消息体
|
||
func buildMessage(event string, data map[string]any) ([]byte, error) {
|
||
return json.Marshal(map[string]any{
|
||
"event": event,
|
||
"data": data,
|
||
})
|
||
}
|