feat: 添加公司AI管理平台基础架构

添加了完整的FastAPI后端项目结构,包括:
- 环境配置文件(.env.example)和项目说明文档(README.md、AGENTS.md)
- Dockerfile用于容器化部署
- 核心基础设施:配置管理、数据库连接、调度器、安全认证
- 模块化设计:AI代理、审批流程、审计日志、业务台账等功能模块
- 支持多数据库连接(主库和遗留系统只读库)
- AI适配器支持OpenClaw、Hermes、OpenAI兼容接口
- 飞书集成、报表生成、风险监控等企业级功能
- 完整的依赖管理和测试指南
```
This commit is contained in:
2026-06-21 21:57:28 +08:00
commit 71ca804764
68 changed files with 3662 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""Existing MySQL integration."""

View File

@@ -0,0 +1,50 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import require_api_key
from app.modules.legacy_mysql.schemas import (
LegacyProjectSyncRequest,
LegacyProjectSyncResult,
QueryResult,
ReadonlyQueryRequest,
)
from app.modules.legacy_mysql.service import LegacyMySQLService
router = APIRouter(dependencies=[Depends(require_api_key)])
@router.get("/health")
def mysql_health(db: Session = Depends(get_db)) -> dict[str, str]:
return LegacyMySQLService(db).health()
@router.get("/tables")
def list_tables(db: Session = Depends(get_db)) -> dict[str, list[str]]:
return {"tables": LegacyMySQLService(db).list_tables()}
@router.get("/tables/{table_name}")
def describe_table(table_name: str, db: Session = Depends(get_db)) -> dict:
return {"table": table_name, "columns": LegacyMySQLService(db).describe_table(table_name)}
@router.post("/query", response_model=QueryResult)
def readonly_query(payload: ReadonlyQueryRequest, db: Session = Depends(get_db)) -> dict:
return LegacyMySQLService(db).execute_readonly(payload.sql, payload.params, payload.limit)
@router.get("/projects", response_model=QueryResult)
def default_project_query(limit: int = 100, db: Session = Depends(get_db)) -> dict:
return LegacyMySQLService(db).fetch_default_projects(limit=limit)
@router.post("/projects/sync", response_model=LegacyProjectSyncResult)
def sync_projects(payload: LegacyProjectSyncRequest, db: Session = Depends(get_db)) -> dict:
return LegacyMySQLService(db).sync_projects(
source_query=payload.source_query,
field_map=payload.field_map,
limit=payload.limit,
dry_run=payload.dry_run,
actor=payload.actor,
)

View File

@@ -0,0 +1,54 @@
from typing import Any
from pydantic import BaseModel, Field
class ReadonlyQueryRequest(BaseModel):
"""Readonly SQL query request for the legacy MySQL connection."""
sql: str = Field(..., description="Readonly SELECT statement.")
params: dict[str, Any] = Field(default_factory=dict)
limit: int = Field(default=100, ge=1, le=500)
class QueryResult(BaseModel):
"""Tabular query result returned as JSON rows."""
columns: list[str]
rows: list[dict[str, Any]]
row_count: int
class LegacyProjectRecord(BaseModel):
"""Raw project row from the legacy project system."""
data: dict[str, Any]
class LegacyProjectSyncRequest(BaseModel):
"""Request body for syncing legacy projects into the internal ledger."""
source_query: str | None = Field(
default=None,
description="Optional SELECT query for project sync.",
)
field_map: dict[str, str] = Field(
default_factory=dict,
description=(
"Map internal project fields to legacy row fields, "
"e.g. {'name': 'project_name'}."
),
)
limit: int = Field(default=100, ge=1, le=500)
dry_run: bool = True
actor: str = "api"
class LegacyProjectSyncResult(BaseModel):
"""Summary of a legacy project sync operation."""
dry_run: bool
created: int
updated: int
skipped: int
items: list[dict[str, Any]]

View 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