```
feat: 添加公司AI管理平台基础架构 添加了完整的FastAPI后端项目结构,包括: - 环境配置文件(.env.example)和项目说明文档(README.md、AGENTS.md) - Dockerfile用于容器化部署 - 核心基础设施:配置管理、数据库连接、调度器、安全认证 - 模块化设计:AI代理、审批流程、审计日志、业务台账等功能模块 - 支持多数据库连接(主库和遗留系统只读库) - AI适配器支持OpenClaw、Hermes、OpenAI兼容接口 - 飞书集成、报表生成、风险监控等企业级功能 - 完整的依赖管理和测试指南 ```
This commit is contained in:
277
app/modules/legacy_mysql/service.py
Normal file
277
app/modules/legacy_mysql/service.py
Normal file
@@ -0,0 +1,277 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlalchemy.engine import Engine, RowMapping
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import legacy_engine
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.business.models import Project
|
||||
from app.modules.business.service import serialize_model
|
||||
|
||||
FORBIDDEN_SQL_TOKENS = {
|
||||
"insert",
|
||||
"update",
|
||||
"delete",
|
||||
"drop",
|
||||
"alter",
|
||||
"truncate",
|
||||
"create",
|
||||
"replace",
|
||||
"grant",
|
||||
"revoke",
|
||||
}
|
||||
|
||||
|
||||
def _jsonable(value: Any) -> Any:
|
||||
"""Convert database scalar values into JSON-friendly values."""
|
||||
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.isoformat()
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
return value
|
||||
|
||||
|
||||
def _row_to_dict(row: RowMapping) -> dict[str, Any]:
|
||||
"""Convert a SQLAlchemy row mapping to a serializable dictionary."""
|
||||
|
||||
return {key: _jsonable(value) for key, value in row.items()}
|
||||
|
||||
|
||||
class LegacyMySQLService:
|
||||
"""Read legacy MySQL data and sync projects into the internal ledger."""
|
||||
|
||||
def __init__(self, db: Session | None):
|
||||
self.db = db
|
||||
|
||||
@staticmethod
|
||||
def _ensure_engine() -> Engine:
|
||||
if legacy_engine is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="LEGACY_DATABASE_URL is not configured",
|
||||
)
|
||||
return legacy_engine
|
||||
|
||||
@staticmethod
|
||||
def _ensure_readonly(sql: str) -> None:
|
||||
stripped = sql.strip().lower()
|
||||
if not stripped.startswith("select"):
|
||||
raise HTTPException(status_code=400, detail="Only SELECT statements are allowed")
|
||||
tokens = {token.strip(" ;,\n\t") for token in stripped.replace("(", " ").split()}
|
||||
if tokens & FORBIDDEN_SQL_TOKENS:
|
||||
raise HTTPException(status_code=400, detail="Forbidden SQL token in readonly query")
|
||||
|
||||
def health(self) -> dict[str, str]:
|
||||
engine = self._ensure_engine()
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
except SQLAlchemyError as exc:
|
||||
raise HTTPException(status_code=503, detail=f"MySQL connection failed: {exc}") from exc
|
||||
return {"status": "ok"}
|
||||
|
||||
def list_tables(self) -> list[str]:
|
||||
engine = self._ensure_engine()
|
||||
return sorted(inspect(engine).get_table_names())
|
||||
|
||||
def describe_table(self, table_name: str) -> list[dict[str, Any]]:
|
||||
engine = self._ensure_engine()
|
||||
inspector = inspect(engine)
|
||||
if table_name not in inspector.get_table_names():
|
||||
raise HTTPException(status_code=404, detail="Table not found")
|
||||
columns = []
|
||||
for column in inspector.get_columns(table_name):
|
||||
columns.append(
|
||||
{
|
||||
"name": column["name"],
|
||||
"type": str(column["type"]),
|
||||
"nullable": column.get("nullable", True),
|
||||
"default": (
|
||||
str(column.get("default"))
|
||||
if column.get("default") is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
return columns
|
||||
|
||||
def execute_readonly(
|
||||
self,
|
||||
sql: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
limit: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
self._ensure_readonly(sql)
|
||||
engine = self._ensure_engine()
|
||||
params = dict(params or {})
|
||||
params.setdefault("limit", min(limit, 500))
|
||||
limited_sql = sql
|
||||
if " limit " not in sql.lower():
|
||||
limited_sql = f"{sql.rstrip(';')} LIMIT :limit"
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(text(limited_sql), params)
|
||||
rows = [_row_to_dict(row) for row in result.mappings().all()]
|
||||
columns = list(rows[0].keys()) if rows else []
|
||||
return {"columns": columns, "rows": rows, "row_count": len(rows)}
|
||||
|
||||
def fetch_default_projects(self, limit: int = 100) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
if not settings.legacy_project_query:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="LEGACY_PROJECT_QUERY is not configured. Configure it in .env first.",
|
||||
)
|
||||
return self.execute_readonly(settings.legacy_project_query, {"limit": limit}, limit=limit)
|
||||
|
||||
@staticmethod
|
||||
def _value(
|
||||
row: dict[str, Any],
|
||||
field_map: dict[str, str],
|
||||
internal_name: str,
|
||||
fallback: Any = None,
|
||||
) -> Any:
|
||||
source_name = field_map.get(internal_name, internal_name)
|
||||
if source_name in row:
|
||||
return row[source_name]
|
||||
return fallback
|
||||
|
||||
def _project_payload(self, row: dict[str, Any], field_map: dict[str, str]) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
external_id = self._value(row, field_map, "external_id", row.get("id"))
|
||||
raw_code = self._value(row, field_map, "code", None)
|
||||
code = str(raw_code) if raw_code else f"{settings.legacy_project_code_prefix}-{external_id}"
|
||||
return {
|
||||
"code": code,
|
||||
"external_id": str(external_id) if external_id is not None else code,
|
||||
"source_system": "legacy_mysql",
|
||||
"name": self._value(row, field_map, "name", "未命名项目"),
|
||||
"owner": self._value(row, field_map, "owner", None),
|
||||
"status": self._value(row, field_map, "status", "未知"),
|
||||
"progress_percent": int(
|
||||
self._value(
|
||||
row,
|
||||
field_map,
|
||||
"progress_percent",
|
||||
row.get("progress") or 0,
|
||||
)
|
||||
or 0
|
||||
),
|
||||
"start_date": self._value(row, field_map, "start_date", None),
|
||||
"due_date": self._value(row, field_map, "due_date", None),
|
||||
"budget_amount": (
|
||||
self._value(row, field_map, "budget_amount", row.get("budget") or 0) or 0
|
||||
),
|
||||
"actual_amount": (
|
||||
self._value(row, field_map, "actual_amount", row.get("actual_cost") or 0) or 0
|
||||
),
|
||||
"description": self._value(row, field_map, "description", None),
|
||||
}
|
||||
|
||||
def sync_projects(
|
||||
self,
|
||||
source_query: str | None = None,
|
||||
field_map: dict[str, str] | None = None,
|
||||
limit: int = 100,
|
||||
dry_run: bool = True,
|
||||
actor: str = "api",
|
||||
) -> dict[str, Any]:
|
||||
if self.db is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Application database session is not available",
|
||||
)
|
||||
|
||||
settings = get_settings()
|
||||
query = source_query or settings.legacy_project_query
|
||||
if not query:
|
||||
raise HTTPException(status_code=400, detail="Project sync query is not configured")
|
||||
|
||||
rows = self.execute_readonly(query, {"limit": limit}, limit=limit)["rows"]
|
||||
field_map = field_map or {}
|
||||
created = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
items: list[dict[str, Any]] = []
|
||||
|
||||
for row in rows:
|
||||
payload = self._project_payload(row, field_map)
|
||||
if not payload["external_id"] and not payload["code"]:
|
||||
skipped += 1
|
||||
items.append(
|
||||
{
|
||||
"action": "skipped",
|
||||
"reason": "missing external_id/code",
|
||||
"source": row,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
stmt = select(Project).where(
|
||||
Project.source_system == "legacy_mysql",
|
||||
Project.external_id == payload["external_id"],
|
||||
)
|
||||
record = self.db.execute(stmt).scalar_one_or_none()
|
||||
if record is None:
|
||||
record = self.db.execute(
|
||||
select(Project).where(Project.code == payload["code"])
|
||||
).scalar_one_or_none()
|
||||
|
||||
if record is None:
|
||||
created += 1
|
||||
action = "create"
|
||||
result = payload
|
||||
if not dry_run:
|
||||
record = Project(**payload)
|
||||
self.db.add(record)
|
||||
self.db.flush()
|
||||
result = serialize_model(record)
|
||||
else:
|
||||
updated += 1
|
||||
action = "update"
|
||||
if not dry_run:
|
||||
for key, value in payload.items():
|
||||
setattr(record, key, value)
|
||||
self.db.flush()
|
||||
result = serialize_model(record)
|
||||
else:
|
||||
result = payload
|
||||
items.append({"action": action, "project": result, "source": row})
|
||||
|
||||
if not dry_run:
|
||||
self.db.commit()
|
||||
|
||||
result = {
|
||||
"dry_run": dry_run,
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
"skipped": skipped,
|
||||
"items": items,
|
||||
}
|
||||
AuditService(self.db).log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source="legacy_mysql",
|
||||
action="sync_projects",
|
||||
target_type="projects",
|
||||
risk_level="medium",
|
||||
request_payload={
|
||||
"source_query": source_query or "LEGACY_PROJECT_QUERY",
|
||||
"field_map": field_map,
|
||||
"limit": limit,
|
||||
"dry_run": dry_run,
|
||||
},
|
||||
response_payload={
|
||||
key: result[key] for key in ["dry_run", "created", "updated", "skipped"]
|
||||
},
|
||||
)
|
||||
)
|
||||
return result
|
||||
Reference in New Issue
Block a user