feat: workspace CRUD at platform scope + drop audit_logs

新增系统管理模块 /api/v1/platform/*:
- workspace 实体 CRUD(创建/列表/详情/更新/软删除)
- workspace 成员 CRUD(添加/列表/更新/移除)
- SystemAdminContext 依赖,仅 platform_role_id 指向 admin 角色的用户可访问
- /api/v1/auth/me 与 /auth/login 增 is_system_admin 派生字段
- 不变量:每个 workspace 至少保留一个 admin;系统管理员无法自我移除成员
- 软删除 workspace 级联软删除其成员

清理 audit_logs(无运行时写入,纯死特性):
- baseline 移除 audit_logs 建表与三索引(20 → 19 tables)
- 删除 AuditLogs 模型定义与 __init__.py 导出
- 清理 migrate_system_json / migrate_legacy_workspaces 中的 audit 写入与回填代码

API.md 增 §七系统管理,§七/§八/§九 顺延为 §八/§九/§十,附录 A/B 同步更新。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-08-04 17:20:42 +08:00
co-authored by Claude Fable 5
parent c263ae6a5f
commit 45f0ff534f
9 changed files with 806 additions and 243 deletions
+11 -108
View File
@@ -5,18 +5,16 @@ import asyncio
import hashlib
import json
import os
from datetime import datetime
from pathlib import Path
from typing import Any
from urllib.parse import quote
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from common.db import create_database_engine, create_session_factory
from common.db.models import (
AuditLogs,
Permissions,
RolePermissions,
Roles,
@@ -39,19 +37,6 @@ PERMISSION_NAMES = {
"resource.public.manage": "管理公共资源",
"system.view": "查看系统管理",
"system.manage": "管理系统配置",
"audit.view": "查看审计日志",
}
ACTION_CATALOG = {
"保存调度配置": ("schedule.save", "schedule"),
"删除脚本对象": ("script.delete", "script"),
"删除实验记录": ("experiment.delete", "experiment"),
"删除数据资源": ("data_resource.delete", "data_resource"),
"上传数据资源": ("data_resource.upload", "data_resource"),
"新建脚本对象": ("script.create", "script"),
"修改用户角色": ("user.role.update", "user"),
"运行 Python": ("script.run_python", "script"),
"运行调度": ("schedule.run", "schedule"),
}
@@ -74,26 +59,18 @@ class LegacyRole(BaseModel):
permissions: list[str]
class LegacyAuditLog(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str
actorId: str
actorName: str
role: str
action: str
target: str
detail: str
status: str
createdAt: str
class LegacySystem(BaseModel):
model_config = ConfigDict(extra="forbid")
"""Legacy platform_data/system.json shape.
``extra='ignore'`` so legacy files that include other top-level
keys (e.g. historical audit-log payloads) can still be parsed —
only the fields this script actually consumes are listed below.
"""
model_config = ConfigDict(extra="ignore")
users: list[LegacyUser]
roles: list[LegacyRole]
audit_logs: list[LegacyAuditLog] = Field(alias="auditLogs")
def deterministic_legacy_ulid(entity_type: str, legacy_key: str) -> str:
@@ -120,10 +97,8 @@ def require_unique(values: list[str], label: str) -> None:
def validate_source(source: LegacySystem) -> None:
role_codes = [role.key for role in source.roles]
user_codes = [user.id for user in source.users]
audit_ids = [item.id for item in source.audit_logs]
require_unique(role_codes, "role keys")
require_unique(user_codes, "user ids")
require_unique(audit_ids, "audit ids")
role_code_set = set(role_codes)
unknown_roles = sorted(
@@ -134,15 +109,6 @@ def validate_source(source: LegacySystem) -> None:
if unknown_roles:
raise ValueError(f"users reference unknown roles: {unknown_roles}")
user_code_set = set(user_codes)
unknown_actors = sorted(
item.actorId
for item in source.audit_logs
if item.actorId not in user_code_set
)
if unknown_actors:
raise ValueError(f"audit logs reference unknown users: {unknown_actors}")
for role in source.roles:
require_unique(role.permissions, f"permissions of role {role.key}")
for permission_code in role.permissions:
@@ -177,8 +143,6 @@ def new_stats() -> dict[str, int]:
"role_permissions_inserted": 0,
"users_inserted": 0,
"users_updated": 0,
"audit_logs_inserted": 0,
"audit_logs_skipped": 0,
}
@@ -343,63 +307,6 @@ async def migrate_users(
return user_ids
def audit_catalog(action: str) -> tuple[str, str]:
known = ACTION_CATALOG.get(action)
if known is not None:
return known
digest = hashlib.sha256(action.encode("utf-8")).hexdigest()[:16]
return (f"legacy.action.{digest}", "legacy")
async def migrate_audit_logs(
session: AsyncSession,
source: LegacySystem,
user_ids: dict[str, str],
stats: dict[str, int],
) -> None:
existing_payloads = (
await session.scalars(select(AuditLogs.detail_json))
).all()
existing_legacy_ids = {
payload.get("legacy_id")
for payload in existing_payloads
if isinstance(payload, dict) and payload.get("legacy_id")
}
for legacy in sorted(source.audit_logs, key=lambda item: item.createdAt):
if legacy.id in existing_legacy_ids:
stats["audit_logs_skipped"] += 1
continue
action_code, target_type = audit_catalog(legacy.action)
session.add(
AuditLogs(
actor_user_id=user_ids[legacy.actorId],
action_code=action_code,
target_type=target_type,
target_id=legacy.target[:128] or None,
operation_status=(
"success" if legacy.status == "成功" else "failed"
),
created_at=datetime.strptime(
legacy.createdAt, "%Y-%m-%d %H:%M:%S"
),
detail_json={
"legacy_id": legacy.id,
"legacy_actor_name": legacy.actorName,
"legacy_role": legacy.role,
"legacy_action": legacy.action,
"legacy_target": legacy.target,
"legacy_detail": legacy.detail,
"legacy_status": legacy.status,
"migration_source": "platform_data/system.json",
},
)
)
existing_legacy_ids.add(legacy.id)
stats["audit_logs_inserted"] += 1
async def run_migration(
database_url: str,
source: LegacySystem,
@@ -424,12 +331,9 @@ async def run_migration(
permission_ids,
stats,
)
user_ids = await migrate_users(
await migrate_users(
session, source, role_ids, stats
)
await migrate_audit_logs(
session, source, user_ids, stats
)
if apply_changes:
await session.commit()
@@ -489,7 +393,6 @@ async def async_main() -> None:
len(role.permissions) for role in source.roles
),
"users": len(source.users),
"audit_logs": len(source.audit_logs),
},
"changes": stats,
}
@@ -501,4 +404,4 @@ def main() -> None:
if __name__ == "__main__":
main()
main()