From 523beeb446a6d37be01a013655c64f3be41b92ae Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Mon, 4 May 2026 05:20:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E6=95=9B=E7=94=A8=E6=88=B7=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E5=B9=B6=E5=85=B1=E4=BA=AB=E9=A1=B9=E7=9B=AE=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端限制系统只保留默认 admin 管理员,新建用户固定为标注员,并拒绝观察员或额外管理员角色。 - 将项目、帧、媒体解析、AI 标注、任务、Dashboard 和导出接口改为共享项目库访问,标注员具备同等项目管理和标注能力。 - 前端用户管理移除角色选择和观察员入口,只展示唯一管理员与标注员状态。 - 更新后端/前端测试,覆盖唯一 admin、旧 viewer 归一为标注员、用户删除和共享项目库访问。 - 同步更新 AGENTS 与 doc 文档中的角色权限、共享项目库和测试计划说明。 --- AGENTS.md | 12 +++---- backend/routers/admin.py | 33 +++++++++++------ backend/routers/ai.py | 45 ++++++++++++----------- backend/routers/auth.py | 51 ++++++++++++++++++++++++-- backend/routers/dashboard.py | 13 +++---- backend/routers/export.py | 6 ++-- backend/routers/media.py | 15 ++------ backend/routers/projects.py | 38 +++++--------------- backend/routers/tasks.py | 16 +++------ backend/tests/test_admin.py | 52 ++++++++++++++++++++++----- backend/tests/test_projects.py | 15 +++++--- doc/02-current-implementation-map.md | 6 ++-- doc/03-frontend-element-audit.md | 4 +-- doc/04-api-contracts.md | 2 +- doc/07-current-requirements-freeze.md | 6 ++-- doc/08-current-design-freeze.md | 6 ++-- doc/09-test-plan.md | 4 +-- doc/10-installation.md | 2 +- src/components/UserAdmin.test.tsx | 18 ++++------ src/components/UserAdmin.tsx | 36 +++++++------------ src/lib/api.test.ts | 6 ++-- 21 files changed, 214 insertions(+), 172 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 906171b..318da80 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -238,9 +238,9 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload ## 主要业务流程 -1. 登录:`Login.tsx` 调用 `POST /api/auth/login`,后端用 `users` 表和密码哈希校验凭证,默认启动时会种子化开发管理员 `admin / 123456`;成功后返回签名 JWT,`GET /api/auth/me` 可读取当前用户;角色包括 `admin`、`annotator`、`viewer`,写入类业务接口要求 `admin/annotator`,用户管理后台要求 `admin`。 -2. 用户管理:`Sidebar` 仅对 `admin` 显示“用户管理”,`UserAdmin.tsx` 调用 `/api/admin/users` 新增、停用/启用、改角色、改密码和删除无项目用户,并调用 `/api/admin/audit-logs` 展示登录和管理操作审计;演示部署可通过“恢复演示出厂设置”二次确认后调用 `/api/admin/demo-factory-reset`,清空演示数据,只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目,同时按内置权威定义恢复“腹腔镜胆囊切除术”和“头颈部CT分割”系统模板,缺失的会重建,被修改或删减的语义分类树会覆盖回默认状态。 -3. 项目管理:`ProjectLibrary.tsx` 调用项目 API 创建项目、拉取列表、重命名项目、复制项目和删除项目;项目卡片删除按钮旁提供复制入口,复制时可选择“新项目重置”(复制项目媒体和已生成帧序列,但清空标注/mask)或“全内容复制”(复制项目、帧序列、标注和关联 mask 元数据),任务运行历史不复制;删除当前项目后会清空工作区当前项目、帧、mask 和选区。 +1. 登录:`Login.tsx` 调用 `POST /api/auth/login`,后端用 `users` 表和密码哈希校验凭证,默认启动时会种子化唯一管理员 `admin / 123456`;成功后返回签名 JWT,`GET /api/auth/me` 可读取当前用户;角色只包括 `admin` 和 `annotator`,非默认 admin 的历史管理员或旧 `viewer` 会归一为 `annotator`;写入类业务接口要求 `admin/annotator`,用户管理、审计日志和演示出厂设置后台仅 `admin` 可用。 +2. 用户管理:`Sidebar` 仅对 `admin` 显示“用户管理”,`UserAdmin.tsx` 调用 `/api/admin/users` 新增标注员、停用/启用、改密码和删除用户,并调用 `/api/admin/audit-logs` 展示登录和管理操作审计;系统不允许新增第二个管理员,也不再支持观察员角色;演示部署可通过“恢复演示出厂设置”二次确认后调用 `/api/admin/demo-factory-reset`,清空演示数据,只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目,同时按内置权威定义恢复“腹腔镜胆囊切除术”和“头颈部CT分割”系统模板,缺失的会重建,被修改或删减的语义分类树会覆盖回默认状态。 +3. 项目管理:`ProjectLibrary.tsx` 调用项目 API 创建项目、拉取列表、重命名项目、复制项目和删除项目;项目库为所有登录用户共享,标注员和管理员在项目创建、导入、解析、标注、AI 推理、任务查看、导出和删除方面能力一致;项目卡片删除按钮旁提供复制入口,复制时可选择“新项目重置”(复制项目媒体和已生成帧序列,但清空标注/mask)或“全内容复制”(复制项目、帧序列、标注和关联 mask 元数据),任务运行历史不复制;删除当前项目后会清空工作区当前项目、帧、mask 和选区。 4. 上传资源:视频走 `/api/media/upload`,只上传源文件并关联项目,不自动拆帧;项目库在视频上传期间显示导入进度条、百分比和已上传字节。只有视频项目在尚未生成帧、未处于项目名称编辑状态且未解析中时显示“生成帧”,DICOM 项目不显示生成帧入口;DICOM 批量走 `/api/media/upload/dicom`,前端和后端都会按文件名自然顺序排序 `.dcm` 文件,避免 `10.dcm` 排在 `2.dcm` 前导致切片错位;DICOM 上传期间显示导入进度条、本次有效文件数量和已上传字节,上传完成后轮询解析任务进度直到完成、失败或取消。 5. 生成帧入队:用户在项目库点击“生成帧”,选择目标 FPS 后前端调用 `/api/media/parse`;后端创建 `ProcessingTask` 并投递 Celery,接口支持 `parse_fps`、`max_frames` 和 `target_width` 标准帧序列参数;项目库会继续轮询任务进度,解析成功后重新拉取项目列表和当前项目对象,使后端生成的 `thumbnail_url` 立即显示为项目封面;项目库和模板库的成功/失败短反馈使用非阻塞 `TransientNotice`,会自动消失。 6. worker 执行:Celery worker 用 FFmpeg 优先拆视频帧,失败后用 OpenCV fallback,DICOM 使用 pydicom;worker 下载和读取 DICOM 时也按文件名自然顺序排序;视频/DICOM 解析完成后都按 `frame_%06d.jpg` 连续生成项目帧序列,并记录 `timestamp_ms`、`source_frame_number` 和任务 `frame_sequence` 元数据,后续工作区、时间轴、AI 传播、标注和导出共用同一套帧序列逻辑。 @@ -317,9 +317,9 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload ## 安全注意事项 - FastAPI 已有真实 `users` 表、密码哈希和签名 JWT;默认 `admin / 123456` 只是开发种子用户,生产部署应通过环境变量或数据库改密。 -- 业务路由会校验 Bearer token;项目、帧、标注、任务、Dashboard 和导出按当前用户拥有的项目过滤,模板支持系统模板(`owner_user_id IS NULL`)和用户模板。 -- 角色分为 `admin`、`annotator`、`viewer`:`admin/annotator` 可调用写入类业务接口,`viewer` 只能调用读接口;`/api/admin/*` 仅允许 `admin`。 -- 管理员后台支持用户新增、停用/启用、改角色、站内弹窗改密码、站内确认删除无项目用户、查看登录/用户管理审计日志,以及站内二次确认后恢复演示出厂设置;禁止管理员删除、停用或降级自己。 +- 业务路由会校验 Bearer token;项目、帧、标注、任务、Dashboard 和导出使用全员共享项目库,模板支持系统模板(`owner_user_id IS NULL`)和用户模板。 +- 角色只分为唯一默认 `admin` 和 `annotator`:`admin/annotator` 可调用写入类业务接口;`/api/admin/*` 仅允许默认 `admin`,包括用户管理、审计日志和演示环境出厂设置。 +- 管理员后台支持新增标注员、停用/启用、站内弹窗改密码、站内确认删除用户、查看登录/用户管理审计日志,以及站内二次确认后恢复演示出厂设置;禁止新增第二个管理员,禁止管理员删除、停用、改名或降级自己。 - JWT 默认开发密钥在 `backend/config.py`,生产部署必须通过 `backend/.env` 覆盖 `JWT_SECRET_KEY`。 - `backend/.env` 被 `.gitignore` 忽略;不要提交真实数据库、MinIO、Redis、模型路径等敏感配置。 - `start_services.sh` 中包含本机路径和 sudo 启动逻辑,迁移机器时要审查。 diff --git a/backend/routers/admin.py b/backend/routers/admin.py index 3891be9..9b4d87d 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -10,7 +10,7 @@ from sqlalchemy.orm import Session from config import settings from database import get_db from models import Annotation, AuditLog, Frame, Mask, ProcessingTask, Project, Template, User -from routers.auth import ensure_default_admin, hash_password, require_admin, write_audit_log +from routers.auth import SUPPORTED_ROLES, ensure_default_admin, hash_password, normalize_user_role, require_admin, write_audit_log from schemas import ( AdminUserCreate, AdminUserUpdate, @@ -30,18 +30,22 @@ from services.default_templates import restore_default_templates router = APIRouter(prefix="/api/admin", tags=["Admin"]) -VALID_ROLES = {"admin", "annotator", "viewer"} DEMO_RESET_CONFIRMATION = "RESET_DEMO_FACTORY" DEMO_PROJECT_NAME = DEMO_DICOM_PROJECT_NAME def _normalize_role(role: str | None) -> str: normalized = (role or "annotator").strip().lower() - if normalized not in VALID_ROLES: + if normalized not in SUPPORTED_ROLES: raise HTTPException(status_code=400, detail=f"Unsupported role: {role}") return normalized +def _assert_non_admin_role(role: str) -> None: + if role == "admin": + raise HTTPException(status_code=400, detail="Only the default admin account can have admin role") + + @router.get("/users", response_model=List[UserOut], summary="List users") def list_users( db: Session = Depends(get_db), @@ -49,7 +53,8 @@ def list_users( ) -> List[User]: """Return all users for the administrator console.""" _ = admin_user - return db.query(User).order_by(User.id).all() + users = db.query(User).order_by(User.id).all() + return [normalize_user_role(db, user) for user in users] @router.post( @@ -69,10 +74,12 @@ def create_user( raise HTTPException(status_code=400, detail="Username is required") if len(payload.password) < 6: raise HTTPException(status_code=400, detail="Password must be at least 6 characters") + role = _normalize_role(payload.role) + _assert_non_admin_role(role) user = User( username=username, password_hash=hash_password(payload.password), - role=_normalize_role(payload.role), + role=role, is_active=1 if payload.is_active else 0, ) db.add(user) @@ -104,6 +111,7 @@ def update_user( user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") + user = normalize_user_role(db, user) updates = payload.model_dump(exclude_unset=True) audit_detail: dict = {"before": {"username": user.username, "role": user.role, "is_active": bool(user.is_active)}} @@ -111,6 +119,8 @@ def update_user( username = (updates["username"] or "").strip() if not username: raise HTTPException(status_code=400, detail="Username is required") + if user.role == "admin" and username != settings.default_admin_username: + raise HTTPException(status_code=400, detail="Default admin username cannot be changed") user.username = username if "password" in updates: password = updates["password"] or "" @@ -119,8 +129,11 @@ def update_user( user.password_hash = hash_password(password) if "role" in updates: next_role = _normalize_role(updates["role"]) - if user.id == admin_user.id and next_role != "admin": - raise HTTPException(status_code=400, detail="Cannot remove your own admin role") + if user.username == settings.default_admin_username: + if next_role != "admin": + raise HTTPException(status_code=400, detail="Cannot remove the default admin role") + else: + _assert_non_admin_role(next_role) user.role = next_role if "is_active" in updates: if user.id == admin_user.id and not updates["is_active"]: @@ -158,9 +171,9 @@ def delete_user( user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") - owned_project_count = db.query(Project).filter(Project.owner_user_id == user_id).count() - if owned_project_count: - raise HTTPException(status_code=409, detail="User owns projects; deactivate or migrate projects first") + user = normalize_user_role(db, user) + if user.role == "admin": + raise HTTPException(status_code=400, detail="Cannot delete the default admin account") username = user.username db.delete(user) db.commit() diff --git a/backend/routers/ai.py b/backend/routers/ai.py index 25709ad..2f27508 100644 --- a/backend/routers/ai.py +++ b/backend/routers/ai.py @@ -45,21 +45,20 @@ GT_IMPORT_CONTOUR_EPSILON_RATIO = 0.00075 GT_IMPORT_MIN_CONTOUR_EPSILON = 0.35 -def _owned_project_or_404(project_id: int, db: Session, current_user: User) -> Project: - project = db.query(Project).filter( - Project.id == project_id, - Project.owner_user_id == current_user.id, - ).first() +def _shared_project_or_404(project_id: int, db: Session, current_user: User) -> Project: + _ = current_user + project = db.query(Project).filter(Project.id == project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") return project -def _owned_frame_or_404(frame_id: int, db: Session, current_user: User, project_id: int | None = None) -> Frame: +def _shared_frame_or_404(frame_id: int, db: Session, current_user: User, project_id: int | None = None) -> Frame: + _ = current_user query = ( db.query(Frame) .join(Project, Project.id == Frame.project_id) - .filter(Frame.id == frame_id, Project.owner_user_id == current_user.id) + .filter(Frame.id == frame_id) ) if project_id is not None: query = query.filter(Frame.project_id == project_id) @@ -480,7 +479,7 @@ def predict( - **interactive**: `prompt_data` is `{ "box": [...], "points": [[x, y]], "labels": [1, 0] }`. - **semantic**: disabled in the current SAM 2.1 point/box product flow. """ - frame = _owned_frame_or_404(payload.image_id, db, current_user) + frame = _shared_frame_or_404(payload.image_id, db, current_user) image = _load_frame_image(frame) prompt_type = payload.prompt_type.lower() @@ -649,7 +648,7 @@ def analyze_mask( ) -> dict: """Return backend-computed mask properties for the frontend inspector.""" if payload.frame_id is not None: - _owned_frame_or_404(payload.frame_id, db, current_user) + _shared_frame_or_404(payload.frame_id, db, current_user) mask_data = payload.mask_data or {} polygons = mask_data.get("polygons") or [] @@ -705,7 +704,7 @@ def smooth_mask( to the current mask, then save through the normal annotation endpoint. """ if payload.frame_id is not None: - _owned_frame_or_404(payload.frame_id, db, current_user) + _shared_frame_or_404(payload.frame_id, db, current_user) polygons = payload.mask_data.get("polygons") or [] valid_polygons = _normalize_polygons(polygons) @@ -751,8 +750,8 @@ def propagate( raise HTTPException(status_code=400, detail="direction must be forward, backward, or both") max_frames = max(1, min(int(payload.max_frames or 30), 500)) - _owned_project_or_404(payload.project_id, db, current_user) - source_frame = _owned_frame_or_404(payload.frame_id, db, current_user, payload.project_id) + _shared_project_or_404(payload.project_id, db, current_user) + source_frame = _shared_frame_or_404(payload.frame_id, db, current_user, payload.project_id) seed = payload.seed.model_dump(exclude_none=True) polygons = seed.get("polygons") or [] @@ -881,8 +880,8 @@ def queue_propagate_task( current_user: User = Depends(require_editor), ) -> ProcessingTaskOut: """Queue multiple seed/direction propagation steps as one background task.""" - _owned_project_or_404(payload.project_id, db, current_user) - source_frame = _owned_frame_or_404(payload.frame_id, db, current_user, payload.project_id) + _shared_project_or_404(payload.project_id, db, current_user) + source_frame = _shared_frame_or_404(payload.frame_id, db, current_user, payload.project_id) if not payload.steps: raise HTTPException(status_code=400, detail="Propagation task requires at least one step") @@ -936,7 +935,7 @@ def auto_segment( current_user: User = Depends(require_editor), ) -> dict: """Run automatic mask generation on a frame using a grid of point prompts.""" - frame = _owned_frame_or_404(image_id, db, current_user) + frame = _shared_frame_or_404(image_id, db, current_user) image = _load_frame_image(frame) try: @@ -959,10 +958,10 @@ def save_annotation( current_user: User = Depends(require_editor), ) -> Annotation: """Persist an annotation (mask, points, bbox) into the database.""" - _owned_project_or_404(payload.project_id, db, current_user) + _shared_project_or_404(payload.project_id, db, current_user) if payload.frame_id: - _owned_frame_or_404(payload.frame_id, db, current_user, payload.project_id) + _shared_frame_or_404(payload.frame_id, db, current_user, payload.project_id) if payload.template_id: _visible_template_or_404(payload.template_id, db, current_user) @@ -998,8 +997,8 @@ async def import_gt_mask( the frontend an editable point-region representation instead of a static bitmap layer. """ - _owned_project_or_404(project_id, db, current_user) - frame = _owned_frame_or_404(frame_id, db, current_user, project_id) + _shared_project_or_404(project_id, db, current_user) + frame = _shared_frame_or_404(frame_id, db, current_user, project_id) if unknown_color_policy not in {"discard", "undefined"}: raise HTTPException(status_code=400, detail="unknown_color_policy must be discard or undefined") @@ -1143,11 +1142,11 @@ def list_annotations( current_user: User = Depends(get_current_user), ) -> List[Annotation]: """Return persisted annotations for a project, optionally scoped to one frame.""" - _owned_project_or_404(project_id, db, current_user) + _shared_project_or_404(project_id, db, current_user) query = db.query(Annotation).filter(Annotation.project_id == project_id) if frame_id is not None: - _owned_frame_or_404(frame_id, db, current_user, project_id) + _shared_frame_or_404(frame_id, db, current_user, project_id) query = query.filter(Annotation.frame_id == frame_id) return query.order_by(Annotation.id).all() @@ -1167,7 +1166,7 @@ def update_annotation( annotation = ( db.query(Annotation) .join(Project, Project.id == Annotation.project_id) - .filter(Annotation.id == annotation_id, Project.owner_user_id == current_user.id) + .filter(Annotation.id == annotation_id) .first() ) if not annotation: @@ -1200,7 +1199,7 @@ def delete_annotation( annotation = ( db.query(Annotation) .join(Project, Project.id == Annotation.project_id) - .filter(Annotation.id == annotation_id, Project.owner_user_id == current_user.id) + .filter(Annotation.id == annotation_id) .first() ) if not annotation: diff --git a/backend/routers/auth.py b/backend/routers/auth.py index cf7ec3e..f65cbf5 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -18,6 +18,7 @@ from schemas import LoginResponse, UserOut router = APIRouter(prefix="/api/auth", tags=["Auth"]) password_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") bearer_scheme = HTTPBearer(auto_error=False) +SUPPORTED_ROLES = {"admin", "annotator"} class LoginRequest(BaseModel): @@ -50,9 +51,26 @@ def create_access_token(user: User, expires_delta: timedelta | None = None) -> s def ensure_default_admin(db: Session) -> User: - """Create the default development admin if the user table is empty.""" + """Create and enforce the single default administrator account.""" existing = db.query(User).filter(User.username == settings.default_admin_username).first() if existing: + changed = False + if existing.role != "admin": + existing.role = "admin" + changed = True + if not existing.is_active: + existing.is_active = 1 + changed = True + extra_admins = db.query(User).filter( + User.role == "admin", + User.id != existing.id, + ).all() + for user in extra_admins: + user.role = "annotator" + changed = True + if changed: + db.commit() + db.refresh(existing) return existing user = User( username=settings.default_admin_username, @@ -63,6 +81,31 @@ def ensure_default_admin(db: Session) -> User: db.add(user) db.commit() db.refresh(user) + extra_admins = db.query(User).filter( + User.role == "admin", + User.id != user.id, + ).all() + if extra_admins: + for extra_user in extra_admins: + extra_user.role = "annotator" + db.commit() + db.refresh(user) + return user + + +def normalize_user_role(db: Session, user: User) -> User: + """Keep legacy accounts within the current two-role policy.""" + desired_role = "admin" if user.username == settings.default_admin_username else "annotator" + changed = False + if user.role != desired_role: + user.role = desired_role + changed = True + if user.username == settings.default_admin_username and not user.is_active: + user.is_active = 1 + changed = True + if changed: + db.commit() + db.refresh(user) return user @@ -92,6 +135,8 @@ def get_current_user( ) from exc user = db.query(User).filter(User.id == user_id).first() + if user: + user = normalize_user_role(db, user) if not user or not user.is_active: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -110,7 +155,7 @@ def require_admin(current_user: User = Depends(get_current_user)) -> User: def require_editor(current_user: User = Depends(get_current_user)) -> User: """Require a user role that can modify segmentation data.""" - if current_user.role not in {"admin", "annotator"}: + if current_user.role not in SUPPORTED_ROLES: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Edit permission required") return current_user @@ -143,6 +188,8 @@ def login(payload: LoginRequest, db: Session = Depends(get_db)) -> dict: """Authenticate a user and return a signed JWT.""" ensure_default_admin(db) user = db.query(User).filter(User.username == payload.username).first() + if user: + user = normalize_user_role(db, user) if not user or not user.is_active or not verify_password(payload.password, user.password_hash): write_audit_log( db, diff --git a/backend/routers/dashboard.py b/backend/routers/dashboard.py index 58b6e88..6f09f91 100644 --- a/backend/routers/dashboard.py +++ b/backend/routers/dashboard.py @@ -58,12 +58,12 @@ def get_dashboard_overview( current_user: User = Depends(get_current_user), ) -> dict[str, Any]: """Return live dashboard data derived from persisted backend records.""" - owned_project_ids_query = db.query(Project.id).filter(Project.owner_user_id == current_user.id) - project_count = db.query(func.count(Project.id)).filter(Project.owner_user_id == current_user.id).scalar() or 0 - frame_count = db.query(func.count(Frame.id)).filter(Frame.project_id.in_(owned_project_ids_query)).scalar() or 0 + shared_project_ids_query = db.query(Project.id) + project_count = db.query(func.count(Project.id)).scalar() or 0 + frame_count = db.query(func.count(Frame.id)).filter(Frame.project_id.in_(shared_project_ids_query)).scalar() or 0 annotation_count = ( db.query(func.count(Annotation.id)) - .filter(Annotation.project_id.in_(owned_project_ids_query)) + .filter(Annotation.project_id.in_(shared_project_ids_query)) .scalar() or 0 ) @@ -76,7 +76,6 @@ def get_dashboard_overview( active_task_count = ( db.query(func.count(ProcessingTask.id)) .outerjoin(Project, Project.id == ProcessingTask.project_id) - .filter((ProcessingTask.project_id.is_(None)) | (Project.owner_user_id == current_user.id)) .filter(ProcessingTask.status.in_(ACTIVE_TASK_STATUSES)) .scalar() or 0 @@ -84,14 +83,12 @@ def get_dashboard_overview( projects = ( db.query(Project) - .filter(Project.owner_user_id == current_user.id) .order_by(Project.updated_at.desc()) .all() ) recent_tasks = ( db.query(ProcessingTask) .outerjoin(Project, Project.id == ProcessingTask.project_id) - .filter((ProcessingTask.project_id.is_(None)) | (Project.owner_user_id == current_user.id)) .order_by(ProcessingTask.created_at.desc()) .limit(50) .all() @@ -120,7 +117,7 @@ def get_dashboard_overview( recent_annotations = ( db.query(Annotation) - .filter(Annotation.project_id.in_(owned_project_ids_query)) + .filter(Annotation.project_id.in_(shared_project_ids_query)) .order_by(Annotation.updated_at.desc()) .limit(10) .all() diff --git a/backend/routers/export.py b/backend/routers/export.py index 49ac43a..ebc2514 100644 --- a/backend/routers/export.py +++ b/backend/routers/export.py @@ -206,10 +206,8 @@ def _frame_image_extension(frame: Frame) -> str: def _project_or_404(project_id: int, db: Session, current_user: User) -> Project: - project = db.query(Project).filter( - Project.id == project_id, - Project.owner_user_id == current_user.id, - ).first() + _ = current_user + project = db.query(Project).filter(Project.id == project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") return project diff --git a/backend/routers/media.py b/backend/routers/media.py index 42d4a1f..b476d62 100644 --- a/backend/routers/media.py +++ b/backend/routers/media.py @@ -72,10 +72,7 @@ async def upload_media( file_url = get_presigned_url(object_name, expires=3600) if project_id: - project = db.query(Project).filter( - Project.id == project_id, - Project.owner_user_id == current_user.id, - ).first() + project = db.query(Project).filter(Project.id == project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") project.video_path = object_name @@ -141,10 +138,7 @@ async def upload_dicom_batch( uploaded = [] if project_id: - project = db.query(Project).filter( - Project.id == project_id, - Project.owner_user_id == current_user.id, - ).first() + project = db.query(Project).filter(Project.id == project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") else: @@ -202,10 +196,7 @@ def parse_media( The Celery worker performs the heavy FFmpeg/OpenCV/pydicom work and updates the persisted task record as it progresses. """ - project = db.query(Project).filter( - Project.id == project_id, - Project.owner_user_id == current_user.id, - ).first() + project = db.query(Project).filter(Project.id == project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") diff --git a/backend/routers/projects.py b/backend/routers/projects.py index dc21f08..08e4589 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -16,12 +16,12 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["Projects"]) -def _next_project_copy_name(db: Session, owner_user_id: int, source_name: str) -> str: +def _next_project_copy_name(db: Session, source_name: str) -> str: base_name = f"{source_name} 副本" existing_names = { row[0] for row in db.query(Project.name) - .filter(Project.owner_user_id == owner_user_id, Project.name.like(f"{base_name}%")) + .filter(Project.name.like(f"{base_name}%")) .all() } if base_name not in existing_names: @@ -76,7 +76,6 @@ def list_projects( """Retrieve a paginated list of projects.""" projects = ( db.query(Project) - .filter(Project.owner_user_id == current_user.id) .offset(skip) .limit(limit) .all() @@ -97,10 +96,7 @@ def get_project( current_user: User = Depends(get_current_user), ) -> Project: """Retrieve a project by its ID.""" - project = db.query(Project).filter( - Project.id == project_id, - Project.owner_user_id == current_user.id, - ).first() + project = db.query(Project).filter(Project.id == project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") return _prepare_project_response(project) @@ -119,16 +115,13 @@ def copy_project( current_user: User = Depends(require_editor), ) -> Project: """Copy a project. Reset copies media/frame sequence; full also copies annotations and mask metadata.""" - source = db.query(Project).filter( - Project.id == project_id, - Project.owner_user_id == current_user.id, - ).first() + source = db.query(Project).filter(Project.id == project_id).first() if not source: raise HTTPException(status_code=404, detail="Project not found") next_name = (payload.name or "").strip() if payload.name is not None else "" if not next_name: - next_name = _next_project_copy_name(db, current_user.id, source.name) + next_name = _next_project_copy_name(db, source.name) copied = Project( name=next_name, @@ -196,10 +189,7 @@ def update_project( current_user: User = Depends(require_editor), ) -> Project: """Update project fields partially.""" - project = db.query(Project).filter( - Project.id == project_id, - Project.owner_user_id == current_user.id, - ).first() + project = db.query(Project).filter(Project.id == project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") @@ -227,10 +217,7 @@ def delete_project( current_user: User = Depends(require_editor), ) -> None: """Delete a project and all related frames and annotations.""" - project = db.query(Project).filter( - Project.id == project_id, - Project.owner_user_id == current_user.id, - ).first() + project = db.query(Project).filter(Project.id == project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") @@ -255,10 +242,7 @@ def create_frame( current_user: User = Depends(require_editor), ) -> Frame: """Register a new frame under a project.""" - project = db.query(Project).filter( - Project.id == project_id, - Project.owner_user_id == current_user.id, - ).first() + project = db.query(Project).filter(Project.id == project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") @@ -282,10 +266,7 @@ def list_frames( current_user: User = Depends(get_current_user), ) -> List[Frame]: """Retrieve all frames belonging to a project.""" - project = db.query(Project).filter( - Project.id == project_id, - Project.owner_user_id == current_user.id, - ).first() + project = db.query(Project).filter(Project.id == project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") @@ -320,7 +301,6 @@ def get_frame( .filter( Frame.project_id == project_id, Frame.id == frame_id, - Project.owner_user_id == current_user.id, ) .first() ) diff --git a/backend/routers/tasks.py b/backend/routers/tasks.py index 589eca3..2995c58 100644 --- a/backend/routers/tasks.py +++ b/backend/routers/tasks.py @@ -33,13 +33,11 @@ def _now() -> datetime: def _get_task_or_404(task_id: int, db: Session, current_user: User) -> ProcessingTask: + _ = current_user task = ( db.query(ProcessingTask) .outerjoin(Project, Project.id == ProcessingTask.project_id) - .filter( - ProcessingTask.id == task_id, - (ProcessingTask.project_id.is_(None)) | (Project.owner_user_id == current_user.id), - ) + .filter(ProcessingTask.id == task_id) .first() ) if not task: @@ -60,9 +58,8 @@ def list_tasks( current_user: User = Depends(get_current_user), ) -> List[ProcessingTask]: """Return recent background processing tasks.""" - query = db.query(ProcessingTask).outerjoin(Project, Project.id == ProcessingTask.project_id).filter( - (ProcessingTask.project_id.is_(None)) | (Project.owner_user_id == current_user.id) - ) + _ = current_user + query = db.query(ProcessingTask).outerjoin(Project, Project.id == ProcessingTask.project_id) if project_id is not None: query = query.filter(ProcessingTask.project_id == project_id) if status is not None: @@ -130,10 +127,7 @@ def retry_task( if previous.project_id is None: raise HTTPException(status_code=400, detail="Task has no project_id") - project = db.query(Project).filter( - Project.id == previous.project_id, - Project.owner_user_id == current_user.id, - ).first() + project = db.query(Project).filter(Project.id == previous.project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") is_propagation_task = previous.task_type == "propagate_masks" diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py index a3c74fe..f621551 100644 --- a/backend/tests/test_admin.py +++ b/backend/tests/test_admin.py @@ -12,14 +12,14 @@ def test_admin_user_management_and_audit_logs(client, db_session): }) assert created.status_code == 201 user_id = created.json()["id"] + assert created.json()["role"] == "annotator" updated = client.patch(f"/api/admin/users/{user_id}", json={ - "role": "viewer", "password": "newsecret", "is_active": False, }) assert updated.status_code == 200 - assert updated.json()["role"] == "viewer" + assert updated.json()["role"] == "annotator" assert updated.json()["is_active"] == 0 users = client.get("/api/admin/users") @@ -37,8 +37,41 @@ def test_admin_user_management_and_audit_logs(client, db_session): assert "admin.user_deleted" in actions +def test_only_default_admin_role_is_supported(client, db_session): + extra_admin = client.post("/api/admin/users", json={ + "username": "chief", + "password": "secret123", + "role": "admin", + "is_active": True, + }) + assert extra_admin.status_code == 400 + + viewer = client.post("/api/admin/users", json={ + "username": "observer", + "password": "secret123", + "role": "viewer", + "is_active": True, + }) + assert viewer.status_code == 400 + + created = client.post("/api/admin/users", json={ + "username": "doctor", + "password": "secret123", + "is_active": True, + }) + assert created.status_code == 201 + user_id = created.json()["id"] + assert created.json()["role"] == "annotator" + assert client.patch(f"/api/admin/users/{user_id}", json={"role": "admin"}).status_code == 400 + assert client.patch(f"/api/admin/users/{user_id}", json={"role": "viewer"}).status_code == 400 + + admin_id = client.get("/api/auth/me").json()["id"] + assert client.patch(f"/api/admin/users/{admin_id}", json={"role": "annotator"}).status_code == 400 + assert client.patch(f"/api/admin/users/{admin_id}", json={"username": "chief"}).status_code == 400 + + def test_admin_routes_require_admin_role(client, db_session): - user = User(username="viewer", password_hash=hash_password("secret123"), role="viewer", is_active=1) + user = User(username="doctor", password_hash=hash_password("secret123"), role="annotator", is_active=1) db_session.add(user) db_session.commit() db_session.refresh(user) @@ -51,7 +84,7 @@ def test_admin_routes_require_admin_role(client, db_session): client.headers.update({"Authorization": original_auth}) -def test_viewer_role_is_read_only_for_business_mutations(client, db_session): +def test_legacy_viewer_role_is_promoted_to_annotator(client, db_session): project = client.post("/api/projects", json={"name": "Readonly Check"}).json() user = User(username="readonly", password_hash=hash_password("secret123"), role="viewer", is_active=1) db_session.add(user) @@ -61,14 +94,14 @@ def test_viewer_role_is_read_only_for_business_mutations(client, db_session): client.headers.update({"Authorization": f"Bearer {create_access_token(user)}"}) try: assert client.get("/api/projects").status_code == 200 - assert client.post("/api/projects", json={"name": "Nope"}).status_code == 403 - assert client.patch(f"/api/projects/{project['id']}", json={"name": "Nope"}).status_code == 403 - assert client.post("/api/ai/annotate", json={"project_id": project["id"]}).status_code == 403 + assert client.post("/api/projects", json={"name": "Annotator Project"}).status_code == 201 + assert client.patch(f"/api/projects/{project['id']}", json={"name": "Shared Edit"}).status_code == 200 + assert client.get("/api/auth/me").json()["role"] == "annotator" finally: client.headers.update({"Authorization": original_auth}) -def test_admin_cannot_delete_self_or_user_with_projects(client, db_session): +def test_admin_cannot_delete_self_but_can_delete_project_author(client, db_session): me = client.get("/api/auth/me").json() assert client.delete(f"/api/admin/users/{me['id']}").status_code == 400 @@ -80,7 +113,8 @@ def test_admin_cannot_delete_self_or_user_with_projects(client, db_session): db_session.commit() response = client.delete(f"/api/admin/users/{user.id}") - assert response.status_code == 409 + assert response.status_code == 204 + assert db_session.query(Project).filter(Project.name == "Owned").count() == 1 def test_demo_factory_reset_leaves_admin_and_parsed_demo_dicom(client, db_session, monkeypatch, tmp_path): diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index 7703bf9..ee53010 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -186,7 +186,7 @@ def test_project_and_frame_404s(client): assert client.get("/api/projects/999/frames/1").status_code == 404 -def test_projects_are_scoped_to_authenticated_owner(client, db_session): +def test_projects_are_shared_between_authenticated_users(client, db_session): owner_project = client.post("/api/projects", json={"name": "Owner Project"}).json() other_user = User( username="other", @@ -203,14 +203,19 @@ def test_projects_are_scoped_to_authenticated_owner(client, db_session): db_session.refresh(other_project) listing = client.get("/api/projects") - assert [project["id"] for project in listing.json()] == [owner_project["id"]] - assert client.get(f"/api/projects/{other_project.id}").status_code == 404 + assert {project["id"] for project in listing.json()} == {owner_project["id"], other_project.id} + assert client.get(f"/api/projects/{other_project.id}").status_code == 200 original_auth = client.headers["Authorization"] client.headers.update({"Authorization": f"Bearer {create_access_token(other_user)}"}) try: other_listing = client.get("/api/projects") - assert [project["id"] for project in other_listing.json()] == [other_project.id] - assert client.get(f"/api/projects/{owner_project['id']}").status_code == 404 + assert {project["id"] for project in other_listing.json()} == {owner_project["id"], other_project.id} + assert client.get(f"/api/projects/{owner_project['id']}").status_code == 200 + renamed = client.patch(f"/api/projects/{owner_project['id']}", json={"name": "Edited By Other"}) + assert renamed.status_code == 200 + assert renamed.json()["name"] == "Edited By Other" finally: client.headers.update({"Authorization": original_auth}) + + assert client.get(f"/api/projects/{owner_project['id']}").json()["name"] == "Edited By Other" diff --git a/doc/02-current-implementation-map.md b/doc/02-current-implementation-map.md index 7bcd02b..371beec 100644 --- a/doc/02-current-implementation-map.md +++ b/doc/02-current-implementation-map.md @@ -64,12 +64,12 @@ 3. FastAPI `backend/routers/auth.py` 查询 `users` 表并校验密码哈希。 4. 前端把返回 JWT 写入 localStorage,并把用户资料写入 store。 5. 后续业务请求带 `Authorization: Bearer `,后端按当前用户过滤项目资源。 -6. `admin/annotator` 可调用写入类业务接口,`viewer` 只能读取;`/api/admin/*` 仅允许 `admin`。 +6. 系统只支持唯一默认 `admin` 和 `annotator`;`admin/annotator` 可调用写入类业务接口;`/api/admin/*` 仅允许默认 `admin`。 ### 管理员用户管理 1. `Sidebar.tsx` 仅对 `currentUser.role === 'admin'` 显示“用户管理”。 -2. `UserAdmin.tsx` 调用 `GET/POST/PATCH/DELETE /api/admin/users` 完成用户新增、停用/启用、角色修改、改密码和删除无项目用户。 +2. `UserAdmin.tsx` 调用 `GET/POST/PATCH/DELETE /api/admin/users` 完成标注员新增、停用/启用、改密码和删除用户;不提供观察员或第二个管理员入口。 3. `UserAdmin.tsx` 调用 `GET /api/admin/audit-logs` 展示登录成功/失败以及用户管理操作审计。 4. `UserAdmin.tsx` 危险区“恢复演示出厂设置”需要浏览器确认和输入 `RESET_DEMO_FACTORY`,随后调用 `POST /api/admin/demo-factory-reset`。 5. 后端 `backend/routers/admin.py` 会阻止管理员删除、停用或降级自己,并阻止删除仍拥有项目的用户;演示出厂重置会清空其它用户、项目帧、标注、任务和私有模板,重新创建演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目。 @@ -114,4 +114,4 @@ - AI 当前启用 SAM 2.1 tiny/small/base+/large 点/框/interactive 路径;语义文本提示和 SAM 3 产品入口已禁用,`model=sam3` 会被后端拒绝。SAM 3 源码保留但不计入当前可用功能。 - 工作区顶部“分割结果导出”和保存状态按钮、左侧工具栏“导入 GT Mask”已接入统一导出、GT 多类别导入、标注新增和 dirty 标注更新;导入 GT Mask 仅支持 8-bit 二值/灰度 maskid 图和 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,未知 maskid 可由用户选择舍弃或导入为未定义类别,16-bit/uint16 GT_label 和普通彩色类别图会被拒绝,尺寸不同会自动最近邻拉伸到当前帧;GT 连通域会生成高精度 polygon,导入后和普通 mask 一样不显示黄色 seed point,并与普通 mask 共用拓扑统计、边缘平滑、编辑和保存链路。保存状态按钮会按待保存数量显示“保存 X 个改动”或“已全部保存”;统一导出可选择整体视频、特定范围帧或当前图片,并勾选分开 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图;特定范围帧导出支持直接输入起止帧,也支持在播放进度条或视频处理进度条上点击/拖拽选择范围;Mix_label 支持默认 0.3 的透明度调节和首帧预览;后端统一导出 ZIP 固定包含 maskid/GT 像素值映射 JSON 与原始图片文件夹,GT_label 固定输出 8-bit uint8 PNG,像素值使用类别真实 maskid,其中 `maskid:0` 的“待分类”和背景同为 0,缺失 maskid 的旧标注才补下一个可用正整数,正整数 maskid 超出 1-255 会拒绝导出,并按客户命名规则输出分开 Mask、GT_label、Pro_label 和 Mix_label 文件夹;清空当前帧遮罩会删除对应后端标注,存在传播链时同一弹窗提供取消/当前帧/按帧范围选择/所有传播帧,按范围清空复用时间轴范围选择和最终确认;按范围或全部清空遇到人工/AI 标注帧时会二次确认,选择保留则整帧保留。手工绘制、polygon 顶点拖动/删除、区域合并/去除和撤销重做已经落到前端 mask 数据结构;多边形、矩形、圆和画笔创建遵循“有选中 mask 则并入选中 mask、无选中 mask 才新建”的规则,即使新几何和旧区域不重叠也会组成同一个多 polygon mask;无选中分类的新建多边形/矩形/圆会默认归入 `maskid:0` 的“待分类”,画笔无选中 mask 时仍要求右侧语义分类树有 active class;`Esc` 只取消选区和临时绘制状态,不删除已有 mask。 - Dashboard 初始统计、队列和活动日志来自后端聚合接口;解析队列来自 `processing_tasks`,worker 进度通过 Redis `seg:progress` 转发到 WebSocket。任务取消、重试和失败详情已接入前后端。 -- 后端已接入 Bearer JWT 鉴权、当前用户项目隔离和角色权限;写入类业务接口要求 `admin/annotator`,管理员用户后台要求 `admin`。当前审计覆盖登录和用户管理操作,全业务级审计仍可继续扩展。 +- 后端已接入 Bearer JWT 鉴权、共享项目库和角色权限;写入类业务接口要求 `admin/annotator`,管理员用户后台要求默认 `admin`。当前审计覆盖登录和用户管理操作,全业务级审计仍可继续扩展。 diff --git a/doc/03-frontend-element-audit.md b/doc/03-frontend-element-audit.md index 4c11435..5acb912 100644 --- a/doc/03-frontend-element-audit.md +++ b/doc/03-frontend-element-audit.md @@ -32,8 +32,8 @@ |------|------|------| | 侧栏“用户管理”入口 | 真实可用 | 仅当前用户 `role=admin` 时显示;非管理员无法看到入口,后端 `/api/admin/*` 也会返回 403 | | 用户列表 | 真实可用 | 调用 `GET /api/admin/users`,展示用户 id、用户名、角色、启停用状态和创建时间 | -| 新增用户 | 真实可用 | 调用 `POST /api/admin/users`,支持设置用户名、初始密码和 `admin/annotator/viewer` 角色;后端校验用户名唯一和密码长度 | -| 修改角色 / 启停用 / 改密码 | 真实可用 | 调用 `PATCH /api/admin/users/{id}`;后端禁止管理员把自己降级或停用,避免锁死后台 | +| 新增用户 | 真实可用 | 调用 `POST /api/admin/users`,支持设置用户名和初始密码,新用户固定为标注员;后端校验用户名唯一、密码长度,并拒绝第二个管理员或观察员角色 | +| 启停用 / 改密码 | 真实可用 | 调用 `PATCH /api/admin/users/{id}`;后端禁止管理员把自己降级、改名或停用,避免锁死后台 | | 删除用户 | 真实可用 | 调用 `DELETE /api/admin/users/{id}`;后端禁止删除自己,且用户名下仍有项目时返回 409,避免悬空项目数据 | | 审计日志 | 真实可用 | 调用 `GET /api/admin/audit-logs`,展示登录成功/失败、用户新增、修改和删除等管理操作 | | 恢复演示出厂设置 | 真实可用 | 管理员点击危险区按钮后先浏览器确认,再输入 `RESET_DEMO_FACTORY`;前端调用 `POST /api/admin/demo-factory-reset`,后端只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目,并清空用户、项目帧、标注、任务和私有模板等演示数据;“腹腔镜胆囊切除术”和“头颈部CT分割”系统模板会按内置默认定义重建或覆盖恢复 | diff --git a/doc/04-api-contracts.md b/doc/04-api-contracts.md index a98b252..7f88885 100644 --- a/doc/04-api-contracts.md +++ b/doc/04-api-contracts.md @@ -15,7 +15,7 @@ timeout: 30000 Authorization: Bearer ``` -当前后端业务接口会校验该 header。缺失、过期或无效 token 返回 401;项目、帧、标注、任务、Dashboard 和导出会按当前用户拥有的项目过滤。 +当前后端业务接口会校验该 header。缺失、过期或无效 token 返回 401;项目、帧、标注、任务、Dashboard 和导出使用全员共享项目库,所有登录用户可读取,`admin/annotator` 可写入。 ## 前端封装的 API diff --git a/doc/07-current-requirements-freeze.md b/doc/07-current-requirements-freeze.md index 4f6b9ad..c512bed 100644 --- a/doc/07-current-requirements-freeze.md +++ b/doc/07-current-requirements-freeze.md @@ -12,9 +12,9 @@ - 页面刷新后前端会用已有 token 调用 `/api/auth/me` 恢复当前用户。 - 登录失败时显示错误信息。 - 业务接口必须校验 Bearer token;缺失或无效 token 返回 401。 -- 项目、帧、标注、任务、Dashboard 和导出必须按当前用户的项目隔离;用户不能读取、修改或删除其他用户项目资源。 -- 角色包括 `admin`、`annotator` 和 `viewer`;`admin/annotator` 可写入业务数据和触发 AI/传播,`viewer` 只能访问读接口,用户管理后台仅 `admin` 可用。 -- 管理员侧栏显示“用户管理”入口;管理员可以新增用户、修改角色、停用/启用、修改密码、删除无项目用户。 +- 项目、帧、标注、任务、Dashboard 和导出使用全员共享项目库;所有登录用户读取同一项目库,`admin/annotator` 可创建、导入、解析、标注、AI 推理、导出、复制、重命名和删除项目。 +- 角色只包括唯一默认 `admin` 和 `annotator`;历史 `viewer` 或额外管理员会归一为标注员;用户管理、审计日志和演示环境出厂设置后台仅默认 `admin` 可用。 +- 管理员侧栏显示“用户管理”入口;管理员可以新增标注员、停用/启用、修改密码、删除用户。 - 系统记录登录成功/失败和用户管理操作到 `audit_logs`,管理员后台可查看最近审计日志。 - 管理员后台提供“恢复演示出厂设置”危险操作;前端必须二次确认,后端也必须校验 `confirmation=RESET_DEMO_FACTORY`,执行后只保留默认 admin 账号、系统模板、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目,清空其它用户、项目、帧、标注、任务、用户模板和旧审计记录,并写入本次重置审计。 - 系统默认模板至少包含“腹腔镜胆囊切除术”和“头颈部CT分割”;头颈部 CT 默认分类名必须使用纯中文,不带括号英文翻译;恢复演示出厂设置不得删除系统默认模板,并必须重建缺失的默认模板、覆盖恢复被修改或删减的默认语义分类树。 diff --git a/doc/08-current-design-freeze.md b/doc/08-current-design-freeze.md index bf56865..351f849 100644 --- a/doc/08-current-design-freeze.md +++ b/doc/08-current-design-freeze.md @@ -84,11 +84,11 @@ ### 用户隔离 -1. `Project.owner_user_id` 指向 `users.id`;启动时默认 admin 用户会被创建,历史 `owner_user_id IS NULL` 的项目会迁移归属到 admin。 +1. `Project.owner_user_id` 指向 `users.id` 作为创建者/历史元数据保留;项目库访问不再按该字段隔离,所有登录用户共享同一项目库。 2. 项目、帧、媒体上传/拆帧、AI 标注、传播任务、任务列表、Dashboard 和导出接口都通过当前 JWT 用户过滤项目资源。 3. `Template.owner_user_id` 支持用户模板;`owner_user_id IS NULL` 的模板视为系统模板,可作为默认分类体系对用户可见。 -4. 角色分为 `admin`、`annotator`、`viewer`:`admin/annotator` 可调用写入类业务接口,`viewer` 只能调用读接口;`/api/admin/*` 仅允许 `admin`。 -5. `UserAdmin.tsx` 仅在当前用户角色为 `admin` 时从 `Sidebar` 展示,调用 `/api/admin/users` 完成新增、角色修改、停用/启用、密码修改和删除无项目用户,调用 `/api/admin/audit-logs` 展示登录和管理操作审计;改密码、删除用户和危险区“恢复演示出厂设置”均使用站内弹窗确认,恢复出厂设置要求输入 `RESET_DEMO_FACTORY` 后调用 `/api/admin/demo-factory-reset`。 +4. 角色只分为唯一默认 `admin` 和 `annotator`:`admin/annotator` 可调用写入类业务接口;`/api/admin/*` 仅允许默认 `admin`,用于用户管理、审计日志和演示环境出厂设置。 +5. `UserAdmin.tsx` 仅在当前用户角色为 `admin` 时从 `Sidebar` 展示,调用 `/api/admin/users` 完成标注员新增、停用/启用、密码修改和删除用户,调用 `/api/admin/audit-logs` 展示登录和管理操作审计;改密码、删除用户和危险区“恢复演示出厂设置”均使用站内弹窗确认,恢复出厂设置要求输入 `RESET_DEMO_FACTORY` 后调用 `/api/admin/demo-factory-reset`。 6. `POST /api/admin/demo-factory-reset` 仅允许 `admin`,会重置默认 admin 密码/角色/启用状态,删除其它用户、项目、帧、标注、mask、任务、用户模板和旧审计,重新创建 `settings.demo_video_path` 指向的演示视频项目,以及 `settings.demo_dicom_dir` 指向的演示 DICOM 项目;DICOM 会按文件名自然顺序上传和生成帧;系统模板保留以保证重置后仍可标注。 7. 缺失、过期或伪造的 Bearer token 会在业务路由返回 401,权限不足返回 403,其他用户项目资源对当前用户表现为 404。 diff --git a/doc/09-test-plan.md b/doc/09-test-plan.md index d387f80..9a3ba39 100644 --- a/doc/09-test-plan.md +++ b/doc/09-test-plan.md @@ -14,7 +14,7 @@ | 需求 | 测试文件 | 覆盖点 | |------|----------|--------| -| R1 登录与会话 | `src/components/Login.test.tsx`, `src/components/Sidebar.test.tsx`, `src/components/UserAdmin.test.tsx`, `src/store/useStore.test.ts`, `backend/tests/test_auth.py`, `backend/tests/test_admin.py` | 成功登录、JWT/token 写入、当前用户写入、刷新恢复基础状态、失败提示、登录输入 autocomplete、后端 401、`/api/auth/me`、管理员入口、用户 CRUD、角色权限、审计日志、viewer 读写权限边界、改密码/删除用户站内确认、演示出厂设置站内二次确认和重置结果 | +| R1 登录与会话 | `src/components/Login.test.tsx`, `src/components/Sidebar.test.tsx`, `src/components/UserAdmin.test.tsx`, `src/store/useStore.test.ts`, `backend/tests/test_auth.py`, `backend/tests/test_admin.py` | 成功登录、JWT/token 写入、当前用户写入、刷新恢复基础状态、失败提示、登录输入 autocomplete、后端 401、`/api/auth/me`、管理员入口、用户 CRUD、唯一 admin/标注员角色权限、审计日志、旧 viewer 归一为标注员、改密码/删除用户站内确认、演示出厂设置站内二次确认和重置结果 | | R2 项目管理 | `src/lib/api.test.ts`, `src/components/ProjectLibrary.test.tsx`, `backend/tests/test_projects.py` | 前端字段映射、PATCH 更新、项目卡片复制/删除、修改项目名称时隐藏生成帧、DICOM 项目不显示生成帧、复制项目 reset/full 契约、DELETE 契约、后端 CRUD、删除级联、帧列表、项目按当前 JWT 用户隔离 | | R3 媒体上传与拆帧 | `src/components/ProjectLibrary.test.tsx`, `src/components/TransientNotice.test.tsx`, `backend/tests/test_media.py`, `backend/tests/test_tasks.py` | 视频导入不自动拆帧、视频/DICOM 上传进度可视化、DICOM 导入显示有效文件数量并在上传后持续显示解析任务进度、显式生成帧 FPS 选择、视频生成帧入队后轮询解析任务并在成功后自动刷新项目封面、项目卡片显示目标 parse_fps 而非原视频 FPS、扩展名校验、自动建项目、关联项目、创建异步任务、非阻塞自动消失操作提示、标准帧序列参数、帧时间戳/源帧号、任务序列元数据、worker 注册帧、取消任务、重试任务、取消后 worker 停止 | | R4 工作区与帧浏览 | `src/components/VideoWorkspace.test.tsx`, `src/components/FrameTimeline.test.tsx` | 加载帧、无帧项目不自动解析并提示生成帧、工作区短状态自动消失、工作区/AI 画布底图默认居中且保留边距、工作区 mask 透明度、回显已保存标注时保留本地未保存 draft mask、选中 mask 后跨帧自动跟随同一传播链结果、左侧工具栏清空遮罩优先作用于当前帧选中 mask/无选中时作用于当前帧全部 mask、无传播链时直接执行、有传播链时可选取消/只清当前帧/按帧范围选择/清空所有传播帧且按范围清空需最终确认、按范围清空或清空所有传播帧遇到人工/AI 标注帧时二次询问并支持保留人工帧、顶栏不显示重复的清空片段遮罩、传播进度存在时任务 message 只显示在蓝色进度面板内且不重复出现在灰色状态文字里、传播链布尔操作按帧范围选择并二次确认、清空/删除前预检后端 annotation id 并跳过本地陈旧 id、删除单个传播 mask 后空帧不保留传播历史颜色、传播权重下拉深色可读配色、自动传播范围选择时显示传播权重和向前/向后帧数、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧通过 source/lineage metadata 识别为蓝色区段和标识点击跳帧、最近自动传播历史片段同一蓝色系按新旧递进纯色显示,旧记录第 5 次后统一阈值色、当前帧白色贯穿线、传播/布尔/清空范围边界贯穿线、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条和视频处理进度条选择传播/布尔/清空范围、左右方向键切帧、播放、按项目 FPS 显示当前/总时长 | @@ -32,7 +32,7 @@ | 需求 | 功能点 | 对应测试 | 当前状态 | |------|--------|----------|----------| -| R1 | 登录页、默认开发管理员、JWT 写入、当前用户写入、刷新恢复基础状态、失败提示、后端 401、`/api/auth/me`、管理员用户管理、角色权限、审计日志、演示出厂设置二次确认、重置后只保留 admin、演示视频项目和已生成帧的自然排序演示 DICOM 项目 | `Login.test.tsx`, `Sidebar.test.tsx`, `UserAdmin.test.tsx`, `useStore.test.ts`, `test_auth.py`, `test_admin.py` | 已覆盖 | +| R1 | 登录页、唯一默认管理员、JWT 写入、当前用户写入、刷新恢复基础状态、失败提示、后端 401、`/api/auth/me`、管理员用户管理、角色权限、审计日志、演示出厂设置二次确认、重置后只保留 admin、演示视频项目和已生成帧的自然排序演示 DICOM 项目 | `Login.test.tsx`, `Sidebar.test.tsx`, `UserAdmin.test.tsx`, `useStore.test.ts`, `test_auth.py`, `test_admin.py` | 已覆盖 | | R2 | 项目列表/创建/选择/重命名/复制、重命名时不触发生成帧、DICOM 不显示生成帧、项目复制 reset/full、项目按用户隔离、视频导入、DICOM 导入、DICOM 前端选择自然排序、后端项目和帧 CRUD | `ProjectLibrary.test.tsx`, `api.test.ts`, `test_projects.py` | 已覆盖 | | R3 | 文件类型校验、自动/指定项目上传、视频导入与生成帧分离、视频/DICOM 上传进度可视化、DICOM 导入显示有效文件数量并在上传后持续显示解析任务进度、显式 FPS 生成帧、视频生成帧完成后自动刷新项目封面、项目卡片 FPS 徽标显示 `parse_fps`、视频/DICOM 拆帧任务、DICOM 上传/下载/读取自然排序、非阻塞自动消失操作提示、`parse_fps/max_frames/target_width`、标准帧序列 metadata、任务查询、取消、重试、worker 取消停止 | `ProjectLibrary.test.tsx`, `TransientNotice.test.tsx`, `api.test.ts`, `test_media.py`, `test_tasks.py` | 已覆盖 | | R4 | 工作区加载帧、无帧项目不自动解析、工作区短状态自动消失、后端标注回显保留本地未保存 draft mask、Canvas/AI 底图居中适配且保留边距、工作区 mask 透明度、选中 mask 后跨帧自动跟随同一传播链结果、左侧工具栏当前帧清空优先作用于选中 mask、无传播链时直接执行、有传播链时可选当前帧/传播所有帧/取消、清空人工/AI 标注帧前二次确认、取消确认不删除、仅自动传播帧不确认、删除单个传播 mask 后空帧不保留传播历史颜色、传播权重下拉深色可读配色、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧蓝色区段和标识点击跳帧、最近自动传播历史片段同一蓝色系按新旧递进显示,旧记录第 5 次后统一阈值色、当前帧白色贯穿线、传播范围洋红/黄绿色边界贯穿线、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条/视频处理进度条拖拽选择传播范围、Canvas/AI 画布拖拽平移回写 position state、左右方向键切帧、播放、按 FPS 显示时间 | `VideoWorkspace.test.tsx`, `FrameTimeline.test.tsx`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx` | 已覆盖 | diff --git a/doc/10-installation.md b/doc/10-installation.md index 1f7f6f7..c969082 100644 --- a/doc/10-installation.md +++ b/doc/10-installation.md @@ -314,7 +314,7 @@ admin / 123456 首次启动会自动创建默认管理员,密码以哈希形式写入 `users` 表;登录返回签名 JWT,业务接口会校验 `Authorization: Bearer `。生产环境必须修改 `jwt_secret_key` 和默认管理员密码。 -默认管理员登录后会看到“用户管理”后台,可新增用户、停用/启用用户、修改角色、重置密码、删除无项目用户并查看登录与用户管理审计日志。角色分为 `admin`、`annotator`、`viewer`:`admin/annotator` 可以执行写入类业务操作,`viewer` 只读。演示部署可在该后台使用“恢复演示出厂设置”,二次确认后只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目;视频来自 `demo_video_path`,DICOM 序列来自 `demo_dicom_dir`。 +默认管理员登录后会看到“用户管理”后台,可新增标注员、停用/启用用户、重置密码、删除用户并查看登录与用户管理审计日志。系统只支持唯一默认 `admin` 和 `annotator` 两类角色:标注员不能新增用户、查看审计日志或恢复演示出厂设置,但可以和管理员共享同一项目库并执行项目管理、标注、AI 推理、任务和导出等业务操作。演示部署可在该后台使用“恢复演示出厂设置”,二次确认后只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目;视频来自 `demo_video_path`,DICOM 序列来自 `demo_dicom_dir`。 --- diff --git a/src/components/UserAdmin.test.tsx b/src/components/UserAdmin.test.tsx index aac602f..d816bf9 100644 --- a/src/components/UserAdmin.test.tsx +++ b/src/components/UserAdmin.test.tsx @@ -52,11 +52,11 @@ describe('UserAdmin', () => { expect(screen.getByText('当前管理员:admin')).toBeInTheDocument(); }); - it('creates a user with role and password', async () => { + it('creates new users as annotators', async () => { apiMock.createAdminUser.mockResolvedValueOnce({ id: 3, username: 'nurse', - role: 'viewer', + role: 'annotator', is_active: 1, }); @@ -64,30 +64,26 @@ describe('UserAdmin', () => { await screen.findByText('doctor'); fireEvent.change(screen.getByPlaceholderText('用户名'), { target: { value: 'nurse' } }); fireEvent.change(screen.getByPlaceholderText('初始密码'), { target: { value: 'secret123' } }); - fireEvent.change(screen.getAllByDisplayValue('标注员')[0], { target: { value: 'viewer' } }); fireEvent.click(screen.getByRole('button', { name: /新增用户/ })); await waitFor(() => expect(apiMock.createAdminUser).toHaveBeenCalledWith({ username: 'nurse', password: 'secret123', - role: 'viewer', + role: 'annotator', is_active: true, })); expect(await screen.findByText('用户已创建')).toBeInTheDocument(); }); - it('updates role, status and password, and deletes users', async () => { - apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'viewer', is_active: 1 }); - apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'viewer', is_active: 0 }); - apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'viewer', is_active: 0 }); + it('updates status and password, and deletes users', async () => { + apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'annotator', is_active: 0 }); + apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'annotator', is_active: 0 }); apiMock.deleteAdminUser.mockResolvedValueOnce(undefined); render(); await screen.findByText('doctor'); - const roleSelects = screen.getAllByDisplayValue('标注员'); - fireEvent.change(roleSelects[1], { target: { value: 'viewer' } }); - await waitFor(() => expect(apiMock.updateAdminUser).toHaveBeenCalledWith(2, { role: 'viewer' })); + expect(screen.queryByText('观察员')).not.toBeInTheDocument(); fireEvent.click(screen.getAllByRole('button', { name: '启用' })[1]); await waitFor(() => expect(apiMock.updateAdminUser).toHaveBeenCalledWith(2, { is_active: false })); diff --git a/src/components/UserAdmin.tsx b/src/components/UserAdmin.tsx index efa25a0..fa95f40 100644 --- a/src/components/UserAdmin.tsx +++ b/src/components/UserAdmin.tsx @@ -17,7 +17,6 @@ import { TransientNotice, type NoticeState, type NoticeTone } from './TransientN const roleLabels: Record = { admin: '管理员', annotator: '标注员', - viewer: '观察员', }; function formatTime(value: string): string { @@ -47,7 +46,6 @@ export function UserAdmin() { const [notice, setNotice] = useState(null); const [newUsername, setNewUsername] = useState(''); const [newPassword, setNewPassword] = useState(''); - const [newRole, setNewRole] = useState('annotator'); const [passwordTarget, setPasswordTarget] = useState(null); const [nextPassword, setNextPassword] = useState(''); const [deleteUserTarget, setDeleteUserTarget] = useState(null); @@ -88,13 +86,12 @@ export function UserAdmin() { const created = await createAdminUser({ username: newUsername.trim(), password: newPassword, - role: newRole, + role: 'annotator', is_active: true, }); setUsers((prev) => [...prev, created]); setNewUsername(''); setNewPassword(''); - setNewRole('annotator'); showNotice('用户已创建', 'success'); setAuditLogs(await getAuditLogs(100)); } catch (err: any) { @@ -188,7 +185,7 @@ export function UserAdmin() {

用户管理后台

-

账号、角色、状态和安全审计

+

账号、状态和安全审计

@@ -209,7 +206,7 @@ export function UserAdmin() { {isLoading && }
-
+ setNewUsername(event.target.value)} @@ -225,15 +222,6 @@ export function UserAdmin() { autoComplete="new-password" className="rounded border border-white/10 bg-[#181818] px-3 py-2 text-sm text-white outline-none focus:border-cyan-400/50" /> -
- + {roleLabels[user.role] || '标注员'} +