Files
Cloud-Tour-to-Libo/app/api/data_platform.py
T

371 lines
11 KiB
Python

"""Project database catalog and registered-table CRUD APIs."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, File, HTTPException, Query, Request, Response, UploadFile
from app.auth import AdminUser, CurrentUser, DataViewerUser
from app.data_platform.mysql_service import (
IDENTIFIER_PATTERN,
MAX_CSV_BYTES,
create_custom_table,
create_custom_table_from_sql,
create_record,
create_table_column,
delete_custom_table,
delete_project_database,
delete_record,
delete_table_column,
ensure_project_database,
execute_project_sql,
export_csv_records,
import_csv_records,
inspect_table,
list_admin_action_logs,
list_databases,
list_records,
list_tables,
preview_csv_import,
rename_custom_table,
rename_project_database,
update_record,
update_table_column,
)
router = APIRouter(prefix="/data-platform")
@router.get("/databases")
async def databases(_user: DataViewerUser):
return await list_databases()
@router.post("/databases")
async def add_database(body: dict[str, Any], user: CurrentUser):
if "admin" not in user.get("roles", []):
raise HTTPException(403, "只有系统管理员可以创建关系数据库")
database_id = str(body.get("database_id") or body.get("project_id") or "").strip()
if not IDENTIFIER_PATTERN.fullmatch(database_id):
raise HTTPException(
400,
"数据库编码必须以小写字母开头,只能包含小写字母、数字和下划线,长度为 2–63 位",
)
display_name = str(body.get("display_name") or database_id).strip()
tenant_id = str(body.get("tenant_id") or database_id).strip()
try:
return await ensure_project_database(database_id, tenant_id, display_name)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@router.post("/databases/initialize")
async def initialize_databases(user: CurrentUser):
if "admin" not in user.get("roles", []):
raise HTTPException(403, "只有系统管理员可以初始化关系数据库")
raise HTTPException(410, "图谱项目不再自动初始化关系数据库,请在数据中心独立创建")
@router.patch("/databases/{project_id}")
async def edit_database(project_id: str, body: dict[str, Any], user: CurrentUser):
if "admin" not in user.get("roles", []):
raise HTTPException(403, "只有系统管理员可以修改关系数据库")
try:
return await rename_project_database(
project_id,
str(body.get("display_name") or ""),
)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@router.delete("/databases/{project_id}")
async def remove_database(project_id: str, body: dict[str, Any], user: CurrentUser):
if "admin" not in user.get("roles", []):
raise HTTPException(403, "只有系统管理员可以删除关系数据库")
try:
return await delete_project_database(
project_id,
str(body.get("confirm_name") or ""),
)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@router.get("/databases/{project_id}/tables")
async def tables(project_id: str, _user: DataViewerUser):
return await list_tables(project_id)
@router.post("/databases/{project_id}/console/execute")
async def execute_console_sql(
project_id: str,
body: dict[str, Any],
request: Request,
user: CurrentUser,
):
if "admin" not in user.get("roles", []):
raise HTTPException(403, "只有系统管理员可以使用 SQL 控制台")
try:
return await execute_project_sql(
project_id,
str(body.get("sql") or ""),
actor=user["username"],
source_ip=request.client.host if request.client else None,
)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@router.get("/security/audit-logs")
async def security_audit_logs(
_user: AdminUser,
limit: int = Query(default=200, ge=1, le=1000),
):
return await list_admin_action_logs(limit)
@router.get("/databases/{project_id}/tables/{table_code}/inspection")
async def table_inspection(project_id: str, table_code: str, _user: DataViewerUser):
return await inspect_table(project_id, table_code)
@router.post("/databases/{project_id}/tables/{table_code}/columns")
async def add_table_column(
project_id: str,
table_code: str,
body: dict[str, Any],
user: CurrentUser,
):
if "admin" not in user.get("roles", []):
raise HTTPException(403, "只有系统管理员可以新增字段")
try:
await create_table_column(project_id, table_code, body)
return await inspect_table(project_id, table_code)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@router.patch(
"/databases/{project_id}/tables/{table_code}/columns/{column_code}"
)
async def edit_table_column(
project_id: str,
table_code: str,
column_code: str,
body: dict[str, Any],
user: CurrentUser,
):
if "admin" not in user.get("roles", []):
raise HTTPException(403, "只有系统管理员可以修改字段")
try:
await update_table_column(project_id, table_code, column_code, body)
return await inspect_table(project_id, table_code)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@router.delete(
"/databases/{project_id}/tables/{table_code}/columns/{column_code}"
)
async def remove_table_column(
project_id: str,
table_code: str,
column_code: str,
body: dict[str, Any],
user: CurrentUser,
):
if "admin" not in user.get("roles", []):
raise HTTPException(403, "只有系统管理员可以删除字段")
try:
await delete_table_column(
project_id,
table_code,
column_code,
str(body.get("confirm_name") or ""),
)
return await inspect_table(project_id, table_code)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@router.post("/databases/{project_id}/tables")
async def add_table(project_id: str, body: dict[str, Any], user: CurrentUser):
if "admin" not in user.get("roles", []):
raise HTTPException(403, "只有系统管理员可以创建数据表")
try:
definition = await create_custom_table(project_id, body, user["username"])
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return definition.as_dict()
@router.post("/databases/{project_id}/tables/from-sql")
async def add_table_from_sql(project_id: str, body: dict[str, Any], user: CurrentUser):
if "admin" not in user.get("roles", []):
raise HTTPException(403, "只有系统管理员可以通过 SQL 创建数据表")
try:
definition = await create_custom_table_from_sql(
project_id,
body,
user["username"],
)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return definition.as_dict()
@router.patch("/databases/{project_id}/tables/{table_code}")
async def edit_table(
project_id: str,
table_code: str,
body: dict[str, Any],
user: CurrentUser,
):
if "admin" not in user.get("roles", []):
raise HTTPException(403, "只有系统管理员可以修改数据表")
try:
definition = await rename_custom_table(
project_id,
table_code,
str(body.get("code") or table_code),
str(body.get("label") or ""),
)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return definition.as_dict()
@router.delete("/databases/{project_id}/tables/{table_code}")
async def remove_table(
project_id: str,
table_code: str,
body: dict[str, Any],
user: CurrentUser,
):
if "admin" not in user.get("roles", []):
raise HTTPException(403, "只有系统管理员可以删除数据表")
try:
return await delete_custom_table(
project_id,
table_code,
str(body.get("confirm_name") or ""),
)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@router.get("/databases/{project_id}/tables/{table_code}/records")
async def records(
project_id: str,
table_code: str,
_user: DataViewerUser,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=5000),
search: str | None = None,
sort_field: str | None = None,
sort_order: str = Query(default="desc", pattern="^(asc|desc)$"),
):
return await list_records(
project_id,
table_code,
page=page,
page_size=page_size,
search=search,
sort_field=sort_field,
sort_order=sort_order,
)
async def _read_csv_upload(file: UploadFile) -> tuple[bytes, str]:
file_name = str(file.filename or "data.csv").strip()
if not file_name.lower().endswith(".csv"):
raise HTTPException(400, "仅支持 .csv 文件")
content = await file.read(MAX_CSV_BYTES + 1)
if len(content) > MAX_CSV_BYTES:
raise HTTPException(413, "CSV 文件不能超过 50 MB")
return content, file_name
@router.post(
"/databases/{project_id}/tables/{table_code}/records/import/preview"
)
async def preview_records_import(
project_id: str,
table_code: str,
user: AdminUser,
file: UploadFile = File(...),
):
content, file_name = await _read_csv_upload(file)
return await preview_csv_import(project_id, table_code, content, file_name)
@router.post("/databases/{project_id}/tables/{table_code}/records/import")
async def import_records(
project_id: str,
table_code: str,
user: AdminUser,
file: UploadFile = File(...),
):
content, file_name = await _read_csv_upload(file)
return await import_csv_records(
project_id,
table_code,
content,
file_name,
user["username"],
)
@router.get("/databases/{project_id}/tables/{table_code}/records/export")
async def export_records(
project_id: str,
table_code: str,
_user: DataViewerUser,
search: str | None = None,
):
content, filename, total = await export_csv_records(
project_id,
table_code,
search,
)
return Response(
content=content,
media_type="text/csv; charset=utf-8",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"X-Exported-Count": str(total),
},
)
@router.post("/databases/{project_id}/tables/{table_code}/records")
async def add_record(
project_id: str,
table_code: str,
body: dict[str, Any],
user: AdminUser,
):
return await create_record(project_id, table_code, body, user["username"])
@router.patch("/databases/{project_id}/tables/{table_code}/records/{record_id}")
async def edit_record(
project_id: str,
table_code: str,
record_id: str,
body: dict[str, Any],
user: AdminUser,
):
return await update_record(project_id, table_code, record_id, body, user["username"])
@router.delete("/databases/{project_id}/tables/{table_code}/records/{record_id}")
async def remove_record(
project_id: str,
table_code: str,
record_id: str,
user: AdminUser,
):
return await delete_record(project_id, table_code, record_id, user["username"])