chore: sync workspace changes
This commit is contained in:
+174
-8
@@ -54,8 +54,10 @@ jobs:
|
||||
file: ./api/Dockerfile
|
||||
push: true
|
||||
build-args: |
|
||||
PYTHON_BASE_IMAGE=${{ vars.PYTHON_BASE_IMAGE || 'docker.m.daocloud.io/library/python:3.11-slim' }}
|
||||
PIP_INDEX_URL=${{ secrets.PIP_INDEX_URL || vars.PIP_INDEX_URL || 'https://pypi.org/simple' }}
|
||||
PIP_DEFAULT_TIMEOUT=${{ vars.PIP_DEFAULT_TIMEOUT || '120' }}
|
||||
PIP_DEFAULT_TIMEOUT=${{ vars.PIP_DEFAULT_TIMEOUT || '300' }}
|
||||
PIP_RETRIES=${{ vars.PIP_RETRIES || '20' }}
|
||||
tags: |
|
||||
${{ steps.vars.outputs.api_image }}:${{ steps.vars.outputs.image_tag }}
|
||||
${{ steps.vars.outputs.api_image }}:latest
|
||||
@@ -69,7 +71,8 @@ jobs:
|
||||
file: ./web/Dockerfile
|
||||
push: true
|
||||
build-args: |
|
||||
NEXT_PUBLIC_API_BASE_URL=${{ vars.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:8000' }}
|
||||
NODE_BASE_IMAGE=${{ vars.NODE_BASE_IMAGE || 'docker.m.daocloud.io/library/node:22-alpine' }}
|
||||
NEXT_PUBLIC_API_BASE_URL=${{ vars.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000' }}
|
||||
tags: |
|
||||
${{ steps.vars.outputs.web_image }}:${{ steps.vars.outputs.image_tag }}
|
||||
${{ steps.vars.outputs.web_image }}:latest
|
||||
@@ -103,7 +106,7 @@ jobs:
|
||||
API_IMAGE: ${{ needs.build-and-push.outputs.api_image }}
|
||||
WEB_IMAGE: ${{ needs.build-and-push.outputs.web_image }}
|
||||
IMAGE_TAG: ${{ needs.build-and-push.outputs.image_tag }}
|
||||
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:8000' }}
|
||||
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000' }}
|
||||
GHCR_USERNAME: ${{ github.actor }}
|
||||
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
@@ -145,20 +148,88 @@ jobs:
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: ${REDIS_IMAGE:-docker.m.daocloud.io/library/redis:7-alpine}
|
||||
container_name: fquiz-redis
|
||||
command: redis-server --appendonly yes
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
volumes:
|
||||
- fquiz_redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
restart: unless-stopped
|
||||
|
||||
minio:
|
||||
image: ${MINIO_IMAGE:-minio/minio:latest}
|
||||
container_name: fquiz-minio
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin}
|
||||
ports:
|
||||
- "${MINIO_API_PORT:-9000}:9000"
|
||||
- "${MINIO_CONSOLE_PORT:-9001}:9001"
|
||||
volumes:
|
||||
- fquiz_minio_data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
minio-init:
|
||||
image: ${MINIO_MC_IMAGE:-minio/mc:latest}
|
||||
container_name: fquiz-minio-init
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_started
|
||||
environment:
|
||||
MINIO_ENDPOINT: ${MINIO_ENDPOINT:-http://minio:9000}
|
||||
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin}
|
||||
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin}
|
||||
MINIO_BUCKET: ${MINIO_BUCKET:-fquiz-files}
|
||||
entrypoint: /bin/sh
|
||||
command:
|
||||
- -c
|
||||
- >
|
||||
until mc alias set local "$MINIO_ENDPOINT" "$MINIO_ACCESS_KEY" "$MINIO_SECRET_KEY"; do
|
||||
sleep 1;
|
||||
done;
|
||||
mc mb -p "local/$MINIO_BUCKET" || true;
|
||||
restart: "no"
|
||||
|
||||
api:
|
||||
image: ${API_IMAGE}
|
||||
container_name: fquiz-api
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_started
|
||||
minio-init:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
API_HOST: ${API_HOST:-0.0.0.0}
|
||||
API_PORT: ${API_PORT:-8000}
|
||||
API_CORS_ORIGINS: ${API_CORS_ORIGINS:-http://localhost:3000,http://127.0.0.1:3000}
|
||||
API_CORS_ORIGIN_REGEX: ${API_CORS_ORIGIN_REGEX:-}
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://fquiz:fquiz@db:5432/fquiz}
|
||||
DATABASE_URL: ${DATABASE_URL:-}
|
||||
DB_HOST: ${DB_HOST:-db}
|
||||
DB_PORT: ${DB_PORT:-5432}
|
||||
DB_NAME: ${DB_NAME:-postgres}
|
||||
DB_SCHEMA: ${DB_SCHEMA:-public}
|
||||
DB_USERNAME: ${DB_USERNAME:-fquiz}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-fquiz}
|
||||
FILE_VFS_ROOT: ${FILE_VFS_ROOT:-./data/vfs}
|
||||
MINIO_ENABLED: ${MINIO_ENABLED:-true}
|
||||
MINIO_ENDPOINT: ${MINIO_ENDPOINT:-http://minio:9000}
|
||||
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin}
|
||||
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin}
|
||||
MINIO_BUCKET: ${MINIO_BUCKET:-fquiz-files}
|
||||
MINIO_REGION: ${MINIO_REGION:-us-east-1}
|
||||
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-this-in-production}
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-15}
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-480}
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-30}
|
||||
REFRESH_COOKIE_SECURE: ${REFRESH_COOKIE_SECURE:-false}
|
||||
REFRESH_COOKIE_SAMESITE: ${REFRESH_COOKIE_SAMESITE:-lax}
|
||||
@@ -166,6 +237,14 @@ jobs:
|
||||
LLM_REQUEST_TIMEOUT_SECONDS: ${LLM_REQUEST_TIMEOUT_SECONDS:-60}
|
||||
CHAT_CONTEXT_MESSAGE_LIMIT: ${CHAT_CONTEXT_MESSAGE_LIMIT:-12}
|
||||
CHAT_DEFAULT_SYSTEM_PROMPT: ${CHAT_DEFAULT_SYSTEM_PROMPT:-You are a helpful assistant.}
|
||||
CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://redis:6379/0}
|
||||
CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/1}
|
||||
CELERY_TIMEZONE: ${CELERY_TIMEZONE:-Asia/Shanghai}
|
||||
SCHEDULER_EXPIRE_INTERVAL_SECONDS: ${SCHEDULER_EXPIRE_INTERVAL_SECONDS:-60}
|
||||
WINE_BINARY_PATH: ${WINE_BINARY_PATH:-wine}
|
||||
WINE_ALLOWED_ROOT: ${WINE_ALLOWED_ROOT:-./data/wine}
|
||||
WINE_DEFAULT_TIMEOUT_SECONDS: ${WINE_DEFAULT_TIMEOUT_SECONDS:-300}
|
||||
WINE_MAX_TIMEOUT_SECONDS: ${WINE_MAX_TIMEOUT_SECONDS:-1800}
|
||||
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-admin@example.com}
|
||||
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin}
|
||||
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-change-me-strong-password}
|
||||
@@ -179,6 +258,64 @@ jobs:
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
celery-worker:
|
||||
image: ${API_IMAGE}
|
||||
container_name: fquiz-celery-worker
|
||||
command:
|
||||
- celery
|
||||
- -A
|
||||
- app.core.celery_app.celery_app
|
||||
- worker
|
||||
- --loglevel=${CELERY_LOG_LEVEL:-INFO}
|
||||
- --concurrency=${CELERY_WORKER_CONCURRENCY:-2}
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL:-}
|
||||
DB_HOST: ${DB_HOST:-db}
|
||||
DB_PORT: ${DB_PORT:-5432}
|
||||
DB_NAME: ${DB_NAME:-postgres}
|
||||
DB_SCHEMA: ${DB_SCHEMA:-public}
|
||||
DB_USERNAME: ${DB_USERNAME:-fquiz}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-fquiz}
|
||||
CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://redis:6379/0}
|
||||
CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/1}
|
||||
CELERY_TIMEZONE: ${CELERY_TIMEZONE:-Asia/Shanghai}
|
||||
SCHEDULER_EXPIRE_INTERVAL_SECONDS: ${SCHEDULER_EXPIRE_INTERVAL_SECONDS:-60}
|
||||
restart: unless-stopped
|
||||
|
||||
celery-beat:
|
||||
image: ${API_IMAGE}
|
||||
container_name: fquiz-celery-beat
|
||||
command:
|
||||
- celery
|
||||
- -A
|
||||
- app.core.celery_app.celery_app
|
||||
- beat
|
||||
- --loglevel=${CELERY_LOG_LEVEL:-INFO}
|
||||
- --schedule=/tmp/celerybeat-schedule
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL:-}
|
||||
DB_HOST: ${DB_HOST:-db}
|
||||
DB_PORT: ${DB_PORT:-5432}
|
||||
DB_NAME: ${DB_NAME:-postgres}
|
||||
DB_SCHEMA: ${DB_SCHEMA:-public}
|
||||
DB_USERNAME: ${DB_USERNAME:-fquiz}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-fquiz}
|
||||
CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://redis:6379/0}
|
||||
CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/1}
|
||||
CELERY_TIMEZONE: ${CELERY_TIMEZONE:-Asia/Shanghai}
|
||||
SCHEDULER_EXPIRE_INTERVAL_SECONDS: ${SCHEDULER_EXPIRE_INTERVAL_SECONDS:-60}
|
||||
restart: unless-stopped
|
||||
|
||||
web:
|
||||
image: ${WEB_IMAGE}
|
||||
container_name: fquiz-web
|
||||
@@ -194,18 +331,33 @@ jobs:
|
||||
|
||||
volumes:
|
||||
fquiz_db_data:
|
||||
fquiz_redis_data:
|
||||
fquiz_minio_data:
|
||||
YAML
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
cat > .env <<'ENV'
|
||||
NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8000
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
API_HOST=0.0.0.0
|
||||
API_PORT=8000
|
||||
API_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||
API_CORS_ORIGIN_REGEX=
|
||||
DATABASE_URL=postgresql+psycopg://fquiz:fquiz@db:5432/fquiz
|
||||
DATABASE_URL=
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
DB_NAME=postgres
|
||||
DB_SCHEMA=public
|
||||
DB_USERNAME=fquiz
|
||||
DB_PASSWORD=fquiz
|
||||
FILE_VFS_ROOT=./data/vfs
|
||||
MINIO_ENABLED=true
|
||||
MINIO_ENDPOINT=http://minio:9000
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
MINIO_BUCKET=fquiz-files
|
||||
MINIO_REGION=us-east-1
|
||||
JWT_SECRET_KEY=change-this-in-production
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=480
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||
REFRESH_COOKIE_SECURE=false
|
||||
REFRESH_COOKIE_SAMESITE=lax
|
||||
@@ -213,6 +365,14 @@ jobs:
|
||||
LLM_REQUEST_TIMEOUT_SECONDS=60
|
||||
CHAT_CONTEXT_MESSAGE_LIMIT=12
|
||||
CHAT_DEFAULT_SYSTEM_PROMPT=You are a helpful assistant.
|
||||
CELERY_BROKER_URL=redis://redis:6379/0
|
||||
CELERY_RESULT_BACKEND=redis://redis:6379/1
|
||||
CELERY_TIMEZONE=Asia/Shanghai
|
||||
SCHEDULER_EXPIRE_INTERVAL_SECONDS=60
|
||||
WINE_BINARY_PATH=wine
|
||||
WINE_ALLOWED_ROOT=./data/wine
|
||||
WINE_DEFAULT_TIMEOUT_SECONDS=300
|
||||
WINE_MAX_TIMEOUT_SECONDS=1800
|
||||
INITIAL_ADMIN_EMAIL=admin@example.com
|
||||
INITIAL_ADMIN_USERNAME=admin
|
||||
INITIAL_ADMIN_PASSWORD=change-me-strong-password
|
||||
@@ -221,6 +381,12 @@ jobs:
|
||||
POSTGRES_PASSWORD=fquiz
|
||||
POSTGRES_PORT=5433
|
||||
POSTGRES_IMAGE=docker.m.daocloud.io/pgvector/pgvector:pg16
|
||||
REDIS_IMAGE=docker.m.daocloud.io/library/redis:7-alpine
|
||||
REDIS_PORT=6379
|
||||
MINIO_IMAGE=minio/minio:latest
|
||||
MINIO_MC_IMAGE=minio/mc:latest
|
||||
MINIO_API_PORT=9000
|
||||
MINIO_CONSOLE_PORT=9001
|
||||
ENV
|
||||
echo "[warn] .env 不存在,已写入默认模板,请尽快改成生产配置。"
|
||||
fi
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
- 宿主机 DB 暴露端口统一走 `POSTGRES_PORT`(默认 `5433`),用于规避与宿主机已有 PostgreSQL(常见 `5432`)冲突;容器内连接仍保持 `db:5432`。
|
||||
- CORS 来源控制采用“双轨配置”:`API_CORS_ORIGINS`(精确列表)+ `API_CORS_ORIGIN_REGEX`(正则,可选);`API_CORS_ORIGINS` 支持 `*` 和通配符域名并在后端转换为 `allow_origin_regex`。
|
||||
- GitHub Actions 使用 `appleboy/ssh-action` 部署时,慢网环境需显式设置 `command_timeout`(建议 `45m`)并为 `docker compose pull` 增加重试,避免出现 `Run Command Timeout` 直接中断发布。
|
||||
- GitHub Actions 的部署编排需与仓库 `docker-compose.yml` 服务拓扑保持一致,至少包含 `db/api/web/redis/minio/minio-init/celery-worker/celery-beat`;避免在 workflow 内维护“精简版 compose”导致运行时能力缺失(如 Celery/MinIO)。
|
||||
- `docker compose up -d` 不会重建 `build` 类型服务镜像;本项目 `web` 无源码挂载且运行 Next.js 生产构建产物,前端代码变更后需执行 `docker compose up --build -d web`(必要时先 `docker compose build --no-cache web`)。
|
||||
- `api` 构建若在拉取 `docker.m.daocloud.io/library/python:3.11-slim` 时出现 manifest `EOF`,优先重试 `docker compose build api`;若持续失败,可在 `.env` 覆盖 `PYTHON_BASE_IMAGE=python:3.11-slim` 走 Docker Hub 兜底。
|
||||
|
||||
@@ -214,6 +215,8 @@
|
||||
- 后台表格行内“操作”入口推荐统一为下拉菜单形态,优先复用 `web/src/components/row-action-menu.tsx`,避免页面内重复堆叠小按钮并降低操作列宽度波动。
|
||||
- Phase B 样板页已落地:`/admin/users`、`/admin/requirements`、`/admin/menus`;后续页面迁移默认保持“业务逻辑不动,仅替换操作入口承载组件”的最小改动策略。
|
||||
- 后台左侧导航默认不展示“系统菜单”标题与底部“当前角色/账号状态”文案,避免重复信息占用导航空间(移动端抽屉同样不显示该标题)。
|
||||
- 后台左侧导航收缩按钮固定在侧栏左下角(桌面端),不再放在顶部 Header;移动端仍保留顶部“打开菜单”按钮。
|
||||
- 后台菜单项图标渲染口径:优先使用后端返回 `menu.icon` 映射,其次按 `menu.path` 回退,最终使用目录/叶子通用图标兜底,确保菜单无空图标。
|
||||
|
||||
## 数据库连接口径(2026-04-23)
|
||||
|
||||
@@ -249,6 +252,7 @@
|
||||
- 新增页面如需 AntD 高级能力,可直接引入 `antd`,但需保持与现有主题和交互风格一致。
|
||||
- 兼容说明:`web/src/types/antd.d.ts` 仅保留 `antd/dist/reset.css` 声明,禁止再写 `declare module "antd"`;否则会覆盖官方类型并导致 `Form.useForm<T>` 等泛型调用在 `next build` 的 TypeScript 阶段失败。
|
||||
- `web/src/components/ui-antd.tsx` 作为兼容层时,若自定义 `type/variant/size/checked` 等语义,必须先 `Omit` 掉对应 AntD 原生同名字段再重定义,否则会触发联合类型冲突并阻断 Docker 构建。
|
||||
- `web/src/app/admin/page.tsx`(后台首页)已采用 AntD 标准化信息布局:统计卡片 + `Segmented` 分组筛选 + `Card.Meta` 模块卡片;权限判断与路由不变,筛选默认值固定 `all` 并在权限变化后自动回落。
|
||||
|
||||
## 需求管理兼容口径(2026-04-22)
|
||||
|
||||
@@ -859,6 +863,19 @@
|
||||
- 后台壳层 `web/src/app/admin/layout.tsx` 不再渲染内容区顶部公共信息块(Breadcrumb + 页面标题 + 页面描述)。
|
||||
- 后台页面默认直接进入业务内容区,避免在每个页面重复展示“模块标题 + 描述文案”。
|
||||
|
||||
## WebSocket STOMP 口径(2026-04-26)
|
||||
|
||||
- 实时推送链路已升级为“双协议并存”:
|
||||
- 旧协议:`/api/v1/ws`(JSON 消息)。
|
||||
- 新协议:`/api/v1/ws/stomp`(STOMP over WebSocket)。
|
||||
- 鉴权口径不变:两条链路均通过 `POST /api/v1/ws/ticket` 获取一次性 ticket,连接时携带 `?ticket=...`。
|
||||
- 前端默认连接已切换到 STOMP:
|
||||
- `web/src/components/ws-provider.tsx` 走 STOMP 握手与 `SUBSCRIBE/UNSUBSCRIBE`。
|
||||
- 业务侧 `useTopicSubscription` API 不变。
|
||||
- 后端推送入口口径不变:
|
||||
- 继续通过 `api/app/services/push_service.py` -> `ws_connection_manager.publish*` 广播事件;
|
||||
- 管理器内部按连接协议分别编码为 JSON 或 STOMP `MESSAGE` 帧。
|
||||
|
||||
## ATP 模型管理口径(2026-04-26)
|
||||
|
||||
- ATP 功能一期定位为“ATPDraw 产物版本管理 + ATP 引擎调用”:
|
||||
|
||||
+202
-9
@@ -1,11 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status
|
||||
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...core.dependencies import CurrentUser, get_current_user
|
||||
from ...services.legacy_authz_service import get_user_authorization, is_user_enabled
|
||||
from ...services.stomp_protocol import (
|
||||
STOMP_SUBPROTOCOLS,
|
||||
build_stomp_frame,
|
||||
destination_to_topic,
|
||||
parse_stomp_frames,
|
||||
select_stomp_version,
|
||||
)
|
||||
from ...services.topic_registry import get_auto_topics, validate_topic_subscription
|
||||
from ...services.user_service import get_user_by_id
|
||||
from ...services.ws_manager import ws_connection_manager
|
||||
@@ -23,27 +30,36 @@ def create_ws_ticket(
|
||||
return WsTicketResponse(ticket=ticket, expires_in=expires_in)
|
||||
|
||||
|
||||
@router.websocket("")
|
||||
async def websocket_endpoint(websocket: WebSocket, db: Session = Depends(get_db)) -> None:
|
||||
async def _authenticate_websocket(
|
||||
websocket: WebSocket,
|
||||
db: Session,
|
||||
) -> tuple[str, set[str], set[str]] | None:
|
||||
ticket = websocket.query_params.get("ticket")
|
||||
user_id = ws_ticket_service.consume(ticket)
|
||||
if not user_id:
|
||||
await websocket.close(code=4401, reason="invalid_ws_ticket")
|
||||
return
|
||||
return None
|
||||
|
||||
user = get_user_by_id(db, user_id)
|
||||
if not user or not is_user_enabled(user.status):
|
||||
await websocket.close(code=4403, reason="user_not_allowed")
|
||||
return
|
||||
return None
|
||||
|
||||
authz = get_user_authorization(db, user.id)
|
||||
role_codes = authz.role_codes
|
||||
permission_codes = authz.permission_codes
|
||||
return user.id, authz.role_codes, authz.permission_codes
|
||||
|
||||
|
||||
@router.websocket("")
|
||||
async def websocket_endpoint(websocket: WebSocket, db: Session = Depends(get_db)) -> None:
|
||||
auth_result = await _authenticate_websocket(websocket, db)
|
||||
if not auth_result:
|
||||
return
|
||||
user_id, role_codes, permission_codes = auth_result
|
||||
|
||||
await websocket.accept()
|
||||
connection = await ws_connection_manager.register(
|
||||
websocket,
|
||||
user_id=user.id,
|
||||
user_id=user_id,
|
||||
role_codes=role_codes,
|
||||
permission_codes=permission_codes,
|
||||
)
|
||||
@@ -52,7 +68,7 @@ async def websocket_endpoint(websocket: WebSocket, db: Session = Depends(get_db)
|
||||
{
|
||||
"type": "ready",
|
||||
"connection_id": connection.connection_id,
|
||||
"user_id": user.id,
|
||||
"user_id": user_id,
|
||||
"auto_topics": sorted(get_auto_topics()),
|
||||
}
|
||||
)
|
||||
@@ -107,3 +123,180 @@ async def websocket_endpoint(websocket: WebSocket, db: Session = Depends(get_db)
|
||||
pass
|
||||
finally:
|
||||
await ws_connection_manager.unregister(connection.connection_id)
|
||||
|
||||
|
||||
async def _accept_stomp_socket(websocket: WebSocket) -> None:
|
||||
offered = websocket.headers.get("sec-websocket-protocol", "")
|
||||
offered_set = {item.strip() for item in offered.split(",") if item.strip()}
|
||||
for protocol in STOMP_SUBPROTOCOLS:
|
||||
if protocol in offered_set:
|
||||
await websocket.accept(subprotocol=protocol)
|
||||
return
|
||||
await websocket.accept()
|
||||
|
||||
|
||||
async def _send_stomp_error(websocket: WebSocket, message: str, *, code: str | None = None) -> None:
|
||||
body = code if code else message
|
||||
await websocket.send_text(
|
||||
build_stomp_frame(
|
||||
"ERROR",
|
||||
headers={
|
||||
"message": message,
|
||||
"content-type": "text/plain",
|
||||
},
|
||||
body=body,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _send_stomp_receipt_if_requested(websocket: WebSocket, frame_headers: dict[str, str]) -> None:
|
||||
receipt_id = frame_headers.get("receipt")
|
||||
if not receipt_id:
|
||||
return
|
||||
await websocket.send_text(build_stomp_frame("RECEIPT", headers={"receipt-id": receipt_id}))
|
||||
|
||||
|
||||
@router.websocket("/stomp")
|
||||
async def websocket_stomp_endpoint(websocket: WebSocket, db: Session = Depends(get_db)) -> None:
|
||||
auth_result = await _authenticate_websocket(websocket, db)
|
||||
if not auth_result:
|
||||
return
|
||||
user_id, role_codes, permission_codes = auth_result
|
||||
|
||||
await _accept_stomp_socket(websocket)
|
||||
|
||||
connection = None
|
||||
connected = False
|
||||
|
||||
try:
|
||||
while True:
|
||||
raw_payload = await websocket.receive_text()
|
||||
try:
|
||||
frames = parse_stomp_frames(raw_payload)
|
||||
except ValueError as exc:
|
||||
await _send_stomp_error(websocket, "Invalid STOMP frame", code=str(exc))
|
||||
continue
|
||||
|
||||
for frame in frames:
|
||||
if not connected:
|
||||
if frame.command not in {"CONNECT", "STOMP"}:
|
||||
await _send_stomp_error(
|
||||
websocket,
|
||||
"First STOMP frame must be CONNECT",
|
||||
code="connect_required",
|
||||
)
|
||||
await websocket.close(code=1002, reason="connect_required")
|
||||
return
|
||||
|
||||
version = select_stomp_version(frame.headers.get("accept-version"))
|
||||
if not version:
|
||||
await _send_stomp_error(
|
||||
websocket,
|
||||
"Unsupported STOMP version",
|
||||
code="unsupported_version",
|
||||
)
|
||||
await websocket.close(code=1002, reason="unsupported_version")
|
||||
return
|
||||
|
||||
connection = await ws_connection_manager.register(
|
||||
websocket,
|
||||
user_id=user_id,
|
||||
role_codes=role_codes,
|
||||
permission_codes=permission_codes,
|
||||
protocol="stomp",
|
||||
)
|
||||
connected = True
|
||||
await websocket.send_text(
|
||||
build_stomp_frame(
|
||||
"CONNECTED",
|
||||
headers={
|
||||
"version": version,
|
||||
"session": connection.connection_id,
|
||||
"server": "fquiz-stomp/1.0",
|
||||
"heart-beat": "0,0",
|
||||
},
|
||||
)
|
||||
)
|
||||
await _send_stomp_receipt_if_requested(websocket, frame.headers)
|
||||
continue
|
||||
|
||||
if frame.command == "SUBSCRIBE":
|
||||
destination = frame.headers.get("destination", "")
|
||||
subscription_id = frame.headers.get("id", "").strip()
|
||||
topic = destination_to_topic(destination)
|
||||
if not topic or not subscription_id:
|
||||
await _send_stomp_error(
|
||||
websocket,
|
||||
"SUBSCRIBE requires destination and id",
|
||||
code="invalid_subscribe",
|
||||
)
|
||||
continue
|
||||
|
||||
is_allowed, reason = validate_topic_subscription(
|
||||
topic,
|
||||
role_codes=connection.role_codes,
|
||||
permission_codes=connection.permission_codes,
|
||||
)
|
||||
if not is_allowed:
|
||||
await _send_stomp_error(
|
||||
websocket,
|
||||
f"Subscription forbidden: {topic}",
|
||||
code=reason or "forbidden",
|
||||
)
|
||||
continue
|
||||
|
||||
await ws_connection_manager.subscribe(
|
||||
connection.connection_id,
|
||||
[topic],
|
||||
subscription_ids={topic: subscription_id},
|
||||
)
|
||||
await _send_stomp_receipt_if_requested(websocket, frame.headers)
|
||||
continue
|
||||
|
||||
if frame.command == "UNSUBSCRIBE":
|
||||
subscription_id = frame.headers.get("id", "").strip()
|
||||
destination = frame.headers.get("destination", "")
|
||||
if subscription_id:
|
||||
await ws_connection_manager.unsubscribe_by_subscription_id(
|
||||
connection.connection_id,
|
||||
subscription_id,
|
||||
)
|
||||
else:
|
||||
topic = destination_to_topic(destination)
|
||||
if topic:
|
||||
await ws_connection_manager.unsubscribe(connection.connection_id, [topic])
|
||||
await _send_stomp_receipt_if_requested(websocket, frame.headers)
|
||||
continue
|
||||
|
||||
if frame.command == "DISCONNECT":
|
||||
await _send_stomp_receipt_if_requested(websocket, frame.headers)
|
||||
await websocket.close(code=1000, reason="client_disconnect")
|
||||
return
|
||||
|
||||
if frame.command == "SEND":
|
||||
destination = frame.headers.get("destination", "")
|
||||
if destination not in {"", "/app/ping"}:
|
||||
await _send_stomp_error(
|
||||
websocket,
|
||||
f"SEND destination not supported: {destination}",
|
||||
code="unsupported_destination",
|
||||
)
|
||||
continue
|
||||
await _send_stomp_receipt_if_requested(websocket, frame.headers)
|
||||
continue
|
||||
|
||||
if frame.command in {"ACK", "NACK", "BEGIN", "COMMIT", "ABORT"}:
|
||||
await _send_stomp_receipt_if_requested(websocket, frame.headers)
|
||||
continue
|
||||
|
||||
await _send_stomp_error(
|
||||
websocket,
|
||||
f"Unsupported STOMP command: {frame.command}",
|
||||
code="unsupported_command",
|
||||
)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
if connection:
|
||||
await ws_connection_manager.unregister(connection.connection_id)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
SUPPORTED_STOMP_VERSIONS = ("1.2", "1.1", "1.0")
|
||||
STOMP_SUBPROTOCOLS = ("v12.stomp", "v11.stomp", "v10.stomp")
|
||||
TOPIC_DESTINATION_PREFIX = "/topic/"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StompFrame:
|
||||
command: str
|
||||
headers: dict[str, str]
|
||||
body: str = ""
|
||||
|
||||
|
||||
def select_stomp_version(accept_version_header: str | None) -> str | None:
|
||||
if not accept_version_header:
|
||||
return "1.0"
|
||||
requested = {version.strip() for version in accept_version_header.split(",") if version.strip()}
|
||||
for version in SUPPORTED_STOMP_VERSIONS:
|
||||
if version in requested:
|
||||
return version
|
||||
return None
|
||||
|
||||
|
||||
def topic_to_destination(topic: str) -> str:
|
||||
return f"{TOPIC_DESTINATION_PREFIX}{topic}"
|
||||
|
||||
|
||||
def destination_to_topic(destination: str) -> str | None:
|
||||
if destination.startswith(TOPIC_DESTINATION_PREFIX):
|
||||
topic = destination[len(TOPIC_DESTINATION_PREFIX):].strip()
|
||||
return topic if topic else None
|
||||
return None
|
||||
|
||||
|
||||
def build_stomp_frame(command: str, *, headers: dict[str, str] | None = None, body: str = "") -> str:
|
||||
lines = [command]
|
||||
for key, value in (headers or {}).items():
|
||||
lines.append(f"{_escape_header(key)}:{_escape_header(value)}")
|
||||
lines.append("")
|
||||
return "\n".join(lines) + body + "\x00"
|
||||
|
||||
|
||||
def parse_stomp_frames(payload: str) -> list[StompFrame]:
|
||||
frames: list[StompFrame] = []
|
||||
cursor = 0
|
||||
data = payload.replace("\r\n", "\n")
|
||||
|
||||
while cursor < len(data):
|
||||
while cursor < len(data) and data[cursor] == "\n":
|
||||
cursor += 1
|
||||
if cursor >= len(data):
|
||||
break
|
||||
|
||||
terminator = data.find("\x00", cursor)
|
||||
if terminator < 0:
|
||||
raise ValueError("frame_terminator_missing")
|
||||
|
||||
raw_frame = data[cursor:terminator]
|
||||
cursor = terminator + 1
|
||||
if not raw_frame:
|
||||
continue
|
||||
|
||||
frames.append(_parse_single_frame(raw_frame))
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
def _parse_single_frame(raw_frame: str) -> StompFrame:
|
||||
header_blob, has_body, body = raw_frame.partition("\n\n")
|
||||
header_lines = [line.rstrip("\r") for line in header_blob.split("\n")]
|
||||
if not header_lines:
|
||||
raise ValueError("missing_command")
|
||||
|
||||
command = header_lines[0].strip().upper()
|
||||
if not command:
|
||||
raise ValueError("missing_command")
|
||||
|
||||
headers: dict[str, str] = {}
|
||||
for line in header_lines[1:]:
|
||||
if not line:
|
||||
continue
|
||||
if ":" not in line:
|
||||
raise ValueError("malformed_header")
|
||||
key, value = line.split(":", 1)
|
||||
headers[_unescape_header(key)] = _unescape_header(value)
|
||||
|
||||
if not has_body:
|
||||
body = ""
|
||||
if "content-length" in headers:
|
||||
try:
|
||||
size = int(headers["content-length"])
|
||||
except ValueError as exc:
|
||||
raise ValueError("invalid_content_length") from exc
|
||||
if size < 0:
|
||||
raise ValueError("invalid_content_length")
|
||||
body = body[:size]
|
||||
|
||||
return StompFrame(command=command, headers=headers, body=body)
|
||||
|
||||
|
||||
def _escape_header(value: str) -> str:
|
||||
return (
|
||||
value.replace("\\", "\\\\")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\n", "\\n")
|
||||
.replace(":", "\\c")
|
||||
)
|
||||
|
||||
|
||||
def _unescape_header(value: str) -> str:
|
||||
result = ""
|
||||
cursor = 0
|
||||
while cursor < len(value):
|
||||
char = value[cursor]
|
||||
if char != "\\":
|
||||
result += char
|
||||
cursor += 1
|
||||
continue
|
||||
cursor += 1
|
||||
if cursor >= len(value):
|
||||
result += "\\"
|
||||
break
|
||||
escaped = value[cursor]
|
||||
cursor += 1
|
||||
if escaped == "r":
|
||||
result += "\r"
|
||||
elif escaped == "n":
|
||||
result += "\n"
|
||||
elif escaped == "c":
|
||||
result += ":"
|
||||
else:
|
||||
result += escaped
|
||||
return result
|
||||
+106
-19
@@ -1,12 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from ..schemas.ws import WsEventEnvelope
|
||||
from .stomp_protocol import build_stomp_frame, topic_to_destination
|
||||
from .topic_registry import get_auto_topics, validate_topic_subscription
|
||||
|
||||
|
||||
@@ -18,6 +21,9 @@ class WsConnection:
|
||||
role_codes: set[str]
|
||||
permission_codes: set[str]
|
||||
subscribed_topics: set[str] = field(default_factory=set)
|
||||
protocol: Literal["json", "stomp"] = "json"
|
||||
stomp_topic_subscriptions: dict[str, set[str]] = field(default_factory=dict)
|
||||
stomp_subscription_topics: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
class WsConnectionManager:
|
||||
@@ -33,6 +39,7 @@ class WsConnectionManager:
|
||||
user_id: str,
|
||||
role_codes: set[str],
|
||||
permission_codes: set[str],
|
||||
protocol: Literal["json", "stomp"] = "json",
|
||||
) -> WsConnection:
|
||||
connection = WsConnection(
|
||||
websocket=websocket,
|
||||
@@ -41,6 +48,7 @@ class WsConnectionManager:
|
||||
role_codes=set(role_codes),
|
||||
permission_codes=set(permission_codes),
|
||||
subscribed_topics=set(get_auto_topics()),
|
||||
protocol=protocol,
|
||||
)
|
||||
async with self._lock:
|
||||
self._connections[connection.connection_id] = connection
|
||||
@@ -52,18 +60,37 @@ class WsConnectionManager:
|
||||
async with self._lock:
|
||||
self._remove_connection_locked(connection_id)
|
||||
|
||||
async def subscribe(self, connection_id: str, topics: list[str]) -> list[str]:
|
||||
async def subscribe(
|
||||
self,
|
||||
connection_id: str,
|
||||
topics: list[str],
|
||||
*,
|
||||
subscription_ids: dict[str, str] | None = None,
|
||||
) -> list[str]:
|
||||
accepted: list[str] = []
|
||||
async with self._lock:
|
||||
connection = self._connections.get(connection_id)
|
||||
if not connection:
|
||||
return accepted
|
||||
for topic in topics:
|
||||
if topic in connection.subscribed_topics:
|
||||
continue
|
||||
if topic not in connection.subscribed_topics:
|
||||
connection.subscribed_topics.add(topic)
|
||||
self._topic_connections.setdefault(topic, set()).add(connection_id)
|
||||
accepted.append(topic)
|
||||
if connection.protocol != "stomp" or not subscription_ids:
|
||||
continue
|
||||
subscription_id = subscription_ids.get(topic)
|
||||
if not subscription_id:
|
||||
continue
|
||||
previous_topic = connection.stomp_subscription_topics.get(subscription_id)
|
||||
if previous_topic and previous_topic != topic:
|
||||
previous_ids = connection.stomp_topic_subscriptions.get(previous_topic)
|
||||
if previous_ids:
|
||||
previous_ids.discard(subscription_id)
|
||||
if not previous_ids:
|
||||
connection.stomp_topic_subscriptions.pop(previous_topic, None)
|
||||
connection.stomp_subscription_topics[subscription_id] = topic
|
||||
connection.stomp_topic_subscriptions.setdefault(topic, set()).add(subscription_id)
|
||||
return accepted
|
||||
|
||||
async def unsubscribe(self, connection_id: str, topics: list[str]) -> list[str]:
|
||||
@@ -75,15 +102,37 @@ class WsConnectionManager:
|
||||
for topic in topics:
|
||||
if topic not in connection.subscribed_topics or topic in get_auto_topics():
|
||||
continue
|
||||
connection.subscribed_topics.discard(topic)
|
||||
subscribers = self._topic_connections.get(topic)
|
||||
if subscribers:
|
||||
subscribers.discard(connection_id)
|
||||
if not subscribers:
|
||||
self._topic_connections.pop(topic, None)
|
||||
self._remove_topic_subscription_locked(connection, topic)
|
||||
removed.append(topic)
|
||||
return removed
|
||||
|
||||
async def unsubscribe_by_subscription_id(
|
||||
self,
|
||||
connection_id: str,
|
||||
subscription_id: str,
|
||||
) -> str | None:
|
||||
async with self._lock:
|
||||
connection = self._connections.get(connection_id)
|
||||
if not connection:
|
||||
return None
|
||||
topic = connection.stomp_subscription_topics.pop(subscription_id, None)
|
||||
if not topic:
|
||||
return None
|
||||
|
||||
topic_subscriptions = connection.stomp_topic_subscriptions.get(topic)
|
||||
if topic_subscriptions:
|
||||
topic_subscriptions.discard(subscription_id)
|
||||
if not topic_subscriptions:
|
||||
connection.stomp_topic_subscriptions.pop(topic, None)
|
||||
|
||||
if topic in get_auto_topics():
|
||||
return topic
|
||||
|
||||
if topic in connection.subscribed_topics and not connection.stomp_topic_subscriptions.get(topic):
|
||||
self._remove_topic_subscription_locked(connection, topic)
|
||||
|
||||
return topic
|
||||
|
||||
async def refresh_user_authorization(
|
||||
self,
|
||||
user_id: str,
|
||||
@@ -113,12 +162,7 @@ class WsConnectionManager:
|
||||
)
|
||||
if is_allowed:
|
||||
continue
|
||||
connection.subscribed_topics.discard(topic)
|
||||
subscribers = self._topic_connections.get(topic)
|
||||
if subscribers:
|
||||
subscribers.discard(connection.connection_id)
|
||||
if not subscribers:
|
||||
self._topic_connections.pop(topic, None)
|
||||
self._remove_topic_subscription_locked(connection, topic)
|
||||
removed_topics.append(topic)
|
||||
|
||||
if removed_topics:
|
||||
@@ -126,6 +170,8 @@ class WsConnectionManager:
|
||||
|
||||
stale_ids: list[str] = []
|
||||
for connection, removed_topics in notifications:
|
||||
if connection.protocol != "json":
|
||||
continue
|
||||
try:
|
||||
await connection.websocket.send_json(
|
||||
{
|
||||
@@ -174,11 +220,11 @@ class WsConnectionManager:
|
||||
if not connections:
|
||||
return
|
||||
|
||||
payload = {"type": "event", "event": event.model_dump(mode="json")}
|
||||
event_payload = event.model_dump(mode="json")
|
||||
stale_ids: list[str] = []
|
||||
for connection in connections:
|
||||
try:
|
||||
await connection.websocket.send_json(payload)
|
||||
await self._send_event_to_connection(connection, event, event_payload)
|
||||
except Exception:
|
||||
stale_ids.append(connection.connection_id)
|
||||
for connection_id in stale_ids:
|
||||
@@ -193,16 +239,57 @@ class WsConnectionManager:
|
||||
]
|
||||
if not connections:
|
||||
return
|
||||
payload = {"type": "event", "event": event.model_dump(mode="json")}
|
||||
event_payload = event.model_dump(mode="json")
|
||||
stale_ids: list[str] = []
|
||||
for connection in connections:
|
||||
try:
|
||||
await connection.websocket.send_json(payload)
|
||||
await self._send_event_to_connection(connection, event, event_payload)
|
||||
except Exception:
|
||||
stale_ids.append(connection.connection_id)
|
||||
for connection_id in stale_ids:
|
||||
await self.unregister(connection_id)
|
||||
|
||||
async def _send_event_to_connection(
|
||||
self,
|
||||
connection: WsConnection,
|
||||
event: WsEventEnvelope,
|
||||
event_payload: dict,
|
||||
) -> None:
|
||||
if connection.protocol == "stomp":
|
||||
body = json.dumps(event_payload, separators=(",", ":"), ensure_ascii=False)
|
||||
destination = topic_to_destination(event.topic)
|
||||
subscription_ids = sorted(connection.stomp_topic_subscriptions.get(event.topic, set()))
|
||||
if not subscription_ids:
|
||||
subscription_ids = [event.topic]
|
||||
for subscription_id in subscription_ids:
|
||||
await connection.websocket.send_text(
|
||||
build_stomp_frame(
|
||||
"MESSAGE",
|
||||
headers={
|
||||
"subscription": subscription_id,
|
||||
"destination": destination,
|
||||
"message-id": event.id,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body=body,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
await connection.websocket.send_json({"type": "event", "event": event_payload})
|
||||
|
||||
def _remove_topic_subscription_locked(self, connection: WsConnection, topic: str) -> None:
|
||||
connection.subscribed_topics.discard(topic)
|
||||
subscribers = self._topic_connections.get(topic)
|
||||
if subscribers:
|
||||
subscribers.discard(connection.connection_id)
|
||||
if not subscribers:
|
||||
self._topic_connections.pop(topic, None)
|
||||
|
||||
subscription_ids = connection.stomp_topic_subscriptions.pop(topic, set())
|
||||
for subscription_id in subscription_ids:
|
||||
connection.stomp_subscription_topics.pop(subscription_id, None)
|
||||
|
||||
def _remove_connection_locked(self, connection_id: str) -> None:
|
||||
connection = self._connections.pop(connection_id, None)
|
||||
if not connection:
|
||||
|
||||
@@ -530,3 +530,34 @@
|
||||
|
||||
- 风险与影响:
|
||||
- 当前 `link` 字段为单向关联(`bidirectional=false`);需求表未自动新增反向聚合列。如需在需求表中直接看 issue 明细,可后续补一个双向/反向展示字段。
|
||||
|
||||
## Work Log - 接入 WebSocket STOMP 机制(2026-04-26)
|
||||
|
||||
- 背景:
|
||||
- 用户要求在当前 `fquiz` 系统落地 Socket STOMP 机制。
|
||||
|
||||
- 本次改动(最小闭环):
|
||||
- 后端新增 STOMP 协议支持:
|
||||
- 新增 `api/app/services/stomp_protocol.py`,提供 STOMP 帧编解码、版本协商与 topic/destination 映射。
|
||||
- `api/app/api/v1/ws.py` 新增 `GET /api/v1/ws/stomp` WebSocket 端点(ticket 鉴权保持不变),支持:
|
||||
- `CONNECT/STOMP`
|
||||
- `SUBSCRIBE/UNSUBSCRIBE`
|
||||
- `DISCONNECT`
|
||||
- `SEND /app/ping`(收据语义)
|
||||
- 保留原有 `/api/v1/ws` JSON 协议端点,确保兼容。
|
||||
- 推送管理器双协议化:
|
||||
- `api/app/services/ws_manager.py` 扩展为 JSON/STOMP 双协议连接管理与事件分发。
|
||||
- 新增 STOMP 订阅 id 到 topic 的映射处理,保证 `MESSAGE.subscription` 可回填对应订阅。
|
||||
- 现有 `publish_topic/publish_to_user` 调用链不变。
|
||||
- 前端切换到 STOMP:
|
||||
- `web/src/components/ws-provider.tsx` 改为通过 `/api/v1/ws/stomp` 连接并发送 STOMP 帧。
|
||||
- 保持 `useWS` / `useTopicSubscription` 现有 API 不变,业务页面无须改动。
|
||||
- 新增 `web/src/lib/stomp.ts` 作为前端 STOMP 帧编解码工具。
|
||||
|
||||
- 验证:
|
||||
- `python3 -m py_compile api/app/services/stomp_protocol.py api/app/services/ws_manager.py api/app/api/v1/ws.py` -> 通过。
|
||||
- `npm --workspace web exec tsc --noEmit --pretty false` -> 通过。
|
||||
|
||||
- 风险与影响:
|
||||
- 目前后端 STOMP 路径已支持核心命令(CONNECT/SUBSCRIBE/UNSUBSCRIBE/DISCONNECT);事务相关命令仅做兼容性接收,不做事务语义实现。
|
||||
- 权限变化后,STOMP 连接上的非法 topic 会被后台移除但不再下发 JSON `unsubscribed` 提示;前端依赖重连后重新协商订阅。
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
## Work Log - 后台首页按 Ant Design 规范优化(2026-04-27)
|
||||
|
||||
- 背景:
|
||||
- 用户要求“按照 ant design 规范优化页面”。
|
||||
- 本次按最小闭环默认命中后台首页 `/admin`,不改接口、权限与路由,仅优化页面组织与组件用法。
|
||||
|
||||
- 本次改动:
|
||||
- `web/src/app/admin/page.tsx`
|
||||
- 新增模块分组筛选:使用 `Segmented` 展示“全部 + 各业务分类”并联动卡片列表。
|
||||
- 新增筛选结果统计:在顶部统计区增加“筛选结果”卡片。
|
||||
- 卡片内容结构标准化:模块卡片改为 `Card.Meta + Typography.Paragraph(ellipsis) + Tag`,信息层级更符合 AntD 卡片模式。
|
||||
- 加入分类色标映射(`CATEGORY_COLORS`),保持类别可读性。
|
||||
- 保留仓库既有 `Card` 类型兼容方案(`AntCard` 强转),并扩展 `Meta` 子组件,兼容 React 19 + 当前 AntD 类型约束。
|
||||
- 权限逻辑不变:仍由 `visible(hasPermission)` 决定模块可见性。
|
||||
|
||||
- AntD CLI 校验(按技能要求):
|
||||
- 变更前 API 查询(JSON):
|
||||
- `info Card/Grid/Statistic/Typography/Tag/Avatar/Empty/Space/Segmented/Badge/Flex --format json`
|
||||
- 变更后规范检查:
|
||||
- `npx @ant-design/cli lint web/src/app/admin/page.tsx --format json` -> 无问题(`total=0`)。
|
||||
|
||||
- 验证:
|
||||
- `npm --workspace web exec tsc --noEmit --pretty false` -> 通过。
|
||||
|
||||
- 风险与影响:
|
||||
- 影响范围仅前端后台首页展示层(`/admin`)。
|
||||
- 未改动后端接口、RBAC、菜单数据与路由注册,功能行为保持不变。
|
||||
|
||||
## Work Log - 左侧菜单收缩按钮下沉到左下角并补齐菜单图标(2026-04-27)
|
||||
|
||||
- 背景:
|
||||
- 用户要求“左侧菜单收缩按钮放到左下角,给菜单都配上图标”。
|
||||
|
||||
- 本次改动:
|
||||
- `web/src/app/admin/layout.tsx`
|
||||
- 桌面端收缩按钮从顶部 `Header` 移除,改为侧栏底部按钮(左下角)。
|
||||
- 侧栏菜单构建函数新增图标注入:优先使用后端 `menu.icon`,其次按 `menu.path` 回退,最后按“目录/叶子”使用默认图标兜底,确保每个菜单都有图标。
|
||||
- 增加默认菜单图标映射,覆盖当前 seed 中 `LayoutDashboard/Users/ShieldCheck/MenuSquare/Settings2/Network/Zap/Map/RadarChart/Experiment/FolderTree/FileText/Terminal`。
|
||||
- `web/src/app/globals.css`
|
||||
- 侧栏改为“菜单区域滚动 + 底部按钮固定”布局,保证按钮稳定停靠在左下角且不挤占菜单可视区。
|
||||
|
||||
- AntD CLI 校验(按技能要求):
|
||||
- 变更前 API 查询(JSON):
|
||||
- `info Layout/Menu/Button --format json`
|
||||
- 变更后规范检查:
|
||||
- `npx @ant-design/cli lint web/src/app/admin/layout.tsx --format json` -> 无问题(`total=0`)。
|
||||
|
||||
- 验证:
|
||||
- `npm --workspace web exec tsc --noEmit --pretty false` -> 通过。
|
||||
|
||||
- 风险与影响:
|
||||
- 仅影响后台壳层导航展示与交互,不改动路由、权限与接口数据结构。
|
||||
- 若后端返回了未收录的新 `menu.icon` 字符串,会走路径或通用兜底图标,不会出现“无图标”空位。
|
||||
|
||||
## Work Log - 打包更新镜像并重启(2026-04-27)
|
||||
|
||||
- 背景:
|
||||
- 用户要求“打包更新镜像并重启”。
|
||||
|
||||
- 本次执行:
|
||||
- `docker compose build`
|
||||
- `docker compose up -d`
|
||||
|
||||
- 验证:
|
||||
- `docker compose ps`
|
||||
- `api/web/celery-worker/celery-beat/db/redis/minio` 均为 `Up`,其中 `api/db/redis` 为 `healthy`。
|
||||
- `curl -fsS http://127.0.0.1:8000/health`
|
||||
- 返回 `{"status":"ok","service":"fquiz-api","version":"0.1.0"}`。
|
||||
- `curl -I -fsS http://127.0.0.1:3000/`
|
||||
- 返回 `HTTP/1.1 200 OK`。
|
||||
- `docker compose logs --tail=80 api/web/celery-worker/celery-beat`
|
||||
- API 启动完成且 `/health` 返回 200。
|
||||
- Web Ready。
|
||||
- Celery worker/beat 启动并连接 Redis。
|
||||
|
||||
- 风险与影响:
|
||||
- 发布过程中重建并重启了 `api/web/celery-worker/celery-beat`,存在短时服务切换窗口。
|
||||
- Celery worker 存在既有 root 运行告警(非本次新增),当前服务可用。
|
||||
@@ -0,0 +1,32 @@
|
||||
## Work Log - GitHub Actions workflow 与 Docker 拓扑对齐(2026-04-29)
|
||||
|
||||
- 背景:
|
||||
- 用户反馈现有 GitHub workflow 与仓库实际 Docker 构建/部署配置不匹配,要求改造 workflow。
|
||||
|
||||
- 本次改动:
|
||||
- 文件:`.github/workflows/main.yml`
|
||||
- 构建阶段对齐:
|
||||
- API 镜像构建参数补齐 `PYTHON_BASE_IMAGE`、`PIP_RETRIES`,并将 `PIP_DEFAULT_TIMEOUT` 默认值改为 `300`。
|
||||
- Web 镜像构建参数补齐 `NODE_BASE_IMAGE`,并统一 `NEXT_PUBLIC_API_BASE_URL` 默认值为 `http://localhost:8000`。
|
||||
- 部署阶段对齐:
|
||||
- workflow 内生成的 `docker-compose.prod.yml` 从 `db/api/web` 扩展为与仓库主 compose 一致的核心拓扑:
|
||||
- `db`
|
||||
- `redis`
|
||||
- `minio`
|
||||
- `minio-init`
|
||||
- `api`
|
||||
- `celery-worker`
|
||||
- `celery-beat`
|
||||
- `web`
|
||||
- 补齐 `api` 运行所需的 `DB_* / MINIO_* / CELERY_* / WINE_*` 环境变量口径。
|
||||
- 将 `ACCESS_TOKEN_EXPIRE_MINUTES` 默认值从 `15` 校正为 `480`,与仓库基线一致。
|
||||
- `.env` 模板补齐 Redis/MinIO/Celery/Wine 相关配置默认值。
|
||||
|
||||
- 验证:
|
||||
- 通过 `git diff -- .github/workflows/main.yml` 逐项核对 workflow 变更。
|
||||
- 通过 `nl -ba .github/workflows/main.yml` 抽查关键片段(构建参数、compose 服务拓扑、.env 模板)确认已对齐。
|
||||
- 本次未执行远端部署或 GitHub Actions 实跑。
|
||||
|
||||
- 风险与影响:
|
||||
- 影响范围限定在 CI/CD workflow,不涉及应用业务代码。
|
||||
- 部署拓扑扩展后,目标服务器需具备额外镜像拉取与运行资源(Redis/MinIO/Celery)。
|
||||
@@ -4,8 +4,18 @@ import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState, type ComponentType, type ReactNode, type SVGProps } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Icon, {
|
||||
AppstoreOutlined,
|
||||
BgColorsOutlined,
|
||||
CodeOutlined,
|
||||
CompressOutlined,
|
||||
DashboardOutlined,
|
||||
DeploymentUnitOutlined,
|
||||
ExperimentOutlined,
|
||||
FileOutlined,
|
||||
FileSearchOutlined,
|
||||
FolderOpenOutlined,
|
||||
FolderOutlined,
|
||||
GlobalOutlined,
|
||||
HomeOutlined,
|
||||
LinkOutlined,
|
||||
LogoutOutlined,
|
||||
@@ -13,10 +23,15 @@ import Icon, {
|
||||
MenuOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
MoonOutlined,
|
||||
RadarChartOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
SettingOutlined,
|
||||
ShopOutlined,
|
||||
SmileOutlined,
|
||||
SunOutlined,
|
||||
SyncOutlined,
|
||||
TeamOutlined,
|
||||
ThunderboltOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
@@ -92,14 +107,66 @@ function isActivePath(pathname: string, menuPath: string | null): boolean {
|
||||
|
||||
type AntdMenuItems = NonNullable<MenuProps["items"]>;
|
||||
|
||||
const MENU_ICON_BY_NAME: Record<string, ReactNode> = {
|
||||
LayoutDashboard: <DashboardOutlined />,
|
||||
Users: <TeamOutlined />,
|
||||
ShieldCheck: <SafetyCertificateOutlined />,
|
||||
MenuSquare: <AppstoreOutlined />,
|
||||
Settings2: <SettingOutlined />,
|
||||
Network: <DeploymentUnitOutlined />,
|
||||
Zap: <ThunderboltOutlined />,
|
||||
Map: <GlobalOutlined />,
|
||||
RadarChart: <RadarChartOutlined />,
|
||||
Experiment: <ExperimentOutlined />,
|
||||
FolderTree: <FolderOpenOutlined />,
|
||||
FileText: <FileSearchOutlined />,
|
||||
Terminal: <CodeOutlined />,
|
||||
};
|
||||
|
||||
const MENU_ICON_BY_PATH: Record<string, ReactNode> = {
|
||||
"/dashboard": <DashboardOutlined />,
|
||||
"/users": <TeamOutlined />,
|
||||
"/roles": <SafetyCertificateOutlined />,
|
||||
"/menus": <AppstoreOutlined />,
|
||||
"/system-params": <SettingOutlined />,
|
||||
"/power-lines": <DeploymentUnitOutlined />,
|
||||
"/power-lines/atp-viewer": <ExperimentOutlined />,
|
||||
"/lightning-currents": <ThunderboltOutlined />,
|
||||
"/lightning-distribution": <GlobalOutlined />,
|
||||
"/task-monitor": <RadarChartOutlined />,
|
||||
"/files": <FolderOpenOutlined />,
|
||||
"/syslog": <FileSearchOutlined />,
|
||||
"/wine-runner": <CodeOutlined />,
|
||||
};
|
||||
|
||||
function resolveMenuIcon(iconName: string | null, menuPath: string | null, hasChildren: boolean): ReactNode {
|
||||
if (iconName) {
|
||||
const namedIcon = MENU_ICON_BY_NAME[iconName.trim()];
|
||||
if (namedIcon) {
|
||||
return namedIcon;
|
||||
}
|
||||
}
|
||||
|
||||
if (menuPath) {
|
||||
const pathIcon = MENU_ICON_BY_PATH[menuPath];
|
||||
if (pathIcon) {
|
||||
return pathIcon;
|
||||
}
|
||||
}
|
||||
|
||||
return hasChildren ? <FolderOutlined /> : <FileOutlined />;
|
||||
}
|
||||
|
||||
function buildMenuItems(items: MenuTreeItem[]): AntdMenuItems {
|
||||
return items.map((item) => {
|
||||
const children = buildMenuItems(item.children);
|
||||
const label = item.path ? <Link href={item.path}>{item.name}</Link> : item.name;
|
||||
const icon = resolveMenuIcon(item.icon, item.path, children.length > 0);
|
||||
|
||||
if (children.length > 0) {
|
||||
return {
|
||||
key: item.id,
|
||||
icon,
|
||||
label,
|
||||
children,
|
||||
};
|
||||
@@ -107,6 +174,7 @@ function buildMenuItems(items: MenuTreeItem[]): AntdMenuItems {
|
||||
|
||||
return {
|
||||
key: item.id,
|
||||
icon,
|
||||
label,
|
||||
disabled: !item.path,
|
||||
};
|
||||
@@ -420,14 +488,6 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{isDesktop && (
|
||||
<Button
|
||||
aria-label={siderCollapsed ? "展开菜单" : "收起菜单"}
|
||||
icon={siderCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
type="text"
|
||||
onClick={() => setSiderCollapsed((previous) => !previous)}
|
||||
/>
|
||||
)}
|
||||
<Link className="admin-design-brand" href="/dashboard">
|
||||
<span className="admin-design-logo">Q</span>
|
||||
<Typography.Text strong>fquiz</Typography.Text>
|
||||
@@ -475,7 +535,16 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
width={256}
|
||||
>
|
||||
<div className="admin-design-sider-inner">
|
||||
{navigationMenu}
|
||||
<div className="admin-design-sider-menu">{navigationMenu}</div>
|
||||
<Button
|
||||
className="admin-design-sider-toggle"
|
||||
aria-label={siderCollapsed ? "展开菜单" : "收起菜单"}
|
||||
icon={siderCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
type="text"
|
||||
onClick={() => setSiderCollapsed((previous) => !previous)}
|
||||
>
|
||||
{!siderCollapsed ? "收起菜单" : null}
|
||||
</Button>
|
||||
</div>
|
||||
</Sider>
|
||||
)}
|
||||
|
||||
+96
-24
@@ -14,12 +14,15 @@ import {
|
||||
SettingOutlined,
|
||||
TeamOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Avatar, Card, Col, Empty, Row, Space, Statistic, Tag, Typography, type CardProps } from "antd";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { Avatar, Card, Col, Empty, Row, Segmented, Space, Statistic, Tag, Typography, type CardProps } from "antd";
|
||||
import type { ComponentType } from "react";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
|
||||
const AntCard = Card as unknown as ComponentType<CardProps>;
|
||||
const AntCard = Card as unknown as ComponentType<CardProps> & {
|
||||
Meta: typeof Card.Meta;
|
||||
};
|
||||
|
||||
type DashboardCard = {
|
||||
href: string;
|
||||
@@ -30,6 +33,15 @@ type DashboardCard = {
|
||||
visible: (hasPermission: (code: string) => boolean) => boolean;
|
||||
};
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
权限: "blue",
|
||||
系统: "geekblue",
|
||||
内容: "cyan",
|
||||
协作: "purple",
|
||||
电力: "gold",
|
||||
研发: "magenta",
|
||||
};
|
||||
|
||||
const CARDS: DashboardCard[] = [
|
||||
{
|
||||
href: "/users",
|
||||
@@ -133,8 +145,40 @@ const CARDS: DashboardCard[] = [
|
||||
|
||||
export default function AdminHomePage() {
|
||||
const { hasPermission, user } = useAuth();
|
||||
const visibleCards = CARDS.filter((item) => item.visible(hasPermission));
|
||||
const categoryCount = new Set(visibleCards.map((item) => item.category)).size;
|
||||
const [activeCategory, setActiveCategory] = useState<string>("all");
|
||||
const visibleCards = useMemo(() => CARDS.filter((item) => item.visible(hasPermission)), [hasPermission]);
|
||||
|
||||
const categoryStats = useMemo(() => {
|
||||
const stats = new Map<string, number>();
|
||||
for (const item of visibleCards) {
|
||||
stats.set(item.category, (stats.get(item.category) ?? 0) + 1);
|
||||
}
|
||||
return stats;
|
||||
}, [visibleCards]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeCategory !== "all" && !categoryStats.has(activeCategory)) {
|
||||
setActiveCategory("all");
|
||||
}
|
||||
}, [activeCategory, categoryStats]);
|
||||
|
||||
const categoryOptions = useMemo(
|
||||
() => [
|
||||
{ label: `全部 (${visibleCards.length})`, value: "all" },
|
||||
...Array.from(categoryStats.entries()).map(([category, count]) => ({
|
||||
label: `${category} (${count})`,
|
||||
value: category,
|
||||
})),
|
||||
],
|
||||
[categoryStats, visibleCards.length],
|
||||
);
|
||||
|
||||
const filteredCards = useMemo(() => {
|
||||
if (activeCategory === "all") {
|
||||
return visibleCards;
|
||||
}
|
||||
return visibleCards.filter((item) => item.category === activeCategory);
|
||||
}, [activeCategory, visibleCards]);
|
||||
|
||||
if (visibleCards.length === 0) {
|
||||
return (
|
||||
@@ -147,49 +191,77 @@ export default function AdminHomePage() {
|
||||
return (
|
||||
<Space direction="vertical" size={24} style={{ width: "100%" }}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} md={8}>
|
||||
<AntCard>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<AntCard size="small">
|
||||
<Statistic title="可访问模块" value={visibleCards.length} suffix="个" />
|
||||
</AntCard>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<AntCard>
|
||||
<Statistic title="业务分组" value={categoryCount} suffix="类" />
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<AntCard size="small">
|
||||
<Statistic title="业务分组" value={categoryStats.size} suffix="类" />
|
||||
</AntCard>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<AntCard>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<AntCard size="small">
|
||||
<Statistic title="筛选结果" value={filteredCards.length} suffix="个" />
|
||||
</AntCard>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<AntCard size="small">
|
||||
<Statistic title="当前角色" value={user?.role_codes.length ?? 0} suffix="个" />
|
||||
</AntCard>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div>
|
||||
<Space align="baseline" style={{ marginBottom: 16 }}>
|
||||
<Space direction="vertical" size={12} style={{ width: "100%", marginBottom: 16 }}>
|
||||
<Space align="baseline">
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
模块导航
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">按权限展示,入口遵循 Ant Design 卡片列表模式。</Typography.Text>
|
||||
<Typography.Text type="secondary">按权限和业务分组快速定位后台模块。</Typography.Text>
|
||||
</Space>
|
||||
<Segmented
|
||||
block
|
||||
options={categoryOptions}
|
||||
value={activeCategory}
|
||||
onChange={(value) => setActiveCategory(String(value))}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{visibleCards.map((item) => (
|
||||
{filteredCards.map((item) => (
|
||||
<Col key={item.href} xs={24} sm={12} xl={8} xxl={6}>
|
||||
<Link href={item.href} style={{ display: "block", height: "100%" }}>
|
||||
<AntCard hoverable style={{ height: "100%" }}>
|
||||
<Space align="start" size={12}>
|
||||
<AntCard
|
||||
hoverable
|
||||
size="small"
|
||||
style={{ height: "100%" }}
|
||||
styles={{ body: { height: "100%" } }}
|
||||
>
|
||||
<Space direction="vertical" size={10} style={{ width: "100%" }}>
|
||||
<AntCard.Meta
|
||||
avatar={
|
||||
<Avatar
|
||||
icon={item.icon}
|
||||
shape="square"
|
||||
style={{ backgroundColor: "var(--ant-color-primary)" }}
|
||||
/>
|
||||
<Space direction="vertical" size={4}>
|
||||
<Space size={8} wrap>
|
||||
<Typography.Text strong>{item.title}</Typography.Text>
|
||||
<Tag color="blue">{item.category}</Tag>
|
||||
</Space>
|
||||
<Typography.Text type="secondary">{item.description}</Typography.Text>
|
||||
</Space>
|
||||
}
|
||||
title={item.title}
|
||||
description={
|
||||
<Typography.Paragraph
|
||||
ellipsis={{ rows: 2, tooltip: item.description }}
|
||||
style={{ marginBottom: 0 }}
|
||||
type="secondary"
|
||||
>
|
||||
{item.description}
|
||||
</Typography.Paragraph>
|
||||
}
|
||||
/>
|
||||
<Tag color={CATEGORY_COLORS[item.category] ?? "blue"} style={{ width: "fit-content" }}>
|
||||
{item.category}
|
||||
</Tag>
|
||||
</Space>
|
||||
</AntCard>
|
||||
</Link>
|
||||
|
||||
+14
-2
@@ -109,18 +109,30 @@ body {
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
height: calc(100vh - 64px);
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.admin-design-sider-inner {
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.admin-design-sider-menu {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.admin-design-sider-toggle {
|
||||
margin-top: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.admin-design-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { getApiBaseUrl } from "@/lib/api";
|
||||
import type { WsEventEnvelope, WsServerMessage, WsTicketResponse } from "@/types/ws";
|
||||
import { buildStompFrame, parseStompFrames, topicToDestination } from "@/lib/stomp";
|
||||
import type { WsEventEnvelope, WsTicketResponse } from "@/types/ws";
|
||||
|
||||
type TopicHandler = (event: WsEventEnvelope) => void;
|
||||
|
||||
@@ -36,6 +37,7 @@ export function WSProvider({ children }: { children: React.ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { user, fetchWithAuth, logout, refreshAccessToken } = useAuth();
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const stompConnectedRef = useRef(false);
|
||||
const reconnectTimerRef = useRef<number | null>(null);
|
||||
const reconnectAttemptRef = useRef(0);
|
||||
const desiredTopicsRef = useRef<Set<string>>(new Set());
|
||||
@@ -65,62 +67,41 @@ export function WSProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const hasSeenEvent = (eventId: string) => seenEventIdsRef.current.includes(eventId);
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
if (!userIdRef.current) {
|
||||
const subscriptionIdForTopic = (topic: string) => `topic:${topic}`;
|
||||
|
||||
const sendSubscribeFrame = useCallback((topic: string) => {
|
||||
const socket = socketRef.current;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN || !stompConnectedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (socketRef.current) {
|
||||
if (
|
||||
socketRef.current.readyState === WebSocket.OPEN
|
||||
|| socketRef.current.readyState === WebSocket.CONNECTING
|
||||
) {
|
||||
socket.send(
|
||||
buildStompFrame({
|
||||
command: "SUBSCRIBE",
|
||||
headers: {
|
||||
id: subscriptionIdForTopic(topic),
|
||||
destination: topicToDestination(topic),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const sendUnsubscribeFrame = useCallback((topic: string) => {
|
||||
const socket = socketRef.current;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN || !stompConnectedRef.current) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
socket.send(
|
||||
buildStompFrame({
|
||||
command: "UNSUBSCRIBE",
|
||||
headers: { id: subscriptionIdForTopic(topic) },
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const ticketRes = await fetchWithAuth("/api/v1/ws/ticket", { method: "POST" });
|
||||
if (!ticketRes.ok) {
|
||||
const handleIncomingEvent = useCallback((event: WsEventEnvelope) => {
|
||||
if (!event || typeof event.id !== "string" || typeof event.topic !== "string") {
|
||||
return;
|
||||
}
|
||||
const ticketPayload = (await ticketRes.json()) as WsTicketResponse;
|
||||
const socket = new WebSocket(`${toWebSocketUrl("/api/v1/ws")}?ticket=${encodeURIComponent(ticketPayload.ticket)}`);
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
setConnected(true);
|
||||
reconnectAttemptRef.current = 0;
|
||||
const topics = Array.from(desiredTopicsRef.current);
|
||||
if (topics.length > 0) {
|
||||
socket.send(JSON.stringify({ type: "subscribe", topics }));
|
||||
}
|
||||
};
|
||||
|
||||
socket.onmessage = (message) => {
|
||||
let parsed: WsServerMessage;
|
||||
try {
|
||||
parsed = JSON.parse(message.data) as WsServerMessage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === "ready") {
|
||||
const topics = Array.from(desiredTopicsRef.current);
|
||||
if (topics.length > 0) {
|
||||
socket.send(JSON.stringify({ type: "subscribe", topics }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === "unsubscribed") {
|
||||
for (const topic of parsed.topics) {
|
||||
desiredTopicsRef.current.delete(topic);
|
||||
handlersRef.current.delete(topic);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === "event") {
|
||||
const event = parsed.event;
|
||||
if (hasSeenEvent(event.id)) {
|
||||
return;
|
||||
}
|
||||
@@ -152,16 +133,98 @@ export function WSProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
const handlers = handlersRef.current.get(event.topic);
|
||||
if (handlers) {
|
||||
if (!handlers) {
|
||||
return;
|
||||
}
|
||||
for (const handler of handlers) {
|
||||
handler(event);
|
||||
}
|
||||
}, [logout, queryClient, refreshAccessToken]);
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
if (!userIdRef.current) {
|
||||
return;
|
||||
}
|
||||
if (socketRef.current) {
|
||||
if (
|
||||
socketRef.current.readyState === WebSocket.OPEN
|
||||
|| socketRef.current.readyState === WebSocket.CONNECTING
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const ticketRes = await fetchWithAuth("/api/v1/ws/ticket", { method: "POST" });
|
||||
if (!ticketRes.ok) {
|
||||
return;
|
||||
}
|
||||
const ticketPayload = (await ticketRes.json()) as WsTicketResponse;
|
||||
const socket = new WebSocket(
|
||||
`${toWebSocketUrl("/api/v1/ws/stomp")}?ticket=${encodeURIComponent(ticketPayload.ticket)}`,
|
||||
["v12.stomp", "v11.stomp", "v10.stomp"],
|
||||
);
|
||||
socketRef.current = socket;
|
||||
stompConnectedRef.current = false;
|
||||
|
||||
socket.onopen = () => {
|
||||
socket.send(
|
||||
buildStompFrame({
|
||||
command: "CONNECT",
|
||||
headers: {
|
||||
"accept-version": "1.2,1.1,1.0",
|
||||
"heart-beat": "10000,10000",
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
socket.onmessage = (message) => {
|
||||
if (typeof message.data !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
let frames;
|
||||
try {
|
||||
frames = parseStompFrames(message.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const frame of frames) {
|
||||
if (frame.command === "CONNECTED") {
|
||||
stompConnectedRef.current = true;
|
||||
setConnected(true);
|
||||
reconnectAttemptRef.current = 0;
|
||||
for (const topic of desiredTopicsRef.current) {
|
||||
sendSubscribeFrame(topic);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (frame.command === "MESSAGE") {
|
||||
if (!frame.body) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const event = JSON.parse(frame.body) as WsEventEnvelope;
|
||||
handleIncomingEvent(event);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (frame.command === "ERROR") {
|
||||
if (frame.body?.includes("user_not_allowed")) {
|
||||
void logout();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = async (event) => {
|
||||
setConnected(false);
|
||||
stompConnectedRef.current = false;
|
||||
if (socketRef.current === socket) {
|
||||
socketRef.current = null;
|
||||
}
|
||||
@@ -180,7 +243,7 @@ export function WSProvider({ children }: { children: React.ReactNode }) {
|
||||
void connectRef.current?.();
|
||||
}, delay);
|
||||
};
|
||||
}, [clearReconnectTimer, fetchWithAuth, logout, queryClient, refreshAccessToken]);
|
||||
}, [clearReconnectTimer, fetchWithAuth, handleIncomingEvent, logout, sendSubscribeFrame]);
|
||||
|
||||
useEffect(() => {
|
||||
connectRef.current = connect;
|
||||
@@ -191,6 +254,7 @@ export function WSProvider({ children }: { children: React.ReactNode }) {
|
||||
clearReconnectTimer();
|
||||
socketRef.current?.close();
|
||||
socketRef.current = null;
|
||||
stompConnectedRef.current = false;
|
||||
desiredTopicsRef.current.clear();
|
||||
handlersRef.current.clear();
|
||||
if (connected) {
|
||||
@@ -214,8 +278,8 @@ export function WSProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const isNewTopic = !desiredTopicsRef.current.has(topic);
|
||||
desiredTopicsRef.current.add(topic);
|
||||
if (isNewTopic && socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(JSON.stringify({ type: "subscribe", topics: [topic] }));
|
||||
if (isNewTopic) {
|
||||
sendSubscribeFrame(topic);
|
||||
}
|
||||
|
||||
return () => {
|
||||
@@ -229,15 +293,13 @@ export function WSProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
handlersRef.current.delete(topic);
|
||||
desiredTopicsRef.current.delete(topic);
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(JSON.stringify({ type: "unsubscribe", topics: [topic] }));
|
||||
}
|
||||
sendUnsubscribeFrame(topic);
|
||||
};
|
||||
}, []);
|
||||
}, [sendSubscribeFrame, sendUnsubscribeFrame]);
|
||||
|
||||
const sendPing = useCallback(() => {
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(JSON.stringify({ type: "ping", ts: Date.now() }));
|
||||
socketRef.current.send("\n");
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
export type StompFrame = {
|
||||
command: string;
|
||||
headers: Record<string, string>;
|
||||
body?: string;
|
||||
};
|
||||
|
||||
const TOPIC_DESTINATION_PREFIX = "/topic/";
|
||||
|
||||
export function topicToDestination(topic: string): string {
|
||||
return `${TOPIC_DESTINATION_PREFIX}${topic}`;
|
||||
}
|
||||
|
||||
export function buildStompFrame(frame: StompFrame): string {
|
||||
const lines: string[] = [frame.command];
|
||||
for (const [key, value] of Object.entries(frame.headers)) {
|
||||
lines.push(`${escapeHeader(key)}:${escapeHeader(value)}`);
|
||||
}
|
||||
lines.push("");
|
||||
return `${lines.join("\n")}${frame.body ?? ""}\u0000`;
|
||||
}
|
||||
|
||||
export function parseStompFrames(payload: string): StompFrame[] {
|
||||
const normalized = payload.replace(/\r\n/g, "\n");
|
||||
const frames: StompFrame[] = [];
|
||||
|
||||
let cursor = 0;
|
||||
while (cursor < normalized.length) {
|
||||
while (cursor < normalized.length && normalized[cursor] === "\n") {
|
||||
cursor += 1;
|
||||
}
|
||||
if (cursor >= normalized.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const terminator = normalized.indexOf("\u0000", cursor);
|
||||
if (terminator < 0) {
|
||||
throw new Error("frame_terminator_missing");
|
||||
}
|
||||
|
||||
const raw = normalized.slice(cursor, terminator);
|
||||
cursor = terminator + 1;
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
frames.push(parseSingleFrame(raw));
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
function parseSingleFrame(raw: string): StompFrame {
|
||||
const boundary = raw.indexOf("\n\n");
|
||||
const headerBlob = boundary >= 0 ? raw.slice(0, boundary) : raw;
|
||||
let body = boundary >= 0 ? raw.slice(boundary + 2) : "";
|
||||
const headerLines = headerBlob.split("\n");
|
||||
const command = headerLines[0]?.trim().toUpperCase();
|
||||
if (!command) {
|
||||
throw new Error("missing_command");
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
for (const line of headerLines.slice(1)) {
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
const separatorIndex = line.indexOf(":");
|
||||
if (separatorIndex < 0) {
|
||||
throw new Error("malformed_header");
|
||||
}
|
||||
const key = unescapeHeader(line.slice(0, separatorIndex));
|
||||
const value = unescapeHeader(line.slice(separatorIndex + 1));
|
||||
headers[key] = value;
|
||||
}
|
||||
|
||||
if (headers["content-length"]) {
|
||||
const contentLength = Number.parseInt(headers["content-length"], 10);
|
||||
if (Number.isNaN(contentLength) || contentLength < 0) {
|
||||
throw new Error("invalid_content_length");
|
||||
}
|
||||
body = body.slice(0, contentLength);
|
||||
}
|
||||
|
||||
return { command, headers, body };
|
||||
}
|
||||
|
||||
function escapeHeader(value: string): string {
|
||||
return value
|
||||
.replaceAll("\\", "\\\\")
|
||||
.replaceAll("\r", "\\r")
|
||||
.replaceAll("\n", "\\n")
|
||||
.replaceAll(":", "\\c");
|
||||
}
|
||||
|
||||
function unescapeHeader(value: string): string {
|
||||
let result = "";
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const char = value[index];
|
||||
if (char !== "\\") {
|
||||
result += char;
|
||||
continue;
|
||||
}
|
||||
const next = value[index + 1];
|
||||
if (!next) {
|
||||
result += "\\";
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
if (next === "r") {
|
||||
result += "\r";
|
||||
} else if (next === "n") {
|
||||
result += "\n";
|
||||
} else if (next === "c") {
|
||||
result += ":";
|
||||
} else {
|
||||
result += next;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user