Merge origin/develop into feature/a-card-operations

This commit is contained in:
郑龙捷
2026-09-02 10:33:00 +08:00
73 changed files with 7177 additions and 3280 deletions
@@ -0,0 +1,130 @@
"""数据资源路由共享的 payload / 可见性辅助。"""
from __future__ import annotations
import os
from pathlib import Path, PurePosixPath
from typing import Any
from common.db.models import DataResources, StorageObjects
from common.storage import workspaces_root
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.api.dependencies import RequestContext
from backend.api.scripts import _escape_like_pattern, normalize_user_path
def build_list_resources_descendant_prefix(parent_path: str) -> str:
"""Return the escaped materialized-path prefix for direct children."""
normalized = normalize_user_path(parent_path)
escaped = _escape_like_pattern(normalized)
return f"{escaped}/" if escaped else ""
def compute_jupyter_relative_path(script_path: str, resource_relative: str) -> str:
"""从当前脚本所在目录算到资源文件的 Jupyter 相对路径。"""
script_dir = PurePosixPath(script_path).parent.as_posix()
if not script_dir or script_dir == ".":
return resource_relative
return os.path.relpath(resource_relative, start=script_dir)
def resource_directory(
object_key: str,
workspace_id: str,
owner_user_id: str,
) -> str:
"""从 object_key 解析资源所在目录(相对于用户根目录,根目录返回 "")。"""
prefix = f"{workspace_id}/{owner_user_id}/"
if not object_key.startswith(prefix):
return ""
tail = object_key[len(prefix):]
directory, _, _ = tail.rpartition("/")
return directory
def resource_payload(
resource: DataResources,
storage_object: StorageObjects,
owner_display_name: str | None = None,
) -> dict[str, Any]:
workspace_prefix = f"{resource.workspace_id}/"
user_prefix = f"{resource.owner_user_id}/"
jupyter_accessible_path = ""
absolute_path = ""
if storage_object.object_key and storage_object.object_key.startswith(
workspace_prefix
):
remainder = storage_object.object_key[len(workspace_prefix):]
if remainder.startswith(user_prefix):
tail = remainder[len(user_prefix):]
jupyter_accessible_path = tail
absolute_path = (
workspaces_root()
/ resource.workspace_id
/ resource.owner_user_id
/ Path(tail)
).as_posix()
else:
jupyter_accessible_path = remainder
elif storage_object.object_key:
jupyter_accessible_path = storage_object.object_key
return {
"resource_id": resource.resource_id,
"workspace_id": resource.workspace_id,
"storage_object_id": resource.storage_object_id,
"owner_user_id": resource.owner_user_id,
"owner_display_name": owner_display_name,
"resource_name": resource.resource_name,
"description": resource.description,
"visibility": resource.visibility,
"status": resource.status,
"created_at": resource.created_at.isoformat(),
"updated_at": resource.updated_at.isoformat(),
"file": {
"file_name": storage_object.file_name,
"file_extension": storage_object.file_extension,
"mime_type": storage_object.mime_type,
"size_bytes": storage_object.size_bytes,
"content_hash": storage_object.content_hash,
"object_status": storage_object.object_status,
},
"jupyter_accessible_path": jupyter_accessible_path,
"absolute_path": absolute_path,
}
def can_view(resource: DataResources, context: RequestContext) -> bool:
if resource.owner_user_id == context.user.user_id:
return True
if resource.visibility in {"workspace", "public"}:
return True
return context.is_admin
async def get_visible_resource(
resource_id: str,
context: RequestContext,
session: AsyncSession,
) -> tuple[DataResources, StorageObjects]:
row = (
await session.execute(
select(DataResources, StorageObjects)
.join(
StorageObjects,
StorageObjects.storage_object_id
== DataResources.storage_object_id,
)
.where(
DataResources.resource_id == resource_id,
DataResources.workspace_id
== context.workspace.workspace_id,
DataResources.status == "active",
)
)
).one_or_none()
if row is None or not can_view(row[0], context):
raise HTTPException(status.HTTP_404_NOT_FOUND, "resource not found")
return row
+110 -16
View File
@@ -12,6 +12,8 @@ The user-facing flow is:
``backend.api.dependencies.request_context``
3. POST /api/v1/auth/logout — clear the cookie
4. GET /api/v1/auth/me — return the current user
5. PATCH /api/v1/auth/me — update display_name / email
6. POST /api/v1/auth/password — change password (clears session cookie)
Service-to-service calls do not use these endpoints — they live on the
shared Docker network and have no application-layer auth. See
@@ -24,15 +26,16 @@ from typing import Any
from common.auth.jwt import JwtError, issue_jwt, verify_jwt_token
from common.auth.membership import resolve_is_system_admin
from common.auth.passwords import verify_password
from common.auth.passwords import hash_password, verify_password
from common.config import settings
from common.db.models import Roles, Users, WorkspaceMembers, Workspaces
from common.ids import new_ulid
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.api.dependencies import database_session, load_user_permissions
from backend.api.dependencies import current_user, database_session, load_user_permissions
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
@@ -95,6 +98,29 @@ def _workspace_payload(
}
class ProfileUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
display_name: str | None = Field(default=None, min_length=1, max_length=100)
email: str | None = Field(default=None, max_length=255)
class PasswordChange(BaseModel):
model_config = ConfigDict(extra="forbid")
current_password: str = Field(min_length=1, max_length=72)
new_password: str = Field(min_length=8, max_length=72)
async def _platform_role_code(session: AsyncSession, user: Users) -> str | None:
if user.platform_role_id is None:
return None
platform_role_row = await session.scalar(
select(Roles).where(Roles.role_id == user.platform_role_id)
)
return platform_role_row.role_code if platform_role_row is not None else None
# 校验账号密码,设置登录 Cookie,并返回用户可进入的工作区列表。
@router.post("/login")
async def login(
@@ -167,13 +193,7 @@ async def login(
# (``Users.platform_role_id``); the iteration over rows above was
# a legacy way to find the "highest" workspace role and is no
# longer correct now that admin/developer are platform-only.
user_role_code: str | None = None
if user.platform_role_id is not None:
platform_role_row = await session.scalar(
select(Roles).where(Roles.role_id == user.platform_role_id)
)
if platform_role_row is not None:
user_role_code = platform_role_row.role_code
user_role_code = await _platform_role_code(session, user)
token = issue_jwt(user.user_id, ttl_seconds=COOKIE_TTL_SECONDS)
_set_session_cookie(request, response, token)
@@ -257,13 +277,7 @@ async def me(
workspaces = [_workspace_payload(ws, role) for ws, role, _ in rows]
default_workspace_id = workspaces[0]["workspace_id"] if workspaces else None
user_role_code: str | None = None
if user.platform_role_id is not None:
platform_role_row = await session.scalar(
select(Roles).where(Roles.role_id == user.platform_role_id)
)
if platform_role_row is not None:
user_role_code = platform_role_row.role_code
user_role_code = await _platform_role_code(session, user)
is_system_admin = await resolve_is_system_admin(session, user)
permissions = await load_user_permissions(session, user)
@@ -284,6 +298,86 @@ async def me(
}
# 当前登录用户修改显示名 / 邮箱(不可改 username、角色、密码)。
@router.patch("/me")
async def update_me(
payload: ProfileUpdate,
user: Users = Depends(current_user),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Update the authenticated user's display_name and/or email."""
if payload.display_name is None and payload.email is None:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"至少提供一个可修改字段",
)
if payload.display_name is not None:
user.display_name = payload.display_name.strip()
if payload.email is not None:
normalized = payload.email.strip() or None
if normalized is not None:
duplicate = await session.scalar(
select(Users.user_id).where(
Users.email == normalized,
Users.user_id != user.user_id,
Users.is_deleted == 0,
)
)
if duplicate is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "邮箱已存在")
user.email = normalized
await session.flush()
await session.refresh(user)
user_role_code = await _platform_role_code(session, user)
is_system_admin = await resolve_is_system_admin(session, user)
permissions = await load_user_permissions(session, user)
return {
"request_id": new_ulid(),
"data": {
"user": _user_payload(
user,
user_role_code,
is_system_admin=is_system_admin,
permissions=permissions,
),
},
"meta": {},
}
# 当前登录用户修改密码;成功后清除 Cookie,需重新登录。
@router.post("/password")
async def change_password(
payload: PasswordChange,
response: Response,
user: Users = Depends(current_user),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Change password after verifying the current one; clears the session cookie."""
if not verify_password(payload.current_password, user.password_hash):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "当前密码不正确")
if payload.current_password == payload.new_password:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"新密码不能与当前密码相同",
)
user.password_hash = hash_password(payload.new_password)
await session.flush()
_clear_session_cookie(response)
return {
"request_id": new_ulid(),
"data": {"password_changed": True},
"meta": {},
}
__all__ = [
"COOKIE_NAME",
"COOKIE_TTL_SECONDS",
@@ -73,6 +73,7 @@ from backend.api.platform._deps import (
system_admin_context,
)
from backend.api.platform.employees import router as employees_router
from backend.api.platform.members import router as members_router
from backend.api.platform.roles import router as roles_router
from backend.api.platform.workspaces import router as workspaces_router
@@ -81,6 +82,7 @@ from backend.api.platform.workspaces import router as workspaces_router
router = APIRouter()
router.include_router(employees_router)
router.include_router(workspaces_router)
router.include_router(members_router)
router.include_router(roles_router)
# 保留 system_admin_context 的 re-export,供其他文件使用
@@ -0,0 +1,60 @@
"""Cursor (keyset) pagination helpers for platform list endpoints.
Cursor encodes the sort key ``(created_at, id)`` as a URL-safe base64
string. Clients pass it back via ``?cursor=`` to fetch the next page.
Invalid cursors raise HTTP 400 — never silently treated as page 1.
"""
from __future__ import annotations
import base64
import datetime
from typing import Any
from fastapi import HTTPException, status
DEFAULT_PAGE_LIMIT = 10
MAX_PAGE_LIMIT = 200
def encode_cursor(created_at: datetime.datetime, row_id: str) -> str:
"""Encode ``(created_at, id)`` into an opaque cursor string."""
if created_at.tzinfo is not None:
created_at = created_at.replace(tzinfo=None)
raw = f"{created_at.isoformat()}|{row_id}".encode("utf-8")
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def decode_cursor(cursor: str) -> tuple[datetime.datetime, str]:
"""Decode a cursor; raise 400 on malformed input."""
try:
padded = cursor + "=" * (-len(cursor) % 4)
raw = base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8")
ts_part, _, row_id = raw.partition("|")
if not ts_part or not row_id:
raise ValueError("missing parts")
created_at = datetime.datetime.fromisoformat(ts_part)
if created_at.tzinfo is not None:
created_at = created_at.replace(tzinfo=None)
return created_at, row_id
except (ValueError, TypeError, UnicodeDecodeError) as exc:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"无效的分页 cursor",
) from exc
def page_meta(
*,
limit: int,
page_count: int,
total_count: int,
next_cursor: str | None,
) -> dict[str, Any]:
return {
"limit": limit,
"page_count": page_count,
"total_count": total_count,
"has_more": next_cursor is not None,
"next_cursor": next_cursor,
}
+81 -6
View File
@@ -11,9 +11,9 @@ from typing import Any, Literal
from common.auth.passwords import hash_password
from common.db.models import Roles, Users, WorkspaceMembers, Workspaces
from common.ids import new_ulid
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import or_, select, update
from sqlalchemy import func, or_, select, tuple_, update
from sqlalchemy.ext.asyncio import AsyncSession
from backend.api.dependencies import database_session
@@ -25,6 +25,13 @@ from backend.api.platform._deps import (
_load_role_by_code,
system_admin_context,
)
from backend.api.platform._pagination import (
DEFAULT_PAGE_LIMIT,
MAX_PAGE_LIMIT,
decode_cursor,
encode_cursor,
page_meta,
)
# 平台角色 code 的字符串约束:与 RoleCreate.role_code 一致。
# 之所以从 Literal["admin","developer"] 放宽为 str,是因为 ``listPlatformRoles``
@@ -91,22 +98,63 @@ def platform_employee_payload(
# 列出整个平台的非删除用户;不局限于某一个工作区。
@router.get("/employees")
async def list_platform_employees(
limit: int = Query(default=DEFAULT_PAGE_LIMIT, ge=1, le=MAX_PAGE_LIMIT),
cursor: str | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""List every non-soft-deleted platform user."""
"""List non-soft-deleted platform users with cursor pagination + search."""
base_filters = [Users.is_deleted == 0]
keyword = (q or "").strip()
if keyword:
like = f"%{keyword}%"
base_filters.append(
or_(
Users.display_name.like(like),
Users.username.like(like),
Users.email.like(like),
)
)
total_count = int(
await session.scalar(
select(func.count()).select_from(Users).where(*base_filters)
)
or 0
)
page_filters = list(base_filters)
if cursor is not None:
cursor_ts, cursor_id = decode_cursor(cursor)
page_filters.append(
tuple_(Users.created_at, Users.user_id) > (cursor_ts, cursor_id)
)
rows = (
await session.execute(
select(Users, Roles)
.outerjoin(Roles, Roles.role_id == Users.platform_role_id)
.where(Users.is_deleted == 0)
.where(*page_filters)
.order_by(Users.created_at, Users.user_id)
.limit(limit + 1)
)
).all()
has_more = len(rows) > limit
page_rows = rows[:limit]
next_cursor = None
if has_more and page_rows:
last_user = page_rows[-1][0]
next_cursor = encode_cursor(last_user.created_at, last_user.user_id)
return _envelope(
context.request_id,
[platform_employee_payload(user, role) for user, role in rows],
{"count": len(rows)},
[platform_employee_payload(user, role) for user, role in page_rows],
page_meta(
limit=limit,
page_count=len(page_rows),
total_count=total_count,
next_cursor=next_cursor,
),
)
@@ -348,3 +396,30 @@ async def delete_platform_employee(
)
class PlatformEmployeePasswordReset(BaseModel):
model_config = ConfigDict(extra="forbid")
new_password: str = Field(min_length=8, max_length=72)
# 系统管理员重置指定员工密码(不需要旧密码)。
@router.post("/employees/{user_id}/reset-password")
async def reset_platform_employee_password(
user_id: str,
payload: PlatformEmployeePasswordReset,
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Reset a platform employee's password. Caller must be a system admin."""
user = await session.get(Users, user_id)
if user is None or user.is_deleted != 0:
raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在")
user.password_hash = hash_password(payload.new_password)
await session.flush()
return _envelope(
context.request_id,
{"user_id": user_id, "password_reset": True},
)
+297
View File
@@ -0,0 +1,297 @@
"""Workspace membership CRUD endpoints.
``GET .../members`` admits system admins or active workspace members;
write endpoints require ``system_admin_context``.
"""
from __future__ import annotations
import datetime
from typing import Any, Literal
from common.db.models import Roles, Users, WorkspaceMembers
from common.ids import new_ulid
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.api.dependencies import current_user, database_session
from backend.api.platform._deps import (
SystemAdminContext,
_count_active_admins,
_envelope,
_is_system_admin,
system_admin_context,
)
from backend.api.platform.workspaces import (
_load_workspace,
member_payload,
)
router = APIRouter(prefix="/api/v1/platform", tags=["platform"])
LIST_PAGE_SIZE = 100
class MemberCreate(BaseModel):
"""Add a user to a workspace. Role is inherited from the user's
platform role (Users.platform_role_id) — not set here."""
model_config = ConfigDict(extra="forbid")
user_id: str = Field(min_length=26, max_length=26)
class MemberUpdate(BaseModel):
"""Update a workspace membership's status. Role cannot be changed
via this endpoint — workspace role is always inherited from the
user's platform role. To change a member's role, PATCH
/platform/employees/{user_id} instead."""
model_config = ConfigDict(extra="forbid")
member_status: Literal["active", "disabled", "locked"] | None = None
@router.get("/workspaces/{workspace_id}/members")
async def list_members(
workspace_id: str,
request: Request,
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""List active and historical (non-soft-deleted) members of a workspace.
Accessible to system admins (any workspace) and to active members of the
workspace itself. The script explorer calls this to seed the per-owner
directory-tree groups for non-admin users; visibility filters on the
scripts/data-resources endpoints still keep each peer's private content
hidden, so this only exposes membership (names), not private files.
"""
user = await current_user(request, session)
is_system_admin = await _is_system_admin(session, user)
if not is_system_admin:
membership = await session.scalar(
select(WorkspaceMembers).where(
WorkspaceMembers.workspace_id == workspace_id,
WorkspaceMembers.user_id == user.user_id,
WorkspaceMembers.is_deleted == 0,
WorkspaceMembers.member_status == "active",
)
)
if membership is None:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"需要系统管理员或该工作区成员权限",
)
await _load_workspace(session, workspace_id)
rows = (
await session.execute(
select(Users, Roles, WorkspaceMembers)
.join(
WorkspaceMembers,
WorkspaceMembers.user_id == Users.user_id,
)
.join(Roles, Roles.role_id == WorkspaceMembers.role_id)
.where(
WorkspaceMembers.workspace_id == workspace_id,
WorkspaceMembers.is_deleted == 0,
)
.order_by(WorkspaceMembers.joined_at, Users.user_id)
.limit(LIST_PAGE_SIZE)
)
).all()
request_id = request.headers.get("X-Request-ID") or new_ulid()
return _envelope(
request_id,
[member_payload(u, r, m) for u, r, m in rows],
{"count": len(rows), "page_size": LIST_PAGE_SIZE},
)
@router.post(
"/workspaces/{workspace_id}/members",
status_code=status.HTTP_201_CREATED,
)
async def add_member(
workspace_id: str,
payload: MemberCreate,
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Add a user to a workspace. The new row starts with member_status='active'.
The role is inherited from the target user's ``platform_role_id``;
the request body does NOT take a ``role_code``. To change a member's
role, PATCH ``/api/v1/platform/employees/{user_id}`` instead.
"""
await _load_workspace(session, workspace_id)
user = await session.get(Users, payload.user_id)
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",
)
if user.platform_role_id is None:
raise HTTPException(
status.HTTP_409_CONFLICT,
"目标用户尚未分配平台角色,无法加入 workspace;"
"请先 PATCH /api/v1/platform/employees/{user_id} 设置 role_code",
)
role = await session.scalar(
select(Roles).where(
Roles.role_id == user.platform_role_id,
Roles.is_deleted == 0,
)
)
if role is None:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
"用户的平台角色行不存在或已被删除",
)
# ``WorkspaceMembers`` 的主键是 ``(workspace_id, user_id)`` 复合 PK,
# 而 ``remove_member`` / ``delete_platform_employee`` 都是软删除 (保留行,
# 仅置 ``is_deleted=1``). 因此这里必须按主键查整行,而不是只看活跃行:
# 否则软删行会被 active-duplicate 检查漏过,然后 INSERT 直接撞 PK.
existing = await session.scalar(
select(WorkspaceMembers).where(
WorkspaceMembers.workspace_id == workspace_id,
WorkspaceMembers.user_id == payload.user_id,
)
)
if existing is not None:
if existing.is_deleted == 0:
raise HTTPException(
status.HTTP_409_CONFLICT,
"用户已是该 workspace 成员;workspace 角色继承自平台角色,"
"要变更请 PATCH /api/v1/platform/employees/{user_id} 修改 role_code",
)
# 复活软删除行. 保留 ``joined_at`` 作为历史记录;``role_id`` 重新继承
# 当前用户的平台角色 (用户在中间可能改过 platform_role);清掉
# ``deleted_at`` 标记本轮已不在软删状态.
existing.is_deleted = 0
existing.deleted_at = None
existing.role_id = role.role_id
existing.member_status = "active"
await session.flush()
await session.refresh(existing)
return _envelope(
context.request_id, member_payload(user, role, existing),
)
membership = WorkspaceMembers(
workspace_id=workspace_id,
user_id=payload.user_id,
role_id=role.role_id,
member_status="active",
)
session.add(membership)
await session.flush()
await session.refresh(membership)
return _envelope(context.request_id, member_payload(user, role, membership))
@router.patch("/workspaces/{workspace_id}/members/{user_id}")
async def update_member(
workspace_id: str,
user_id: str,
payload: MemberUpdate,
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Update a workspace membership's status. Role is not editable here.
Workspace role is always inherited from the user's platform role
(``Users.platform_role_id``). To change role, PATCH
``/api/v1/platform/employees/{user_id}`` instead.
Last-admin guard still applies to ``member_status`` changes: setting
the only active admin to ``disabled``/``locked`` would leave the
workspace without admin coverage.
"""
await _load_workspace(session, workspace_id)
row = (
await session.execute(
select(Users, Roles, WorkspaceMembers)
.join(
WorkspaceMembers,
WorkspaceMembers.user_id == Users.user_id,
)
.join(Roles, Roles.role_id == WorkspaceMembers.role_id)
.where(
WorkspaceMembers.workspace_id == workspace_id,
WorkspaceMembers.user_id == user_id,
WorkspaceMembers.is_deleted == 0,
)
)
).first()
if row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在")
user, role, membership = row
if payload.member_status is not None and payload.member_status != membership.member_status:
if (
role.role_code == "admin"
and payload.member_status != "active"
):
remaining = await _count_active_admins(
session, workspace_id, exclude_user_id=user_id,
)
if remaining == 0:
raise HTTPException(
status.HTTP_409_CONFLICT,
"workspace 必须保留至少一个 admin",
)
membership.member_status = payload.member_status
await session.flush()
await session.refresh(membership)
return _envelope(context.request_id, member_payload(user, role, membership))
@router.delete("/workspaces/{workspace_id}/members/{user_id}")
async def remove_member(
workspace_id: str,
user_id: str,
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Soft-delete a workspace membership.
System admins cannot remove themselves — the only escape is to delete
the entire workspace, which cascades membership soft-deletion.
"""
await _load_workspace(session, workspace_id)
if user_id == context.user.user_id:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"系统管理员不能把自己从 workspace 移除;如需退出,请删除整个 workspace",
)
row = (
await session.execute(
select(Roles, WorkspaceMembers)
.join(Roles, Roles.role_id == WorkspaceMembers.role_id)
.where(
WorkspaceMembers.workspace_id == workspace_id,
WorkspaceMembers.user_id == user_id,
WorkspaceMembers.is_deleted == 0,
)
)
).first()
if row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在")
role, membership = row
if role.role_code == "admin" and membership.member_status == "active":
remaining = await _count_active_admins(
session, workspace_id, exclude_user_id=user_id,
)
if remaining == 0:
raise HTTPException(
status.HTTP_409_CONFLICT,
"workspace 必须保留至少一个 admin",
)
membership.is_deleted = 1
membership.deleted_at = datetime.datetime.utcnow()
await session.flush()
return _envelope(
context.request_id,
{"workspace_id": workspace_id, "user_id": user_id, "removed": True},
)
+62 -285
View File
@@ -1,7 +1,7 @@
"""Workspace & membership CRUD endpoints.
Five workspace endpoints plus five membership endpoints, gated by
``system_admin_context`` (except ``GET .../members``, which also admits workspace
members). Last-admin guards and soft-delete cascades live here.
"""Workspace CRUD endpoints.
Membership endpoints live in ``members.py``. Soft-delete cascades for
workspace DELETE still live here.
"""
from __future__ import annotations
@@ -12,29 +12,29 @@ from typing import Any, Literal
from common.db.models import Roles, Users, WorkspaceMembers, Workspaces
from common.ids import new_ulid
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select, update
from sqlalchemy import func, or_, select, tuple_, update
from sqlalchemy.ext.asyncio import AsyncSession
from backend.api.dependencies import current_user, database_session
from backend.api.dependencies import database_session
from backend.api.platform._deps import (
SystemAdminContext,
_count_active_admins,
_envelope,
_is_system_admin,
_load_role_by_code,
system_admin_context,
)
from backend.api.platform._pagination import (
DEFAULT_PAGE_LIMIT,
MAX_PAGE_LIMIT,
decode_cursor,
encode_cursor,
page_meta,
)
router = APIRouter(prefix="/api/v1/platform", tags=["platform"])
WORKSPACE_CODE_PATTERN = re.compile(r"^[a-z0-9-]{3,32}$")
LIST_PAGE_SIZE = 100
WORKSPACE_EDITABLE_STATUS = ("active", "archived")
MEMBER_ROLE_CODES = ("admin", "developer")
MEMBER_STATUS_VALUES = ("active", "disabled", "locked")
# 创建工作区时前端提交的请求体;禁止未声明字段。
class WorkspaceCreate(BaseModel):
@@ -55,24 +55,6 @@ class WorkspaceUpdate(BaseModel):
# 'disabled' is rejected here on purpose — soft delete must go through DELETE.
status: Literal["active", "archived"] | None = None
class MemberCreate(BaseModel):
"""Add a user to a workspace. Role is inherited from the user's
platform role (Users.platform_role_id) — not set here."""
model_config = ConfigDict(extra="forbid")
user_id: str = Field(min_length=26, max_length=26)
class MemberUpdate(BaseModel):
"""Update a workspace membership's status. Role cannot be changed
via this endpoint — workspace role is always inherited from the
user's platform role. To change a member's role, PATCH
/platform/employees/{user_id} instead."""
model_config = ConfigDict(extra="forbid")
member_status: Literal["active", "disabled", "locked"] | None = None
def workspace_payload(workspace: Workspaces) -> dict[str, Any]:
return {
"workspace_id": workspace.workspace_id,
@@ -114,28 +96,66 @@ async def _load_workspace(session: AsyncSession, workspace_id: str) -> Workspace
@router.get("/workspaces")
async def list_workspaces(
limit: int = Query(default=DEFAULT_PAGE_LIMIT, ge=1, le=MAX_PAGE_LIMIT),
cursor: str | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""List active/archived workspaces. Soft-deleted rows are filtered out.
"""List active/archived workspaces with cursor pagination + search."""
base_filters = [
Workspaces.status != "disabled",
Workspaces.is_deleted == 0,
]
keyword = (q or "").strip()
if keyword:
like = f"%{keyword}%"
base_filters.append(
or_(
Workspaces.workspace_name.like(like),
Workspaces.workspace_code.like(like),
Workspaces.description.like(like),
)
)
total_count = int(
await session.scalar(
select(func.count()).select_from(Workspaces).where(*base_filters)
)
or 0
)
page_filters = list(base_filters)
if cursor is not None:
cursor_ts, cursor_id = decode_cursor(cursor)
page_filters.append(
tuple_(Workspaces.created_at, Workspaces.workspace_id)
> (cursor_ts, cursor_id)
)
Silent ``pageSize=100`` cap — YAGNI on real pagination until needed.
"""
rows = (
await session.execute(
select(Workspaces)
.where(
Workspaces.status != "disabled",
Workspaces.is_deleted == 0,
)
.where(*page_filters)
.order_by(Workspaces.created_at, Workspaces.workspace_id)
.limit(LIST_PAGE_SIZE)
.limit(limit + 1)
)
).scalars().all()
has_more = len(rows) > limit
page_rows = list(rows[:limit])
next_cursor = None
if has_more and page_rows:
last = page_rows[-1]
next_cursor = encode_cursor(last.created_at, last.workspace_id)
return _envelope(
context.request_id,
[workspace_payload(w) for w in rows],
{"count": len(rows), "page_size": LIST_PAGE_SIZE},
[workspace_payload(w) for w in page_rows],
page_meta(
limit=limit,
page_count=len(page_rows),
total_count=total_count,
next_cursor=next_cursor,
),
)
@router.post("/workspaces", status_code=status.HTTP_201_CREATED)
@@ -252,246 +272,3 @@ async def delete_workspace(
await session.flush()
await session.refresh(workspace)
return _envelope(context.request_id, workspace_payload(workspace))
@router.get("/workspaces/{workspace_id}/members")
async def list_members(
workspace_id: str,
request: Request,
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""List active and historical (non-soft-deleted) members of a workspace.
Accessible to system admins (any workspace) and to active members of the
workspace itself. The script explorer calls this to seed the per-owner
directory-tree groups for non-admin users; visibility filters on the
scripts/data-resources endpoints still keep each peer's private content
hidden, so this only exposes membership (names), not private files.
"""
user = await current_user(request, session)
is_system_admin = await _is_system_admin(session, user)
if not is_system_admin:
membership = await session.scalar(
select(WorkspaceMembers).where(
WorkspaceMembers.workspace_id == workspace_id,
WorkspaceMembers.user_id == user.user_id,
WorkspaceMembers.is_deleted == 0,
WorkspaceMembers.member_status == "active",
)
)
if membership is None:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"需要系统管理员或该工作区成员权限",
)
await _load_workspace(session, workspace_id)
rows = (
await session.execute(
select(Users, Roles, WorkspaceMembers)
.join(
WorkspaceMembers,
WorkspaceMembers.user_id == Users.user_id,
)
.join(Roles, Roles.role_id == WorkspaceMembers.role_id)
.where(
WorkspaceMembers.workspace_id == workspace_id,
WorkspaceMembers.is_deleted == 0,
)
.order_by(WorkspaceMembers.joined_at, Users.user_id)
.limit(LIST_PAGE_SIZE)
)
).all()
request_id = request.headers.get("X-Request-ID") or new_ulid()
return _envelope(
request_id,
[member_payload(u, r, m) for u, r, m in rows],
{"count": len(rows), "page_size": LIST_PAGE_SIZE},
)
@router.post(
"/workspaces/{workspace_id}/members",
status_code=status.HTTP_201_CREATED,
)
async def add_member(
workspace_id: str,
payload: MemberCreate,
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Add a user to a workspace. The new row starts with member_status='active'.
The role is inherited from the target user's ``platform_role_id``;
the request body does NOT take a ``role_code``. To change a member's
role, PATCH ``/api/v1/platform/employees/{user_id}`` instead.
"""
await _load_workspace(session, workspace_id)
user = await session.get(Users, payload.user_id)
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",
)
if user.platform_role_id is None:
raise HTTPException(
status.HTTP_409_CONFLICT,
"目标用户尚未分配平台角色,无法加入 workspace;"
"请先 PATCH /api/v1/platform/employees/{user_id} 设置 role_code",
)
role = await session.scalar(
select(Roles).where(
Roles.role_id == user.platform_role_id,
Roles.is_deleted == 0,
)
)
if role is None:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
"用户的平台角色行不存在或已被删除",
)
# ``WorkspaceMembers`` 的主键是 ``(workspace_id, user_id)`` 复合 PK,
# 而 ``remove_member`` / ``delete_platform_employee`` 都是软删除 (保留行,
# 仅置 ``is_deleted=1``). 因此这里必须按主键查整行,而不是只看活跃行:
# 否则软删行会被 active-duplicate 检查漏过,然后 INSERT 直接撞 PK.
existing = await session.scalar(
select(WorkspaceMembers).where(
WorkspaceMembers.workspace_id == workspace_id,
WorkspaceMembers.user_id == payload.user_id,
)
)
if existing is not None:
if existing.is_deleted == 0:
raise HTTPException(
status.HTTP_409_CONFLICT,
"用户已是该 workspace 成员;workspace 角色继承自平台角色,"
"要变更请 PATCH /api/v1/platform/employees/{user_id} 修改 role_code",
)
# 复活软删除行. 保留 ``joined_at`` 作为历史记录;``role_id`` 重新继承
# 当前用户的平台角色 (用户在中间可能改过 platform_role);清掉
# ``deleted_at`` 标记本轮已不在软删状态.
existing.is_deleted = 0
existing.deleted_at = None
existing.role_id = role.role_id
existing.member_status = "active"
await session.flush()
await session.refresh(existing)
return _envelope(
context.request_id, member_payload(user, role, existing),
)
membership = WorkspaceMembers(
workspace_id=workspace_id,
user_id=payload.user_id,
role_id=role.role_id,
member_status="active",
)
session.add(membership)
await session.flush()
await session.refresh(membership)
return _envelope(context.request_id, member_payload(user, role, membership))
@router.patch("/workspaces/{workspace_id}/members/{user_id}")
async def update_member(
workspace_id: str,
user_id: str,
payload: MemberUpdate,
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Update a workspace membership's status. Role is not editable here.
Workspace role is always inherited from the user's platform role
(``Users.platform_role_id``). To change role, PATCH
``/api/v1/platform/employees/{user_id}`` instead.
Last-admin guard still applies to ``member_status`` changes: setting
the only active admin to ``disabled``/``locked`` would leave the
workspace without admin coverage.
"""
await _load_workspace(session, workspace_id)
row = (
await session.execute(
select(Users, Roles, WorkspaceMembers)
.join(
WorkspaceMembers,
WorkspaceMembers.user_id == Users.user_id,
)
.join(Roles, Roles.role_id == WorkspaceMembers.role_id)
.where(
WorkspaceMembers.workspace_id == workspace_id,
WorkspaceMembers.user_id == user_id,
WorkspaceMembers.is_deleted == 0,
)
)
).first()
if row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在")
user, role, membership = row
if payload.member_status is not None and payload.member_status != membership.member_status:
if (
role.role_code == "admin"
and payload.member_status != "active"
):
remaining = await _count_active_admins(
session, workspace_id, exclude_user_id=user_id,
)
if remaining == 0:
raise HTTPException(
status.HTTP_409_CONFLICT,
"workspace 必须保留至少一个 admin",
)
membership.member_status = payload.member_status
await session.flush()
await session.refresh(membership)
return _envelope(context.request_id, member_payload(user, role, membership))
@router.delete("/workspaces/{workspace_id}/members/{user_id}")
async def remove_member(
workspace_id: str,
user_id: str,
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Soft-delete a workspace membership.
System admins cannot remove themselves — the only escape is to delete
the entire workspace, which cascades membership soft-deletion.
"""
await _load_workspace(session, workspace_id)
if user_id == context.user.user_id:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"系统管理员不能把自己从 workspace 移除;如需退出,请删除整个 workspace",
)
row = (
await session.execute(
select(Roles, WorkspaceMembers)
.join(Roles, Roles.role_id == WorkspaceMembers.role_id)
.where(
WorkspaceMembers.workspace_id == workspace_id,
WorkspaceMembers.user_id == user_id,
WorkspaceMembers.is_deleted == 0,
)
)
).first()
if row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在")
role, membership = row
if role.role_code == "admin" and membership.member_status == "active":
remaining = await _count_active_admins(
session, workspace_id, exclude_user_id=user_id,
)
if remaining == 0:
raise HTTPException(
status.HTTP_409_CONFLICT,
"workspace 必须保留至少一个 admin",
)
membership.is_deleted = 1
membership.deleted_at = datetime.datetime.utcnow()
await session.flush()
return _envelope(
context.request_id,
{"workspace_id": workspace_id, "user_id": user_id, "removed": True},
)
+9 -167
View File
@@ -7,17 +7,13 @@
from __future__ import annotations
import os
from datetime import UTC, datetime
from pathlib import Path, PurePosixPath
from typing import Any
from common.db.models import DataResources, StorageObjects, Users
from common.ids import new_ulid
from common.storage import workspaces_root
from common.storage.schemas import (
CreateUploadRequest,
DownloadUrlRequest,
)
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
from sqlalchemy import func, or_, select
@@ -28,8 +24,15 @@ from backend.api.dependencies import (
database_session,
request_context,
)
from backend.api.scripts import _escape_like_pattern, normalize_user_path
from backend.schemas.common import DownloadUrlRequest
from backend.api._resources_common import (
build_list_resources_descendant_prefix as _build_list_resources_descendant_prefix,
can_view,
compute_jupyter_relative_path,
get_visible_resource,
resource_directory,
resource_payload,
)
from backend.api.scripts import _escape_like_pattern
from backend.schemas.resources import (
CompleteResourceUploadRequest,
CreateResourceUploadRequest,
@@ -37,7 +40,6 @@ from backend.schemas.resources import (
)
from backend.services.storage import (
acquire_named_lock,
create_download_url_payload,
create_upload_record,
release_named_lock,
soft_delete_object,
@@ -47,119 +49,6 @@ from backend.services.storage import (
router = APIRouter(prefix="/api/v1/data-resources", tags=["data-resources"])
def _build_list_resources_descendant_prefix(parent_path: str) -> str:
"""Return the escaped materialized-path prefix for direct children
of ``parent_path`` against ``StorageObjects.object_key``.
The full object_key is ``{ws_id}/{owner_user_id}/{jupyter_path}``.
``list_resources`` prepends ``{ws_id}/{owner_user_id}`` (the requester
by default, or the ``owner_user_id`` query param) to this prefix and
applies ``LIKE '{ws_id}/{owner}/{prefix}%' AND NOT LIKE '...%/%'`` so
only that owner's direct children under ``parent_path`` match. LIKE
wildcards in parent_path are escaped so folder names containing ``_``
or ``%`` do not act as wildcards.
"""
normalized = normalize_user_path(parent_path)
escaped = _escape_like_pattern(normalized)
return f"{escaped}/" if escaped else ""
def compute_jupyter_relative_path(script_path: str, resource_relative: str) -> str:
"""从当前脚本所在目录算到资源文件的 Jupyter 相对路径。
script_path / resource_relative 都是相对于 user root_dir 的 POSIX 路径。
"""
script_dir = PurePosixPath(script_path).parent.as_posix()
if not script_dir or script_dir == ".":
return resource_relative
return os.path.relpath(resource_relative, start=script_dir)
def resource_directory(
object_key: str,
workspace_id: str,
owner_user_id: str,
) -> str:
"""从 object_key 解析资源所在目录(相对于用户根目录,根目录返回 "")。
object_key 形如 ``{ws_id}/{user_id}/{target_path}/{file_name}``;
不匹配该前缀的键(如无 ws/user 前缀的旧数据)统一视为根目录。
"""
prefix = f"{workspace_id}/{owner_user_id}/"
if not object_key.startswith(prefix):
return ""
tail = object_key[len(prefix):]
directory, _, _ = tail.rpartition("/")
return directory
def resource_payload(
resource: DataResources,
storage_object: StorageObjects,
owner_display_name: str | None = None,
) -> dict[str, Any]:
# ``object_key`` now follows ``{ws_id}/{user_id}/{target_path}/{file_name}``
# (target_path may be empty). Legacy objects still live under
# ``.resources/{file_name}`` and must remain readable. Both shapes share
# the same derivation: strip the workspace/user prefix and use the rest
# as the Jupyter-relative path.
workspace_prefix = f"{resource.workspace_id}/"
user_prefix = f"{resource.owner_user_id}/"
jupyter_accessible_path = ""
absolute_path = ""
if storage_object.object_key and storage_object.object_key.startswith(
workspace_prefix
):
remainder = storage_object.object_key[len(workspace_prefix):]
if remainder.startswith(user_prefix):
tail = remainder[len(user_prefix):]
jupyter_accessible_path = tail
absolute_path = (
workspaces_root()
/ resource.workspace_id
/ resource.owner_user_id
/ Path(tail)
).as_posix()
else:
jupyter_accessible_path = remainder
elif storage_object.object_key:
jupyter_accessible_path = storage_object.object_key
return {
"resource_id": resource.resource_id,
"workspace_id": resource.workspace_id,
"storage_object_id": resource.storage_object_id,
"owner_user_id": resource.owner_user_id,
"owner_display_name": owner_display_name,
"resource_name": resource.resource_name,
"description": resource.description,
"visibility": resource.visibility,
"status": resource.status,
"created_at": resource.created_at.isoformat(),
"updated_at": resource.updated_at.isoformat(),
"file": {
"file_name": storage_object.file_name,
"file_extension": storage_object.file_extension,
"mime_type": storage_object.mime_type,
"size_bytes": storage_object.size_bytes,
"content_hash": storage_object.content_hash,
"object_status": storage_object.object_status,
},
"jupyter_accessible_path": jupyter_accessible_path,
"absolute_path": absolute_path,
}
def can_view(resource: DataResources, context: RequestContext) -> bool:
# 同一 workspace 内:owner 永远可见自己的资源(含 private);
# 其他成员只见 visibility in {workspace, public} 的资源;
# admin 全部可见。
if resource.owner_user_id == context.user.user_id:
return True
if resource.visibility in {"workspace", "public"}:
return True
return context.is_admin
# 根据当前脚本位置计算资源的相对路径,便于 Notebook 中用相对路径读取文件。
@router.post("/{resource_id}/jupyter-relative-path")
async def resource_jupyter_relative_path(
@@ -465,31 +354,6 @@ async def list_resources(
}
async def get_visible_resource(
resource_id: str,
context: RequestContext,
session: AsyncSession,
) -> tuple[DataResources, StorageObjects]:
row = (
await session.execute(
select(DataResources, StorageObjects)
.join(
StorageObjects,
StorageObjects.storage_object_id
== DataResources.storage_object_id,
)
.where(
DataResources.resource_id == resource_id,
DataResources.workspace_id
== context.workspace.workspace_id,
DataResources.status == "active",
)
)
).one_or_none()
if row is None or not can_view(row[0], context):
raise HTTPException(status.HTTP_404_NOT_FOUND, "resource not found")
return row
# 查询单个数据资源的元数据与其关联文件信息。
@router.get("/{resource_id}")
@@ -510,28 +374,6 @@ async def get_resource(
}
# 为资源文件生成带时效的下载链接。
@router.post("/{resource_id}/download-url")
async def resource_download_url(
resource_id: str,
payload: DownloadUrlRequest,
request: Request,
context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
resource, _ = await get_visible_resource(
resource_id,
context,
session,
)
data = await create_download_url_payload(
await session.get(StorageObjects, resource.storage_object_id),
DownloadUrlRequest(expires_seconds=payload.expires_seconds),
request,
)
return {"request_id": context.request_id, "data": data["data"], "meta": {}}
# 软删除数据资源及其关联对象,遵循存储层的回收站策略。
@router.delete("/{resource_id}")
async def delete_resource(
@@ -0,0 +1,231 @@
"""数据资源下载、同源内容流与表格抽样预览接口。
与 ``resources.py`` 共用前缀 ``/api/v1/data-resources``,在 ``main`` 中并列挂载。
"""
from __future__ import annotations
import csv
import io
from typing import Any
from urllib.parse import quote
from common.db.models import StorageObjects
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from backend.api.dependencies import (
RequestContext,
database_session,
request_context,
)
from backend.api._resources_common import get_visible_resource
from backend.schemas.common import DownloadUrlRequest
from backend.services.storage import create_download_url_payload
router = APIRouter(prefix="/api/v1/data-resources", tags=["data-resources"])
_PREVIEW_MAX_BYTES = 2 * 1024 * 1024
_PREVIEW_DEFAULT_LIMIT = 100
_PREVIEW_MAX_LIMIT = 500
_TABLE_EXTENSIONS = {".csv", ".tsv"}
def _extension_of(file_name: str | None, resource_name: str | None) -> str:
for candidate in (file_name, resource_name):
if not candidate:
continue
lower = candidate.lower()
for ext in _TABLE_EXTENSIONS:
if lower.endswith(ext):
return ext
return ""
def _decode_preview_text(raw: bytes) -> str:
for encoding in ("utf-8-sig", "utf-8", "gb18030"):
try:
return raw.decode(encoding)
except UnicodeDecodeError:
continue
return raw.decode("utf-8", errors="replace")
async def _read_prefix_bytes(store: Any, object_key: str, max_bytes: int) -> bytes:
chunks: list[bytes] = []
total = 0
async for chunk in store.get_stream(object_key):
if not chunk:
continue
chunks.append(chunk)
total += len(chunk)
if total >= max_bytes:
break
data = b"".join(chunks)
return data[:max_bytes]
def _parse_table_preview(
text: str,
*,
delimiter: str,
limit: int,
byte_truncated: bool,
) -> dict[str, Any]:
reader = csv.reader(io.StringIO(text), delimiter=delimiter)
try:
header = next(reader)
except StopIteration:
return {
"kind": "table",
"columns": [],
"rows": [],
"row_count": 0,
"truncated": byte_truncated,
"delimiter": delimiter,
}
columns = [str(cell) if cell is not None else "" for cell in header]
if not any(columns):
columns = [f"col_{index + 1}" for index in range(max(len(header), 1))]
rows: list[list[str]] = []
truncated = byte_truncated
for row in reader:
if len(rows) >= limit:
truncated = True
break
cells = [str(cell) if cell is not None else "" for cell in row]
if len(cells) < len(columns):
cells.extend([""] * (len(columns) - len(cells)))
elif len(cells) > len(columns):
cells = cells[: len(columns)]
rows.append(cells)
return {
"kind": "table",
"columns": columns,
"rows": rows,
"row_count": len(rows),
"truncated": truncated,
"delimiter": delimiter,
}
@router.post("/{resource_id}/download-url")
async def resource_download_url(
resource_id: str,
payload: DownloadUrlRequest,
request: Request,
context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
resource, _ = await get_visible_resource(
resource_id,
context,
session,
)
data = await create_download_url_payload(
await session.get(StorageObjects, resource.storage_object_id),
DownloadUrlRequest(expires_seconds=payload.expires_seconds),
request,
)
return {"request_id": context.request_id, "data": data["data"], "meta": {}}
@router.get("/{resource_id}/content")
async def resource_content(
resource_id: str,
request: Request,
context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session),
) -> StreamingResponse:
"""同源流式读取资源字节,供前端预览器加载。"""
resource, storage_object = await get_visible_resource(
resource_id,
context,
session,
)
if storage_object.object_status != "available":
raise HTTPException(status.HTTP_404_NOT_FOUND, "object not found")
if (
not storage_object.bucket_name
or not storage_object.object_key
or storage_object.bucket_name not in request.app.state.object_stores
):
raise HTTPException(
status.HTTP_409_CONFLICT,
"object does not support content download",
)
file_name = storage_object.file_name or resource.resource_name or "file"
media_type = storage_object.mime_type or "application/octet-stream"
store = request.app.state.object_stores[storage_object.bucket_name]
stream = store.get_stream(storage_object.object_key)
headers = {
"Content-Disposition": f"inline; filename*=UTF-8''{quote(file_name)}",
"Cache-Control": "private, no-store",
}
if storage_object.size_bytes is not None:
headers["Content-Length"] = str(storage_object.size_bytes)
return StreamingResponse(
stream,
media_type=media_type,
headers=headers,
)
@router.get("/{resource_id}/preview")
async def resource_preview(
resource_id: str,
request: Request,
context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session),
limit: int = Query(default=_PREVIEW_DEFAULT_LIMIT, ge=1, le=_PREVIEW_MAX_LIMIT),
) -> dict[str, Any]:
"""表格类数据资源抽样预览(csv / tsv)。"""
resource, storage_object = await get_visible_resource(
resource_id,
context,
session,
)
if storage_object.object_status != "available":
raise HTTPException(status.HTTP_404_NOT_FOUND, "object not found")
if (
not storage_object.bucket_name
or not storage_object.object_key
or storage_object.bucket_name not in request.app.state.object_stores
):
raise HTTPException(
status.HTTP_409_CONFLICT,
"object does not support preview",
)
extension = _extension_of(storage_object.file_name, resource.resource_name)
if extension not in _TABLE_EXTENSIONS:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
"仅支持预览 .csv / .tsv 表格文件",
)
store = request.app.state.object_stores[storage_object.bucket_name]
raw = await _read_prefix_bytes(
store,
storage_object.object_key,
_PREVIEW_MAX_BYTES,
)
byte_truncated = (
storage_object.size_bytes is not None
and storage_object.size_bytes > len(raw)
) or len(raw) >= _PREVIEW_MAX_BYTES
text = _decode_preview_text(raw)
delimiter = "\t" if extension == ".tsv" else ","
payload = _parse_table_preview(
text,
delimiter=delimiter,
limit=limit,
byte_truncated=byte_truncated,
)
return {"request_id": context.request_id, "data": payload, "meta": {}}
+2
View File
@@ -40,6 +40,7 @@ from backend.api.jupyter import router as jupyter_router
from backend.api.operations import router as operations_router
from backend.api.platform import router as platform_router
from backend.api.resources import router as resources_router
from backend.api.resources_content import router as resources_content_router
from backend.api.schedules.runs import router as schedule_runs_router
from backend.api.schedules.schedules import router as schedules_router
from backend.api.scripts import router as scripts_router
@@ -143,6 +144,7 @@ app = create_service_app(
app.include_router(auth_router)
app.include_router(jupyter_router)
app.include_router(resources_router)
app.include_router(resources_content_router)
app.include_router(schedule_runs_router)
app.include_router(schedules_router)
app.include_router(scripts_router)
@@ -0,0 +1,39 @@
"""Unit tests for auth profile / password request schemas."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from backend.api.auth import PasswordChange, ProfileUpdate
from backend.api.platform.employees import PlatformEmployeePasswordReset
def test_profile_update_allows_partial_fields() -> None:
only_name = ProfileUpdate(display_name="张三")
assert only_name.display_name == "张三"
assert only_name.email is None
only_email = ProfileUpdate(email="a@example.com")
assert only_email.email == "a@example.com"
def test_profile_update_forbids_unknown_fields() -> None:
with pytest.raises(ValidationError):
ProfileUpdate(display_name="张三", username="hacked") # type: ignore[call-arg]
def test_password_change_enforces_new_password_length() -> None:
with pytest.raises(ValidationError):
PasswordChange(current_password="old-pass", new_password="short")
ok = PasswordChange(current_password="old-pass-1", new_password="new-pass-12")
assert ok.new_password == "new-pass-12"
def test_admin_password_reset_schema() -> None:
with pytest.raises(ValidationError):
PlatformEmployeePasswordReset(new_password="1234567")
payload = PlatformEmployeePasswordReset(new_password="reset-pass-9")
assert payload.new_password == "reset-pass-9"
+57
View File
@@ -0,0 +1,57 @@
"""Unit tests for platform cursor pagination helpers."""
from __future__ import annotations
import datetime
import pytest
from fastapi import HTTPException
from backend.api.platform._pagination import (
decode_cursor,
encode_cursor,
page_meta,
)
def test_encode_decode_roundtrip() -> None:
created_at = datetime.datetime(2026, 3, 15, 12, 30, 45, 123000)
row_id = "01HXY9C5B8N3K4P7Q6RT2V0J8D"
cursor = encode_cursor(created_at, row_id)
decoded_ts, decoded_id = decode_cursor(cursor)
assert decoded_ts == created_at
assert decoded_id == row_id
def test_decode_strips_timezone() -> None:
created_at = datetime.datetime(2026, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc)
cursor = encode_cursor(created_at, "abc")
decoded_ts, decoded_id = decode_cursor(cursor)
assert decoded_ts.tzinfo is None
assert decoded_id == "abc"
def test_decode_invalid_cursor_raises_400() -> None:
with pytest.raises(HTTPException) as exc_info:
decode_cursor("not-a-valid-cursor!!!")
assert exc_info.value.status_code == 400
def test_page_meta_has_more() -> None:
meta = page_meta(
limit=10,
page_count=10,
total_count=25,
next_cursor="abc",
)
assert meta["has_more"] is True
assert meta["next_cursor"] == "abc"
assert meta["total_count"] == 25
meta_end = page_meta(
limit=10,
page_count=5,
total_count=25,
next_cursor=None,
)
assert meta_end["has_more"] is False