From c8ba704e409a317b958ea946e87ac9e2e581336e Mon Sep 17 00:00:00 2001 From: chengkai3 Date: Wed, 29 Apr 2026 23:10:25 +0800 Subject: [PATCH] chore: sync workspace changes --- .github/workflows/main.yml | 182 +++++++++++++++++++++++-- MEMORY.md | 17 +++ api/app/api/v1/ws.py | 211 +++++++++++++++++++++++++++-- api/app/services/stomp_protocol.py | 136 +++++++++++++++++++ api/app/services/ws_manager.py | 129 +++++++++++++++--- memory/2026-04-26.md | 31 +++++ memory/2026-04-27.md | 78 +++++++++++ memory/2026-04-29.md | 32 +++++ web/src/app/admin/layout.tsx | 87 ++++++++++-- web/src/app/admin/page.tsx | 134 +++++++++++++----- web/src/app/globals.css | 16 ++- web/src/components/ws-provider.tsx | 190 +++++++++++++++++--------- web/src/lib/stomp.ts | 119 ++++++++++++++++ 13 files changed, 1218 insertions(+), 144 deletions(-) create mode 100644 api/app/services/stomp_protocol.py create mode 100644 memory/2026-04-27.md create mode 100644 memory/2026-04-29.md create mode 100644 web/src/lib/stomp.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5ee283b..9d99f02 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -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 diff --git a/MEMORY.md b/MEMORY.md index 56bfa44..f19dd3d 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -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` 等泛型调用在 `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 引擎调用”: diff --git a/api/app/api/v1/ws.py b/api/app/api/v1/ws.py index 27383a6..5903f61 100644 --- a/api/app/api/v1/ws.py +++ b/api/app/api/v1/ws.py @@ -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) diff --git a/api/app/services/stomp_protocol.py b/api/app/services/stomp_protocol.py new file mode 100644 index 0000000..b70b613 --- /dev/null +++ b/api/app/services/stomp_protocol.py @@ -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 diff --git a/api/app/services/ws_manager.py b/api/app/services/ws_manager.py index 9798fd8..2970b7b 100644 --- a/api/app/services/ws_manager.py +++ b/api/app/services/ws_manager.py @@ -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: + 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 - connection.subscribed_topics.add(topic) - self._topic_connections.setdefault(topic, set()).add(connection_id) - accepted.append(topic) + 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: diff --git a/memory/2026-04-26.md b/memory/2026-04-26.md index 93cdbf2..9fbcd97 100644 --- a/memory/2026-04-26.md +++ b/memory/2026-04-26.md @@ -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` 提示;前端依赖重连后重新协商订阅。 diff --git a/memory/2026-04-27.md b/memory/2026-04-27.md new file mode 100644 index 0000000..be1a65d --- /dev/null +++ b/memory/2026-04-27.md @@ -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 运行告警(非本次新增),当前服务可用。 diff --git a/memory/2026-04-29.md b/memory/2026-04-29.md new file mode 100644 index 0000000..a49fa03 --- /dev/null +++ b/memory/2026-04-29.md @@ -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)。 diff --git a/web/src/app/admin/layout.tsx b/web/src/app/admin/layout.tsx index 0aa49df..7ffbe15 100644 --- a/web/src/app/admin/layout.tsx +++ b/web/src/app/admin/layout.tsx @@ -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; +const MENU_ICON_BY_NAME: Record = { + LayoutDashboard: , + Users: , + ShieldCheck: , + MenuSquare: , + Settings2: , + Network: , + Zap: , + Map: , + RadarChart: , + Experiment: , + FolderTree: , + FileText: , + Terminal: , +}; + +const MENU_ICON_BY_PATH: Record = { + "/dashboard": , + "/users": , + "/roles": , + "/menus": , + "/system-params": , + "/power-lines": , + "/power-lines/atp-viewer": , + "/lightning-currents": , + "/lightning-distribution": , + "/task-monitor": , + "/files": , + "/syslog": , + "/wine-runner": , +}; + +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 ? : ; +} + function buildMenuItems(items: MenuTreeItem[]): AntdMenuItems { return items.map((item) => { const children = buildMenuItems(item.children); const label = item.path ? {item.name} : 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 && ( - )} diff --git a/web/src/app/admin/page.tsx b/web/src/app/admin/page.tsx index 5935ee7..8523960 100644 --- a/web/src/app/admin/page.tsx +++ b/web/src/app/admin/page.tsx @@ -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; +const AntCard = Card as unknown as ComponentType & { + Meta: typeof Card.Meta; +}; type DashboardCard = { href: string; @@ -30,6 +33,15 @@ type DashboardCard = { visible: (hasPermission: (code: string) => boolean) => boolean; }; +const CATEGORY_COLORS: Record = { + 权限: "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("all"); + const visibleCards = useMemo(() => CARDS.filter((item) => item.visible(hasPermission)), [hasPermission]); + + const categoryStats = useMemo(() => { + const stats = new Map(); + 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 ( - - + + - - - + + + - - + + + + + + +
- - - 模块导航 - - 按权限展示,入口遵循 Ant Design 卡片列表模式。 + + + + 模块导航 + + 按权限和业务分组快速定位后台模块。 + + setActiveCategory(String(value))} + /> - {visibleCards.map((item) => ( + {filteredCards.map((item) => ( - - - + + + } + title={item.title} + description={ + + {item.description} + + } /> - - - {item.title} - {item.category} - - {item.description} - + + {item.category} + diff --git a/web/src/app/globals.css b/web/src/app/globals.css index d849102..ad3076e 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -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; } diff --git a/web/src/components/ws-provider.tsx b/web/src/components/ws-provider.tsx index e08ba76..668fb86 100644 --- a/web/src/components/ws-provider.tsx +++ b/web/src/components/ws-provider.tsx @@ -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(null); + const stompConnectedRef = useRef(false); const reconnectTimerRef = useRef(null); const reconnectAttemptRef = useRef(0); const desiredTopicsRef = useRef>(new Set()); @@ -65,6 +67,80 @@ export function WSProvider({ children }: { children: React.ReactNode }) { const hasSeenEvent = (eventId: string) => seenEventIdsRef.current.includes(eventId); + const subscriptionIdForTopic = (topic: string) => `topic:${topic}`; + + const sendSubscribeFrame = useCallback((topic: string) => { + const socket = socketRef.current; + if (!socket || socket.readyState !== WebSocket.OPEN || !stompConnectedRef.current) { + return; + } + 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 handleIncomingEvent = useCallback((event: WsEventEnvelope) => { + if (!event || typeof event.id !== "string" || typeof event.topic !== "string") { + return; + } + if (hasSeenEvent(event.id)) { + return; + } + rememberEventId(event.id); + + if (event.topic === "auth") { + if (event.name === "auth.permission_changed") { + void refreshAccessToken(); + } + if (event.name === "auth.profile_changed") { + const status = typeof event.payload.status === "string" ? event.payload.status : null; + if (status && status !== "active") { + void logout(); + return; + } + void refreshAccessToken(); + } + } + + if (event.meta?.requires_refetch) { + for (const key of event.meta.requires_refetch) { + void queryClient.invalidateQueries({ + predicate: (query) => { + const first = query.queryKey[0]; + return typeof first === "string" && (first === key || first.startsWith(`${key}?`)); + }, + }); + } + } + + const handlers = handlersRef.current.get(event.topic); + if (!handlers) { + return; + } + for (const handler of handlers) { + handler(event); + } + }, [logout, queryClient, refreshAccessToken]); + const connect = useCallback(async () => { if (!userIdRef.current) { return; @@ -83,78 +159,64 @@ export function WSProvider({ children }: { children: React.ReactNode }) { return; } const ticketPayload = (await ticketRes.json()) as WsTicketResponse; - const socket = new WebSocket(`${toWebSocketUrl("/api/v1/ws")}?ticket=${encodeURIComponent(ticketPayload.ticket)}`); + 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 = () => { - setConnected(true); - reconnectAttemptRef.current = 0; - const topics = Array.from(desiredTopicsRef.current); - if (topics.length > 0) { - socket.send(JSON.stringify({ type: "subscribe", topics })); - } + socket.send( + buildStompFrame({ + command: "CONNECT", + headers: { + "accept-version": "1.2,1.1,1.0", + "heart-beat": "10000,10000", + }, + }), + ); }; socket.onmessage = (message) => { - let parsed: WsServerMessage; + if (typeof message.data !== "string") { + return; + } + + let frames; try { - parsed = JSON.parse(message.data) as WsServerMessage; + frames = parseStompFrames(message.data); } 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; - } - rememberEventId(event.id); - - if (event.topic === "auth") { - if (event.name === "auth.permission_changed") { - void refreshAccessToken(); - } - if (event.name === "auth.profile_changed") { - const status = typeof event.payload.status === "string" ? event.payload.status : null; - if (status && status !== "active") { - void logout(); - return; - } - void refreshAccessToken(); + 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 (event.meta?.requires_refetch) { - for (const key of event.meta.requires_refetch) { - void queryClient.invalidateQueries({ - predicate: (query) => { - const first = query.queryKey[0]; - return typeof first === "string" && (first === key || first.startsWith(`${key}?`)); - }, - }); + if (frame.command === "MESSAGE") { + if (!frame.body) { + continue; } + try { + const event = JSON.parse(frame.body) as WsEventEnvelope; + handleIncomingEvent(event); + } catch { + continue; + } + continue; } - const handlers = handlersRef.current.get(event.topic); - if (handlers) { - for (const handler of handlers) { - handler(event); + if (frame.command === "ERROR") { + if (frame.body?.includes("user_not_allowed")) { + void logout(); } } } @@ -162,6 +224,7 @@ export function WSProvider({ children }: { children: React.ReactNode }) { 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"); } }, []); diff --git a/web/src/lib/stomp.ts b/web/src/lib/stomp.ts new file mode 100644 index 0000000..0cb32ac --- /dev/null +++ b/web/src/lib/stomp.ts @@ -0,0 +1,119 @@ +export type StompFrame = { + command: string; + headers: Record; + 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 = {}; + 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; +}