"""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"])