diff --git a/API.md b/API.md index 7fb807c..ac470bd 100644 --- a/API.md +++ b/API.md @@ -500,6 +500,7 @@ Base 前缀 `/api/v1/admin`。 | 方法 | 路径 | 说明 | |---|---|---| | `GET` | `/api/v1/platform/employees` | 列全平台未软删员工;包含停用、锁定及无平台角色用户 | +| `POST` | `/api/v1/platform/employees` | 创建平台员工账号(返回 201);不自动加入任何 workspace | | `GET` | `/api/v1/platform/workspaces` | 列 workspace(`active`/`archived`);已软删的过滤掉 | | `POST` | `/api/v1/platform/workspaces` | 创建 workspace(返回 201);创建者自动成为 admin 成员 | | `GET` | `/api/v1/platform/workspaces/{workspace_id}` | 单个 workspace(含已 disabled 的,用于恢复) | @@ -596,7 +597,8 @@ Base 前缀 `/api/v1/admin`。 | `role_code` | string | 是 | `admin` \| `developer` | **不可填 `system_admin`**(那是用户级身份,不是 workspace 角色) | - 服务端默认 `member_status='active'`。 -- 用户不存在 → 404;用户已是该 workspace 成员 → 409。 +- 用户不存在或已软删除 → 404;用户状态不是 `active` → 409;用户已是该 workspace 成员 → 409。 +- 新员工必须先通过 `POST /api/v1/platform/employees` 或现有 workspace 员工创建接口建立用户账号。 ### 7.6 `PATCH /api/v1/platform/workspaces/{workspace_id}/members/{user_id}` @@ -647,6 +649,26 @@ Base 前缀 `/api/v1/admin`。 } ``` +### 7.9 `POST /api/v1/platform/employees` + +只创建平台员工账号,不创建任何 `workspace_members` 记录。调用者必须是系统管理员;已认证但不是系统管理员时返回 `403`。 + +- **请求体字段**: + +| 字段 | 类型 | 必填 | 限制 | 说明 | +|---|---|---|---|---| +| `username` | string | 是 | 2~64 字符 | 全平台唯一登录名 | +| `display_name` | string | 是 | 1~100 字符 | 显示名称 | +| `email` | string | 否 | ≤255 字符 | 邮箱,全平台唯一 | +| `password` | string | 是 | 8~72 字符 | 登录密码 | + +- 新用户状态固定为 `active`,且不分配平台角色。 +- 密码使用 bcrypt 哈希保存;响应不包含 `password` 或 `password_hash`。 +- 用户名或邮箱重复 → 409。 +- 创建成功后,可调用 `POST /api/v1/platform/workspaces/{workspace_id}/members` 将用户加入指定 workspace。 + +- **响应 201**:字段与 §7.8 的员工元素一致,其中 `role_code`、`role_name` 均为 `null`,`meta` 为空对象。 + --- ## 八、Jupyter 路由 diff --git a/backend/src/backend/platform.py b/backend/src/backend/platform.py index a4937f7..85a9fb8 100644 --- a/backend/src/backend/platform.py +++ b/backend/src/backend/platform.py @@ -28,6 +28,7 @@ Workspace membership CRUD:: Platform employee roster:: GET /employees — list all non-deleted users + POST /employees — create a user without workspace membership Invariants ---------- @@ -50,10 +51,11 @@ from typing import Any, Literal from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel, ConfigDict, Field -from sqlalchemy import func, select, update +from sqlalchemy import func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from backend.dependencies import current_user, database_session +from common.auth.passwords import hash_password from common.db.models import Roles, Users, WorkspaceMembers, Workspaces from common.ids import new_ulid @@ -111,6 +113,15 @@ class MemberUpdate(BaseModel): member_status: Literal["active", "disabled", "locked"] | None = None +class PlatformEmployeeCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + + username: str = Field(min_length=2, max_length=64) + display_name: str = Field(min_length=1, max_length=100) + email: str | None = Field(default=None, max_length=255) + password: str = Field(min_length=8, max_length=72) + + # --------------------------------------------------------------------------- # System-admin context dependency # --------------------------------------------------------------------------- @@ -302,6 +313,39 @@ async def list_platform_employees( ) +@router.post("/employees", status_code=status.HTTP_201_CREATED) +async def create_platform_employee( + payload: PlatformEmployeeCreate, + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Create a platform user without assigning workspace membership.""" + username = payload.username.strip() + display_name = payload.display_name.strip() + duplicate_conditions = [Users.username == username] + if payload.email: + duplicate_conditions.append(Users.email == payload.email.strip()) + duplicate = await session.scalar( + select(Users.user_id).where(or_(*duplicate_conditions)) + ) + if duplicate is not None: + raise HTTPException(status.HTTP_409_CONFLICT, "用户名或邮箱已存在") + + user = Users( + user_id=new_ulid(), + username=username, + display_name=display_name, + email=payload.email.strip() if payload.email else None, + password_hash=hash_password(payload.password), + status="active", + platform_role_id=None, + ) + session.add(user) + await session.flush() + await session.refresh(user) + return _envelope(context.request_id, platform_employee_payload(user, None)) + + # --------------------------------------------------------------------------- # Workspace CRUD # --------------------------------------------------------------------------- @@ -502,8 +546,13 @@ async def add_member( """Add a user to a workspace. The new row starts with member_status='active'.""" await _load_workspace(session, workspace_id) user = await session.get(Users, payload.user_id) - if user is None: + if user is None or user.is_deleted != 0: raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在") + if user.status != "active": + raise HTTPException( + status.HTTP_409_CONFLICT, + f"用户状态为 {user.status},无法加入 workspace", + ) role = await _load_role_by_code(session, payload.role_code) duplicate = await session.scalar( select(WorkspaceMembers.user_id).where(