feat(ai_agent): 新增openclaw_hermes混合AI适配器 新增OpenClawHermesAdapter适配器,结合Hermes记忆功能和OpenClaw执行能力, 实现AI问答流程中的记忆召回、执行操作和记忆存储的完整闭环。 同时更新NoopAdapter提示信息,添加新的模型提供商选项。 feat(business): 新增考勤、工作报告和风险事件业务模型 新增AttendanceRecord、WorkReport、RiskEvent和LegacySyncRun四个业务模型, 扩展业务领域注册表,支持考勤管理、工作报告生成和风险事件跟踪等核心业务功能。 feat(reports): 实现考勤汇总和工作日报周报生成功能 新增attendance_summary方法用于统计每日考勤情况, 新增generate_work_report方法用于生成日/周经营报告, 包含任务完成情况、待处理事项和风险指标等综合信息。 feat(risk): 扩展风险管理API端点和供应商风险检测 新增供应商风险查询端点和风险事件管理端点, 提供风险事件列表查询和自动生成功能, 增强供应商风险评估能力。 feat(feishu): 添加考勤查询命令和风险摘要增强 集成考勤汇总查询功能到飞书命令系统, 在风险摘要中添加供应商风险和开放风险事件统计, 丰富日常经营管理信息展示。 refactor(service): 优化业务服务数据验证和类型转换 重构_model_payload函数实现数据验证和类型转换, 添加列值类型强制转换逻辑,提高API数据处理的准确性和安全性。 build(deps): 添加postgresql数据库驱动依赖 在Dockerfile中添加psycopg[binary]==3.2.3依赖包, 支持PostgreSQL数据库连接和操作。 chore(config): 更新.gitignore文件排除备份和迁移目录 在.gitignore中添加AGENTS.md.bak-*和migration/目录排除规则, 避免备份文件和本地迁移工作区被提交到版本控制系统。 ```
294 lines
10 KiB
Python
294 lines
10 KiB
Python
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 LegacySyncRun, 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,
|
|
}
|
|
sync_run = LegacySyncRun(
|
|
code=f"SYNC-PROJECTS-{datetime.utcnow():%Y%m%d%H%M%S%f}",
|
|
domain="projects",
|
|
source_table="LEGACY_PROJECT_QUERY",
|
|
status="dry_run" if dry_run else "success",
|
|
finished_at=datetime.utcnow(),
|
|
created_count=created,
|
|
updated_count=updated,
|
|
skipped_count=skipped,
|
|
note="Project sync from readonly legacy MySQL",
|
|
)
|
|
self.db.add(sync_run)
|
|
self.db.commit()
|
|
self.db.refresh(sync_run)
|
|
result["sync_run_code"] = sync_run.code
|
|
|
|
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
|