```
feat: 添加公司AI管理平台基础架构 添加了完整的FastAPI后端项目结构,包括: - 环境配置文件(.env.example)和项目说明文档(README.md、AGENTS.md) - Dockerfile用于容器化部署 - 核心基础设施:配置管理、数据库连接、调度器、安全认证 - 模块化设计:AI代理、审批流程、审计日志、业务台账等功能模块 - 支持多数据库连接(主库和遗留系统只读库) - AI适配器支持OpenClaw、Hermes、OpenAI兼容接口 - 飞书集成、报表生成、风险监控等企业级功能 - 完整的依赖管理和测试指南 ```
This commit is contained in:
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Company AI management platform."""
|
||||
1
app/api/__init__.py
Normal file
1
app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API package."""
|
||||
29
app/api/router.py
Normal file
29
app/api/router.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.modules.ai_agent.routes import router as ai_router
|
||||
from app.modules.approvals.routes import router as approvals_router
|
||||
from app.modules.audit.routes import router as audit_router
|
||||
from app.modules.business.routes import router as business_router
|
||||
from app.modules.feishu.routes import router as feishu_router
|
||||
from app.modules.legacy_mysql.routes import router as legacy_mysql_router
|
||||
from app.modules.reports.routes import router as reports_router
|
||||
from app.modules.risk.routes import router as risk_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
|
||||
@api_router.get("/health", tags=["system"])
|
||||
def health_check() -> dict[str, str]:
|
||||
"""Return basic API health status."""
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
api_router.include_router(business_router, prefix="/business", tags=["business"])
|
||||
api_router.include_router(legacy_mysql_router, prefix="/integrations/mysql", tags=["mysql"])
|
||||
api_router.include_router(feishu_router, prefix="/integrations/feishu", tags=["feishu"])
|
||||
api_router.include_router(ai_router, prefix="/ai", tags=["ai"])
|
||||
api_router.include_router(approvals_router, prefix="/approvals", tags=["approvals"])
|
||||
api_router.include_router(reports_router, prefix="/reports", tags=["reports"])
|
||||
api_router.include_router(risk_router, prefix="/risks", tags=["risks"])
|
||||
api_router.include_router(audit_router, prefix="/audit", tags=["audit"])
|
||||
1
app/core/__init__.py
Normal file
1
app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Core infrastructure."""
|
||||
60
app/core/config.py
Normal file
60
app/core/config.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Runtime settings loaded from environment variables and `.env`."""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
app_name: str = "Company AI Management Platform"
|
||||
app_env: str = "local"
|
||||
debug: bool = False
|
||||
api_prefix: str = "/api/v1"
|
||||
api_key: str | None = None
|
||||
cors_origins: list[str] = Field(default_factory=lambda: ["*"])
|
||||
|
||||
database_url: str = "mysql+pymysql://root:password@127.0.0.1:3306/company_ai?charset=utf8mb4"
|
||||
legacy_database_url: str | None = None
|
||||
legacy_project_query: str | None = None
|
||||
legacy_project_code_prefix: str = "LEGACY"
|
||||
redis_url: str = "redis://127.0.0.1:6379/0"
|
||||
|
||||
feishu_base_url: str = "https://open.feishu.cn/open-apis"
|
||||
feishu_app_id: str | None = None
|
||||
feishu_app_secret: str | None = None
|
||||
feishu_verification_token: str | None = None
|
||||
feishu_encrypt_key: str | None = None
|
||||
feishu_default_chat_id: str | None = None
|
||||
|
||||
model_provider: str = "noop"
|
||||
openclaw_base_url: str = "http://127.0.0.1:18789"
|
||||
openclaw_api_key: str | None = None
|
||||
hermes_base_url: str = "http://127.0.0.1:8080"
|
||||
hermes_api_key: str | None = None
|
||||
direct_llm_base_url: str = "https://api.openai.com/v1"
|
||||
direct_llm_api_key: str | None = None
|
||||
direct_llm_model: str = "gpt-4.1-mini"
|
||||
|
||||
scheduler_enabled: bool = False
|
||||
daily_brief_cron_hour: int = 9
|
||||
daily_brief_cron_minute: int = 0
|
||||
weekly_project_report_day_of_week: str = "mon"
|
||||
weekly_project_report_cron_hour: int = 9
|
||||
weekly_project_report_cron_minute: int = 30
|
||||
|
||||
@field_validator("cors_origins", mode="before")
|
||||
@classmethod
|
||||
def parse_cors_origins(cls, value: str | list[str]) -> list[str]:
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""Return cached application settings."""
|
||||
|
||||
return Settings()
|
||||
51
app/core/database.py
Normal file
51
app/core/database.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for SQLAlchemy ORM models."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
engine = create_engine(settings.database_url, pool_pre_ping=True, pool_recycle=3600)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
||||
|
||||
legacy_engine = (
|
||||
create_engine(settings.legacy_database_url, pool_pre_ping=True, pool_recycle=3600)
|
||||
if settings.legacy_database_url
|
||||
else None
|
||||
)
|
||||
LegacySessionLocal = (
|
||||
sessionmaker(bind=legacy_engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
||||
if legacy_engine
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
"""Yield an application database session for FastAPI dependencies."""
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_legacy_db() -> Generator[Session, None, None]:
|
||||
"""Yield a legacy database session when the legacy connection is configured."""
|
||||
|
||||
if LegacySessionLocal is None:
|
||||
raise RuntimeError("LEGACY_DATABASE_URL is not configured")
|
||||
db = LegacySessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
82
app/core/scheduler.py
Normal file
82
app/core/scheduler.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
def attach_scheduler(app: FastAPI) -> None:
|
||||
"""Attach optional APScheduler jobs to the FastAPI application."""
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.scheduler_enabled:
|
||||
return
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
from app.core.database import SessionLocal
|
||||
from app.modules.reports.service import ReportService
|
||||
|
||||
scheduler = BackgroundScheduler(timezone="Asia/Shanghai")
|
||||
|
||||
def run_daily_brief() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
report = ReportService(db).daily_brief()
|
||||
app.state.last_daily_brief = report
|
||||
if (
|
||||
settings.feishu_app_id
|
||||
and settings.feishu_app_secret
|
||||
and settings.feishu_default_chat_id
|
||||
):
|
||||
ReportService(db).push_report(
|
||||
report,
|
||||
receive_id=settings.feishu_default_chat_id,
|
||||
receive_id_type="chat_id",
|
||||
actor="scheduler",
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def run_project_weekly() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
report = ReportService(db).project_weekly()
|
||||
app.state.last_project_weekly = report
|
||||
if (
|
||||
settings.feishu_app_id
|
||||
and settings.feishu_app_secret
|
||||
and settings.feishu_default_chat_id
|
||||
):
|
||||
ReportService(db).push_report(
|
||||
report,
|
||||
receive_id=settings.feishu_default_chat_id,
|
||||
receive_id_type="chat_id",
|
||||
actor="scheduler",
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
scheduler.add_job(
|
||||
run_daily_brief,
|
||||
trigger="cron",
|
||||
hour=settings.daily_brief_cron_hour,
|
||||
minute=settings.daily_brief_cron_minute,
|
||||
id="daily_brief_push",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
run_project_weekly,
|
||||
trigger="cron",
|
||||
day_of_week=settings.weekly_project_report_day_of_week,
|
||||
hour=settings.weekly_project_report_cron_hour,
|
||||
minute=settings.weekly_project_report_cron_minute,
|
||||
id="project_weekly_push",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
@app.on_event("startup")
|
||||
def start_scheduler() -> None:
|
||||
scheduler.start()
|
||||
|
||||
@app.on_event("shutdown")
|
||||
def stop_scheduler() -> None:
|
||||
scheduler.shutdown(wait=False)
|
||||
13
app/core/security.py
Normal file
13
app/core/security.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from fastapi import Header, HTTPException, status
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
def require_api_key(x_api_key: str | None = Header(default=None)) -> None:
|
||||
"""Validate the optional internal API key header."""
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.api_key:
|
||||
return
|
||||
if x_api_key != settings.api_key:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
|
||||
36
app/main.py
Normal file
36
app/main.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.router import api_router
|
||||
from app.core.config import get_settings
|
||||
from app.core.scheduler import attach_scheduler
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Create and configure the FastAPI application."""
|
||||
|
||||
settings = get_settings()
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
debug=settings.debug,
|
||||
version="0.1.0",
|
||||
description=(
|
||||
"AI integration layer for company lifecycle management, existing MySQL "
|
||||
"project systems, Feishu, OpenClaw, Hermes, and model providers."
|
||||
),
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(api_router, prefix=settings.api_prefix)
|
||||
attach_scheduler(app)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
1
app/modules/ai_agent/__init__.py
Normal file
1
app/modules/ai_agent/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""AI agent integration."""
|
||||
123
app/modules/ai_agent/adapters.py
Normal file
123
app/modules/ai_agent/adapters.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
|
||||
|
||||
class AIAdapter(ABC):
|
||||
"""Interface for model provider adapters."""
|
||||
|
||||
provider_name: str
|
||||
|
||||
@abstractmethod
|
||||
def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class NoopAdapter(AIAdapter):
|
||||
"""Deterministic adapter used when no model provider is configured."""
|
||||
|
||||
provider_name = "noop"
|
||||
|
||||
def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"answer": (
|
||||
"AI provider is not configured yet. This is a deterministic placeholder. "
|
||||
"Set MODEL_PROVIDER to openclaw, hermes, or direct_llm after credentials are ready."
|
||||
),
|
||||
"raw": {"prompt": prompt, "context": context or {}},
|
||||
}
|
||||
|
||||
|
||||
class OpenClawAdapter(AIAdapter):
|
||||
"""Adapter for an OpenClaw-compatible agent endpoint."""
|
||||
|
||||
provider_name = "openclaw"
|
||||
|
||||
def __init__(self, settings: Settings):
|
||||
self.settings = settings
|
||||
|
||||
def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
url = f"{self.settings.openclaw_base_url.rstrip('/')}/api/v1/agent/ask"
|
||||
headers = {}
|
||||
if self.settings.openclaw_api_key:
|
||||
headers["Authorization"] = f"Bearer {self.settings.openclaw_api_key}"
|
||||
payload = {"prompt": prompt, "context": context or {}}
|
||||
with httpx.Client(timeout=60) as client:
|
||||
response = client.post(url, json=payload, headers=headers)
|
||||
if response.status_code >= 400:
|
||||
raise HTTPException(status_code=502, detail={"openclaw_error": response.text})
|
||||
data = response.json()
|
||||
return {"answer": data.get("answer") or data.get("content") or str(data), "raw": data}
|
||||
|
||||
|
||||
class HermesAdapter(AIAdapter):
|
||||
"""Adapter for a Hermes-compatible memory or agent endpoint."""
|
||||
|
||||
provider_name = "hermes"
|
||||
|
||||
def __init__(self, settings: Settings):
|
||||
self.settings = settings
|
||||
|
||||
def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
url = f"{self.settings.hermes_base_url.rstrip('/')}/api/v1/ask"
|
||||
headers = {}
|
||||
if self.settings.hermes_api_key:
|
||||
headers["Authorization"] = f"Bearer {self.settings.hermes_api_key}"
|
||||
payload = {"prompt": prompt, "context": context or {}}
|
||||
with httpx.Client(timeout=60) as client:
|
||||
response = client.post(url, json=payload, headers=headers)
|
||||
if response.status_code >= 400:
|
||||
raise HTTPException(status_code=502, detail={"hermes_error": response.text})
|
||||
data = response.json()
|
||||
return {"answer": data.get("answer") or data.get("content") or str(data), "raw": data}
|
||||
|
||||
|
||||
class DirectLLMAdapter(AIAdapter):
|
||||
"""Adapter for OpenAI-compatible chat completions APIs."""
|
||||
|
||||
provider_name = "direct_llm"
|
||||
|
||||
def __init__(self, settings: Settings):
|
||||
self.settings = settings
|
||||
|
||||
def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
if not self.settings.direct_llm_api_key:
|
||||
raise HTTPException(status_code=503, detail="DIRECT_LLM_API_KEY is not configured")
|
||||
url = f"{self.settings.direct_llm_base_url.rstrip('/')}/chat/completions"
|
||||
headers = {"Authorization": f"Bearer {self.settings.direct_llm_api_key}"}
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are a company management AI. Be concise, cite data from context, "
|
||||
"and never approve payments, performance changes, or trades automatically."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": f"Context:\n{context or {}}\n\nTask:\n{prompt}"},
|
||||
]
|
||||
payload = {"model": self.settings.direct_llm_model, "messages": messages}
|
||||
with httpx.Client(timeout=60) as client:
|
||||
response = client.post(url, json=payload, headers=headers)
|
||||
if response.status_code >= 400:
|
||||
raise HTTPException(status_code=502, detail={"llm_error": response.text})
|
||||
data = response.json()
|
||||
answer = data["choices"][0]["message"]["content"]
|
||||
return {"answer": answer, "raw": data}
|
||||
|
||||
|
||||
def get_adapter() -> AIAdapter:
|
||||
"""Return the configured AI provider adapter."""
|
||||
|
||||
settings = get_settings()
|
||||
provider = settings.model_provider.lower()
|
||||
if provider == "openclaw":
|
||||
return OpenClawAdapter(settings)
|
||||
if provider == "hermes":
|
||||
return HermesAdapter(settings)
|
||||
if provider == "direct_llm":
|
||||
return DirectLLMAdapter(settings)
|
||||
return NoopAdapter()
|
||||
38
app/modules/ai_agent/routes.py
Normal file
38
app/modules/ai_agent/routes.py
Normal file
@@ -0,0 +1,38 @@
|
||||
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.ai_agent.schemas import (
|
||||
AIAskRequest,
|
||||
AIAskResponse,
|
||||
DraftPolicyRequest,
|
||||
InvestmentResearchRequest,
|
||||
)
|
||||
from app.modules.ai_agent.service import AIService
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||
|
||||
|
||||
@router.post("/ask", response_model=AIAskResponse)
|
||||
def ask(payload: AIAskRequest, db: Session = Depends(get_db)) -> dict:
|
||||
return AIService(db).ask(payload.prompt, payload.context, payload.actor, payload.source)
|
||||
|
||||
|
||||
@router.post("/draft-policy", response_model=AIAskResponse)
|
||||
def draft_policy(payload: DraftPolicyRequest, db: Session = Depends(get_db)) -> dict:
|
||||
return AIService(db).draft_policy(
|
||||
title=payload.title,
|
||||
policy_type=payload.policy_type,
|
||||
requirements=payload.requirements,
|
||||
actor=payload.actor,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/investment-research", response_model=AIAskResponse)
|
||||
def investment_research(payload: InvestmentResearchRequest, db: Session = Depends(get_db)) -> dict:
|
||||
return AIService(db).draft_investment_research(
|
||||
symbol_or_topic=payload.symbol_or_topic,
|
||||
risk_preference=payload.risk_preference,
|
||||
actor=payload.actor,
|
||||
)
|
||||
29
app/modules/ai_agent/schemas.py
Normal file
29
app/modules/ai_agent/schemas.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AIAskRequest(BaseModel):
|
||||
prompt: str
|
||||
context: dict[str, Any] = Field(default_factory=dict)
|
||||
actor: str = "api"
|
||||
source: str = "api"
|
||||
|
||||
|
||||
class AIAskResponse(BaseModel):
|
||||
provider: str
|
||||
answer: str
|
||||
raw: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class DraftPolicyRequest(BaseModel):
|
||||
title: str
|
||||
policy_type: str
|
||||
requirements: list[str]
|
||||
actor: str = "api"
|
||||
|
||||
|
||||
class InvestmentResearchRequest(BaseModel):
|
||||
symbol_or_topic: str
|
||||
risk_preference: str = "balanced"
|
||||
actor: str = "api"
|
||||
70
app/modules/ai_agent/service.py
Normal file
70
app/modules/ai_agent/service.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.ai_agent.adapters import get_adapter
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
|
||||
|
||||
class AIService:
|
||||
"""Coordinate AI provider calls and audit logging."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.audit = AuditService(db)
|
||||
|
||||
def ask(
|
||||
self,
|
||||
prompt: str,
|
||||
context: dict[str, Any] | None = None,
|
||||
actor: str = "api",
|
||||
source: str = "api",
|
||||
) -> dict[str, Any]:
|
||||
adapter = get_adapter()
|
||||
result = adapter.ask(prompt, context or {})
|
||||
response = {
|
||||
"provider": adapter.provider_name,
|
||||
"answer": result["answer"],
|
||||
"raw": result.get("raw", {}),
|
||||
}
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=source,
|
||||
action="ai.ask",
|
||||
target_type="ai",
|
||||
risk_level="medium",
|
||||
request_payload={"prompt": prompt, "context": context or {}},
|
||||
response_payload=response,
|
||||
)
|
||||
)
|
||||
return response
|
||||
|
||||
def draft_policy(
|
||||
self,
|
||||
title: str,
|
||||
policy_type: str,
|
||||
requirements: list[str],
|
||||
actor: str,
|
||||
) -> dict[str, Any]:
|
||||
prompt = (
|
||||
f"Draft a company policy in Chinese. Title: {title}. Type: {policy_type}. "
|
||||
"Include purpose, scope, roles, process, approval rules, audit rules, and KPI linkage. "
|
||||
f"Requirements: {requirements}"
|
||||
)
|
||||
return self.ask(prompt, actor=actor, source="policy")
|
||||
|
||||
def draft_investment_research(
|
||||
self,
|
||||
symbol_or_topic: str,
|
||||
risk_preference: str,
|
||||
actor: str,
|
||||
) -> dict[str, Any]:
|
||||
prompt = (
|
||||
"Create an investment research memo in Chinese. Do not give direct trading "
|
||||
"instructions. Include thesis, risks, data needed, position sizing constraints, "
|
||||
"and human approval checklist. "
|
||||
f"Topic: {symbol_or_topic}. Risk preference: {risk_preference}."
|
||||
)
|
||||
return self.ask(prompt, actor=actor, source="investment")
|
||||
1
app/modules/approvals/__init__.py
Normal file
1
app/modules/approvals/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Approval workflow module."""
|
||||
29
app/modules/approvals/models.py
Normal file
29
app/modules/approvals/models.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class ApprovalRequest(Base):
|
||||
__tablename__ = "approval_requests"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
ticket_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
domain: Mapped[str] = mapped_column(String(128), index=True)
|
||||
record_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
action: Mapped[str] = mapped_column(String(128), index=True)
|
||||
applicant: Mapped[str] = mapped_column(String(128), default="api", index=True)
|
||||
approver: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
payload: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
decision_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow,
|
||||
)
|
||||
decided_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
38
app/modules/approvals/routes.py
Normal file
38
app/modules/approvals/routes.py
Normal file
@@ -0,0 +1,38 @@
|
||||
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.approvals.schemas import ApprovalCreate, ApprovalDecision, ApprovalRead
|
||||
from app.modules.approvals.service import ApprovalService
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||
|
||||
|
||||
@router.post("", response_model=ApprovalRead)
|
||||
def create_approval(payload: ApprovalCreate, db: Session = Depends(get_db)):
|
||||
return ApprovalService(db).create(payload)
|
||||
|
||||
|
||||
@router.get("", response_model=list[ApprovalRead])
|
||||
def list_approvals(
|
||||
status: str | None = None,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return ApprovalService(db).list(status_filter=status, limit=limit)
|
||||
|
||||
|
||||
@router.get("/{ticket_id}", response_model=ApprovalRead)
|
||||
def get_approval(ticket_id: str, db: Session = Depends(get_db)):
|
||||
return ApprovalService(db).get_by_ticket(ticket_id)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/approve", response_model=ApprovalRead)
|
||||
def approve(ticket_id: str, payload: ApprovalDecision, db: Session = Depends(get_db)):
|
||||
return ApprovalService(db).decide(ticket_id, payload.approver, True, payload.comment)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/reject", response_model=ApprovalRead)
|
||||
def reject(ticket_id: str, payload: ApprovalDecision, db: Session = Depends(get_db)):
|
||||
return ApprovalService(db).decide(ticket_id, payload.approver, False, payload.comment)
|
||||
37
app/modules/approvals/schemas.py
Normal file
37
app/modules/approvals/schemas.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ApprovalCreate(BaseModel):
|
||||
domain: str
|
||||
record_id: str | None = None
|
||||
action: str
|
||||
applicant: str = "api"
|
||||
reason: str | None = None
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ApprovalDecision(BaseModel):
|
||||
approver: str
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class ApprovalRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
ticket_id: str
|
||||
domain: str
|
||||
record_id: str | None
|
||||
action: str
|
||||
applicant: str
|
||||
approver: str | None
|
||||
status: str
|
||||
reason: str | None
|
||||
payload: str | None
|
||||
decision_comment: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
decided_at: datetime | None
|
||||
111
app/modules/approvals/service.py
Normal file
111
app/modules/approvals/service.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.approvals.models import ApprovalRequest
|
||||
from app.modules.approvals.schemas import ApprovalCreate
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
|
||||
|
||||
class ApprovalService:
|
||||
"""Create, decide, and validate approval tickets for guarded actions."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.audit = AuditService(db)
|
||||
|
||||
def create(self, payload: ApprovalCreate) -> ApprovalRequest:
|
||||
ticket = ApprovalRequest(
|
||||
ticket_id=f"APR-{uuid.uuid4().hex[:12].upper()}",
|
||||
domain=payload.domain,
|
||||
record_id=payload.record_id,
|
||||
action=payload.action,
|
||||
applicant=payload.applicant,
|
||||
reason=payload.reason,
|
||||
payload=json.dumps(payload.payload, ensure_ascii=False, default=str),
|
||||
)
|
||||
self.db.add(ticket)
|
||||
self.db.commit()
|
||||
self.db.refresh(ticket)
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=payload.applicant,
|
||||
source="approval",
|
||||
action="approval.create",
|
||||
target_type=payload.domain,
|
||||
target_id=payload.record_id,
|
||||
risk_level="medium",
|
||||
request_payload=payload.model_dump(),
|
||||
response_payload={"ticket_id": ticket.ticket_id, "status": ticket.status},
|
||||
)
|
||||
)
|
||||
return ticket
|
||||
|
||||
def list(self, status_filter: str | None = None, limit: int = 100) -> list[ApprovalRequest]:
|
||||
stmt = select(ApprovalRequest).order_by(ApprovalRequest.id.desc()).limit(min(limit, 500))
|
||||
if status_filter:
|
||||
stmt = stmt.where(ApprovalRequest.status == status_filter)
|
||||
return list(self.db.execute(stmt).scalars())
|
||||
|
||||
def get_by_ticket(self, ticket_id: str) -> ApprovalRequest:
|
||||
ticket = self.db.execute(
|
||||
select(ApprovalRequest).where(ApprovalRequest.ticket_id == ticket_id)
|
||||
).scalar_one_or_none()
|
||||
if ticket is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Approval ticket not found",
|
||||
)
|
||||
return ticket
|
||||
|
||||
def decide(
|
||||
self,
|
||||
ticket_id: str,
|
||||
approver: str,
|
||||
approved: bool,
|
||||
comment: str | None,
|
||||
) -> ApprovalRequest:
|
||||
ticket = self.get_by_ticket(ticket_id)
|
||||
if ticket.status != "pending":
|
||||
raise HTTPException(status_code=409, detail="Approval ticket already decided")
|
||||
ticket.status = "approved" if approved else "rejected"
|
||||
ticket.approver = approver
|
||||
ticket.decision_comment = comment
|
||||
from datetime import datetime
|
||||
|
||||
ticket.decided_at = datetime.utcnow()
|
||||
self.db.commit()
|
||||
self.db.refresh(ticket)
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=approver,
|
||||
source="approval",
|
||||
action="approval.approve" if approved else "approval.reject",
|
||||
target_type=ticket.domain,
|
||||
target_id=ticket.record_id,
|
||||
risk_level="high",
|
||||
request_payload={"ticket_id": ticket_id, "comment": comment},
|
||||
response_payload={"status": ticket.status},
|
||||
)
|
||||
)
|
||||
return ticket
|
||||
|
||||
def is_approved_for(
|
||||
self,
|
||||
ticket_id: str,
|
||||
domain: str,
|
||||
record_id: str | int | None,
|
||||
action: str,
|
||||
) -> bool:
|
||||
ticket = self.get_by_ticket(ticket_id)
|
||||
if ticket.status != "approved":
|
||||
return False
|
||||
if ticket.domain != domain:
|
||||
return False
|
||||
if ticket.record_id and record_id is not None and str(ticket.record_id) != str(record_id):
|
||||
return False
|
||||
return ticket.action in {action, "update", f"update:{domain}", "*"}
|
||||
1
app/modules/audit/__init__.py
Normal file
1
app/modules/audit/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Audit module."""
|
||||
22
app/modules/audit/models.py
Normal file
22
app/modules/audit/models.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
actor: Mapped[str] = mapped_column(String(128), default="system", index=True)
|
||||
source: Mapped[str] = mapped_column(String(64), default="api", index=True)
|
||||
action: Mapped[str] = mapped_column(String(128), index=True)
|
||||
target_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
target_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
risk_level: Mapped[str] = mapped_column(String(32), default="low", index=True)
|
||||
request_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
response_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="success", index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
14
app/modules/audit/routes.py
Normal file
14
app/modules/audit/routes.py
Normal file
@@ -0,0 +1,14 @@
|
||||
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.audit.schemas import AuditLogRead
|
||||
from app.modules.audit.service import AuditService
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||
|
||||
|
||||
@router.get("/logs", response_model=list[AuditLogRead])
|
||||
def list_audit_logs(limit: int = 100, db: Session = Depends(get_db)) -> list:
|
||||
return AuditService(db).list_logs(limit=limit)
|
||||
32
app/modules/audit/schemas.py
Normal file
32
app/modules/audit/schemas.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class AuditLogCreate(BaseModel):
|
||||
actor: str = "system"
|
||||
source: str = "api"
|
||||
action: str
|
||||
target_type: str | None = None
|
||||
target_id: str | None = None
|
||||
risk_level: str = "low"
|
||||
request_payload: Any | None = None
|
||||
response_payload: Any | None = None
|
||||
status: str = "success"
|
||||
|
||||
|
||||
class AuditLogRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
actor: str
|
||||
source: str
|
||||
action: str
|
||||
target_type: str | None
|
||||
target_id: str | None
|
||||
risk_level: str
|
||||
request_payload: str | None
|
||||
response_payload: str | None
|
||||
status: str
|
||||
created_at: datetime
|
||||
46
app/modules/audit/service.py
Normal file
46
app/modules/audit/service.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.audit.models import AuditLog
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
|
||||
|
||||
def _dump(value: Any | None) -> str | None:
|
||||
"""Serialize audit payloads while preserving existing strings."""
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return json.dumps(value, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
class AuditService:
|
||||
"""Persist and query audit log entries."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def log(self, payload: AuditLogCreate) -> AuditLog:
|
||||
record = AuditLog(
|
||||
actor=payload.actor,
|
||||
source=payload.source,
|
||||
action=payload.action,
|
||||
target_type=payload.target_type,
|
||||
target_id=payload.target_id,
|
||||
risk_level=payload.risk_level,
|
||||
request_payload=_dump(payload.request_payload),
|
||||
response_payload=_dump(payload.response_payload),
|
||||
status=payload.status,
|
||||
)
|
||||
self.db.add(record)
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
return record
|
||||
|
||||
def list_logs(self, limit: int = 100) -> list[AuditLog]:
|
||||
stmt = select(AuditLog).order_by(AuditLog.id.desc()).limit(min(limit, 500))
|
||||
return list(self.db.execute(stmt).scalars())
|
||||
1
app/modules/business/__init__.py
Normal file
1
app/modules/business/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Business lifecycle module."""
|
||||
159
app/modules/business/models.py
Normal file
159
app/modules/business/models.py
Normal file
@@ -0,0 +1,159 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Date, DateTime, Integer, Numeric, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
)
|
||||
|
||||
|
||||
class Project(Base, TimestampMixin):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), index=True)
|
||||
owner: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(64), default="立项", index=True)
|
||||
priority: Mapped[str] = mapped_column(String(32), default="P2")
|
||||
progress_percent: Mapped[int] = mapped_column(Integer, default=0)
|
||||
risk_level: Mapped[str] = mapped_column(String(32), default="low", index=True)
|
||||
budget_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0)
|
||||
actual_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0)
|
||||
start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
source_system: Mapped[str] = mapped_column(String(64), default="internal")
|
||||
external_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
|
||||
|
||||
class WorkTask(Base, TimestampMixin):
|
||||
__tablename__ = "work_tasks"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), index=True)
|
||||
project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
owner: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(64), default="待办", index=True)
|
||||
priority: Mapped[str] = mapped_column(String(32), default="P2")
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
blocker: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class Procurement(Base, TimestampMixin):
|
||||
__tablename__ = "procurements"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), index=True)
|
||||
applicant: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
supplier_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
budget_subject: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
expected_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0)
|
||||
actual_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0)
|
||||
approval_status: Mapped[str] = mapped_column(String(64), default="草稿", index=True)
|
||||
delivery_status: Mapped[str] = mapped_column(String(64), default="未到货", index=True)
|
||||
payment_status: Mapped[str] = mapped_column(String(64), default="未付款", index=True)
|
||||
comparison_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class Expense(Base, TimestampMixin):
|
||||
__tablename__ = "expenses"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
expense_type: Mapped[str] = mapped_column(String(128), index=True)
|
||||
amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0)
|
||||
applicant: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
department: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
budget_subject: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
payment_account: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
invoice_status: Mapped[str] = mapped_column(String(64), default="未收票")
|
||||
approval_status: Mapped[str] = mapped_column(String(64), default="草稿", index=True)
|
||||
payment_status: Mapped[str] = mapped_column(String(64), default="未付款", index=True)
|
||||
|
||||
|
||||
class FundAccount(Base, TimestampMixin):
|
||||
__tablename__ = "fund_accounts"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), index=True)
|
||||
account_type: Mapped[str] = mapped_column(String(64), default="bank")
|
||||
current_balance: Mapped[Decimal] = mapped_column(Numeric(16, 2), default=0)
|
||||
expected_receivable: Mapped[Decimal] = mapped_column(Numeric(16, 2), default=0)
|
||||
expected_payable: Mapped[Decimal] = mapped_column(Numeric(16, 2), default=0)
|
||||
safety_line: Mapped[Decimal] = mapped_column(Numeric(16, 2), default=0)
|
||||
risk_level: Mapped[str] = mapped_column(String(32), default="low", index=True)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class Policy(Base, TimestampMixin):
|
||||
__tablename__ = "policies"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), index=True)
|
||||
policy_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
owner_department: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
version: Mapped[str] = mapped_column(String(32), default="v1.0")
|
||||
status: Mapped[str] = mapped_column(String(64), default="草案", index=True)
|
||||
effective_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
feishu_doc_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class Standard(Base, TimestampMixin):
|
||||
__tablename__ = "standards"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), index=True)
|
||||
standard_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
applies_to: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(64), default="有效", index=True)
|
||||
check_items: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
remediation: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
policy_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
|
||||
|
||||
class PerformanceMetric(Base, TimestampMixin):
|
||||
__tablename__ = "performance_metrics"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), index=True)
|
||||
applies_to_role: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
formula: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
weight: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=0)
|
||||
data_source: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
auto_score: Mapped[Decimal] = mapped_column(Numeric(8, 2), default=0)
|
||||
confirmed_score: Mapped[Decimal | None] = mapped_column(Numeric(8, 2), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(64), default="草稿", index=True)
|
||||
|
||||
|
||||
class Supplier(Base, TimestampMixin):
|
||||
__tablename__ = "suppliers"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), index=True)
|
||||
category: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
contact: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
quality_score: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=0)
|
||||
delivery_score: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=0)
|
||||
price_score: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=0)
|
||||
risk_level: Mapped[str] = mapped_column(String(32), default="low", index=True)
|
||||
blacklist_status: Mapped[str] = mapped_column(String(32), default="normal", index=True)
|
||||
26
app/modules/business/registry.py
Normal file
26
app/modules/business/registry.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from sqlalchemy.orm import DeclarativeMeta
|
||||
|
||||
from app.modules.business import models
|
||||
|
||||
|
||||
DOMAIN_MODELS: dict[str, type[DeclarativeMeta]] = {
|
||||
"projects": models.Project,
|
||||
"tasks": models.WorkTask,
|
||||
"procurements": models.Procurement,
|
||||
"expenses": models.Expense,
|
||||
"fund-accounts": models.FundAccount,
|
||||
"policies": models.Policy,
|
||||
"standards": models.Standard,
|
||||
"performance-metrics": models.PerformanceMetric,
|
||||
"suppliers": models.Supplier,
|
||||
}
|
||||
|
||||
LOW_RISK_DOMAINS = {"projects", "tasks", "procurements", "expenses", "policies", "standards"}
|
||||
HIGH_RISK_DOMAINS = {"fund-accounts", "performance-metrics"}
|
||||
|
||||
|
||||
def get_domain_model(domain: str) -> type[DeclarativeMeta]:
|
||||
if domain not in DOMAIN_MODELS:
|
||||
supported = ", ".join(sorted(DOMAIN_MODELS))
|
||||
raise KeyError(f"Unsupported domain '{domain}'. Supported: {supported}")
|
||||
return DOMAIN_MODELS[domain]
|
||||
71
app/modules/business/routes.py
Normal file
71
app/modules/business/routes.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import require_api_key
|
||||
from app.modules.business.registry import DOMAIN_MODELS
|
||||
from app.modules.business.schemas import DomainListRead, DomainRecordCreate, DomainRecordUpdate
|
||||
from app.modules.business.service import BusinessService
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||
|
||||
|
||||
@router.get("/domains")
|
||||
def list_domains() -> dict[str, list[str]]:
|
||||
return {"domains": sorted(DOMAIN_MODELS)}
|
||||
|
||||
|
||||
@router.get("/{domain}", response_model=DomainListRead)
|
||||
def list_records(
|
||||
domain: str,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
status: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
try:
|
||||
total, items = BusinessService(db).list_records(domain, limit, offset, status)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return {"domain": domain, "total": total, "items": items}
|
||||
|
||||
|
||||
@router.get("/{domain}/{record_id}")
|
||||
def get_record(domain: str, record_id: int, db: Session = Depends(get_db)) -> dict:
|
||||
try:
|
||||
return {"domain": domain, "data": BusinessService(db).get_record(domain, record_id)}
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{domain}")
|
||||
def create_record(
|
||||
domain: str,
|
||||
payload: DomainRecordCreate,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
try:
|
||||
data = BusinessService(db).create_record(domain, payload.data, payload.actor)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return {"domain": domain, "data": data}
|
||||
|
||||
|
||||
@router.patch("/{domain}/{record_id}")
|
||||
def update_record(
|
||||
domain: str,
|
||||
record_id: int,
|
||||
payload: DomainRecordUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
try:
|
||||
data = BusinessService(db).update_record(
|
||||
domain,
|
||||
record_id,
|
||||
payload.data,
|
||||
actor=payload.actor,
|
||||
approval_ticket_id=payload.approval_ticket_id,
|
||||
)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return {"domain": domain, "data": data}
|
||||
28
app/modules/business/schemas.py
Normal file
28
app/modules/business/schemas.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DomainRecordCreate(BaseModel):
|
||||
data: dict[str, Any] = Field(..., description="Domain fields to create.")
|
||||
actor: str = "api"
|
||||
|
||||
|
||||
class DomainRecordUpdate(BaseModel):
|
||||
data: dict[str, Any] = Field(..., description="Domain fields to update.")
|
||||
actor: str = "api"
|
||||
approval_ticket_id: str | None = Field(
|
||||
default=None,
|
||||
description="Required by policy for high-risk updates such as funds or performance.",
|
||||
)
|
||||
|
||||
|
||||
class DomainRecordRead(BaseModel):
|
||||
domain: str
|
||||
data: dict[str, Any]
|
||||
|
||||
|
||||
class DomainListRead(BaseModel):
|
||||
domain: str
|
||||
total: int
|
||||
items: list[dict[str, Any]]
|
||||
138
app/modules/business/service.py
Normal file
138
app/modules/business/service.py
Normal file
@@ -0,0 +1,138 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import Select, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.approvals.service import ApprovalService
|
||||
from app.modules.business.registry import HIGH_RISK_DOMAINS, get_domain_model
|
||||
|
||||
|
||||
def serialize_model(record: Any) -> dict[str, Any]:
|
||||
"""Convert a SQLAlchemy model instance into a JSON-friendly dictionary."""
|
||||
|
||||
data: dict[str, Any] = {}
|
||||
for column in record.__table__.columns:
|
||||
value = getattr(record, column.name)
|
||||
if isinstance(value, (datetime, date)):
|
||||
data[column.name] = value.isoformat()
|
||||
elif isinstance(value, Decimal):
|
||||
data[column.name] = float(value)
|
||||
else:
|
||||
data[column.name] = value
|
||||
return data
|
||||
|
||||
|
||||
class BusinessService:
|
||||
"""Manage generic CRUD operations across registered business domains."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.audit = AuditService(db)
|
||||
|
||||
def list_records(
|
||||
self,
|
||||
domain: str,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
status_filter: str | None = None,
|
||||
) -> tuple[int, list[dict[str, Any]]]:
|
||||
model = get_domain_model(domain)
|
||||
stmt: Select = select(model)
|
||||
count_stmt = select(func.count()).select_from(model)
|
||||
if status_filter and hasattr(model, "status"):
|
||||
stmt = stmt.where(model.status == status_filter)
|
||||
count_stmt = count_stmt.where(model.status == status_filter)
|
||||
stmt = stmt.order_by(model.id.desc()).limit(min(limit, 500)).offset(max(offset, 0))
|
||||
total = int(self.db.execute(count_stmt).scalar() or 0)
|
||||
return total, [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
||||
|
||||
def get_record(self, domain: str, record_id: int) -> dict[str, Any]:
|
||||
model = get_domain_model(domain)
|
||||
record = self.db.get(model, record_id)
|
||||
if record is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Record not found")
|
||||
return serialize_model(record)
|
||||
|
||||
def create_record(
|
||||
self,
|
||||
domain: str,
|
||||
data: dict[str, Any],
|
||||
actor: str = "api",
|
||||
) -> dict[str, Any]:
|
||||
model = get_domain_model(domain)
|
||||
allowed = {column.name for column in model.__table__.columns if column.name != "id"}
|
||||
payload = {key: value for key, value in data.items() if key in allowed}
|
||||
record = model(**payload)
|
||||
self.db.add(record)
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
result = serialize_model(record)
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source="api",
|
||||
action=f"create:{domain}",
|
||||
target_type=domain,
|
||||
target_id=str(record.id),
|
||||
request_payload=data,
|
||||
response_payload=result,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
def update_record(
|
||||
self,
|
||||
domain: str,
|
||||
record_id: int,
|
||||
data: dict[str, Any],
|
||||
actor: str = "api",
|
||||
approval_ticket_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if domain in HIGH_RISK_DOMAINS:
|
||||
if not approval_ticket_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="High-risk domain update requires approval_ticket_id",
|
||||
)
|
||||
if not ApprovalService(self.db).is_approved_for(
|
||||
approval_ticket_id,
|
||||
domain,
|
||||
record_id,
|
||||
f"update:{domain}",
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Approval ticket is not approved for this update",
|
||||
)
|
||||
model = get_domain_model(domain)
|
||||
record = self.db.get(model, record_id)
|
||||
if record is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Record not found",
|
||||
)
|
||||
allowed = {column.name for column in model.__table__.columns if column.name != "id"}
|
||||
for key, value in data.items():
|
||||
if key in allowed:
|
||||
setattr(record, key, value)
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
result = serialize_model(record)
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source="api",
|
||||
action=f"update:{domain}",
|
||||
target_type=domain,
|
||||
target_id=str(record.id),
|
||||
risk_level="high" if domain in HIGH_RISK_DOMAINS else "low",
|
||||
request_payload={"data": data, "approval_ticket_id": approval_ticket_id},
|
||||
response_payload=result,
|
||||
)
|
||||
)
|
||||
return result
|
||||
1
app/modules/feishu/__init__.py
Normal file
1
app/modules/feishu/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Feishu integration."""
|
||||
91
app/modules/feishu/client.py
Normal file
91
app/modules/feishu/client.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
class FeishuClient:
|
||||
"""Small Feishu Open Platform client for tenant token and message APIs."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.settings = get_settings()
|
||||
self._tenant_access_token: str | None = None
|
||||
self._token_expires_at: float = 0
|
||||
|
||||
def _is_configured(self) -> bool:
|
||||
return bool(self.settings.feishu_app_id and self.settings.feishu_app_secret)
|
||||
|
||||
def _get_tenant_access_token(self) -> str:
|
||||
if not self._is_configured():
|
||||
raise HTTPException(status_code=503, detail="Feishu app credentials are not configured")
|
||||
if self._tenant_access_token and time.time() < self._token_expires_at:
|
||||
return self._tenant_access_token
|
||||
|
||||
url = f"{self.settings.feishu_base_url}/auth/v3/tenant_access_token/internal"
|
||||
payload = {
|
||||
"app_id": self.settings.feishu_app_id,
|
||||
"app_secret": self.settings.feishu_app_secret,
|
||||
}
|
||||
with httpx.Client(timeout=20) as client:
|
||||
response = client.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise HTTPException(status_code=502, detail={"feishu_error": data})
|
||||
self._tenant_access_token = data["tenant_access_token"]
|
||||
self._token_expires_at = time.time() + int(data.get("expire", 7200)) - 300
|
||||
return self._tenant_access_token
|
||||
|
||||
def send_message(
|
||||
self,
|
||||
receive_id: str,
|
||||
receive_id_type: str,
|
||||
msg_type: str,
|
||||
content: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
token = self._get_tenant_access_token()
|
||||
url = f"{self.settings.feishu_base_url}/im/v1/messages"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
params = {"receive_id_type": receive_id_type}
|
||||
payload = {
|
||||
"receive_id": receive_id,
|
||||
"msg_type": msg_type,
|
||||
"content": json.dumps(content, ensure_ascii=False),
|
||||
}
|
||||
with httpx.Client(timeout=20) as client:
|
||||
response = client.post(url, headers=headers, params=params, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data
|
||||
|
||||
def send_text(
|
||||
self,
|
||||
text: str,
|
||||
receive_id: str | None = None,
|
||||
receive_id_type: str = "chat_id",
|
||||
) -> dict:
|
||||
chat_id = receive_id or self.settings.feishu_default_chat_id
|
||||
if not chat_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="receive_id or FEISHU_DEFAULT_CHAT_ID is required",
|
||||
)
|
||||
return self.send_message(chat_id, receive_id_type, "text", {"text": text})
|
||||
|
||||
def send_card(
|
||||
self,
|
||||
card: dict[str, Any],
|
||||
receive_id: str | None = None,
|
||||
receive_id_type: str = "chat_id",
|
||||
) -> dict:
|
||||
chat_id = receive_id or self.settings.feishu_default_chat_id
|
||||
if not chat_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="receive_id or FEISHU_DEFAULT_CHAT_ID is required",
|
||||
)
|
||||
return self.send_message(chat_id, receive_id_type, "interactive", card)
|
||||
176
app/modules/feishu/commands.py
Normal file
176
app/modules/feishu/commands.py
Normal file
@@ -0,0 +1,176 @@
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.modules.ai_agent.service import AIService
|
||||
from app.modules.feishu.service import FeishuService
|
||||
from app.modules.reports.service import ReportService
|
||||
from app.modules.risk.service import RiskService
|
||||
|
||||
|
||||
def _parse_content_text(content: Any) -> str:
|
||||
"""Extract plain command text from a Feishu message content payload."""
|
||||
|
||||
if isinstance(content, dict):
|
||||
return str(content.get("text") or content.get("content") or "")
|
||||
if not isinstance(content, str):
|
||||
return ""
|
||||
try:
|
||||
data = json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
return content
|
||||
if isinstance(data, dict):
|
||||
return str(data.get("text") or data.get("content") or "")
|
||||
return content
|
||||
|
||||
|
||||
def _clean_command_text(text: str) -> str:
|
||||
"""Remove mentions and invisible characters from Feishu command text."""
|
||||
|
||||
text = re.sub(r"@\S+", "", text or "")
|
||||
text = text.replace("\u200b", "")
|
||||
return text.strip()
|
||||
|
||||
|
||||
class FeishuCommandService:
|
||||
"""Route Feishu text commands to reports, risk summaries, or AI replies."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.feishu = FeishuService(db)
|
||||
|
||||
def extract_event_command(self, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
event = payload.get("event") or {}
|
||||
message = event.get("message") or {}
|
||||
if not message:
|
||||
return None
|
||||
text = _clean_command_text(_parse_content_text(message.get("content")))
|
||||
if not text:
|
||||
return None
|
||||
sender = event.get("sender") or {}
|
||||
sender_id = sender.get("sender_id") or {}
|
||||
actor = sender_id.get("open_id") or sender_id.get("user_id") or "feishu"
|
||||
return {
|
||||
"text": text,
|
||||
"chat_id": message.get("chat_id"),
|
||||
"actor": actor,
|
||||
}
|
||||
|
||||
def handle_text(
|
||||
self,
|
||||
text: str,
|
||||
chat_id: str | None = None,
|
||||
actor: str = "feishu",
|
||||
auto_reply: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
command_text = _clean_command_text(text)
|
||||
lowered = command_text.lower()
|
||||
provider_response: dict[str, Any] | None = None
|
||||
|
||||
if any(keyword in command_text for keyword in ["日报", "晨报", "经营日报", "经营晨报"]):
|
||||
report = ReportService(self.db).daily_brief()
|
||||
result = {
|
||||
"command": "daily_brief",
|
||||
"reply_type": "card",
|
||||
"title": report["title"],
|
||||
"content": report["content"],
|
||||
"lines": report["lines"],
|
||||
}
|
||||
if auto_reply:
|
||||
provider_response = self._send_card_if_configured(
|
||||
chat_id,
|
||||
report["title"],
|
||||
report["lines"],
|
||||
actor,
|
||||
)
|
||||
result["provider_response"] = provider_response
|
||||
return result
|
||||
|
||||
if any(keyword in command_text for keyword in ["周报", "项目周报"]):
|
||||
report = ReportService(self.db).project_weekly()
|
||||
result = {
|
||||
"command": "project_weekly",
|
||||
"reply_type": "card",
|
||||
"title": report["title"],
|
||||
"content": report["content"],
|
||||
"lines": report["lines"],
|
||||
}
|
||||
if auto_reply:
|
||||
provider_response = self._send_card_if_configured(
|
||||
chat_id,
|
||||
report["title"],
|
||||
report["lines"],
|
||||
actor,
|
||||
)
|
||||
result["provider_response"] = provider_response
|
||||
return result
|
||||
|
||||
if any(keyword in command_text for keyword in ["风险", "预警", "risk"]):
|
||||
summary = RiskService(self.db).summary()
|
||||
lines = [
|
||||
f"- 综合风险等级:{summary['risk_level']}",
|
||||
f"- 风险分:{summary['risk_score']}",
|
||||
f"- 逾期任务:{len(summary['overdue_tasks'])}",
|
||||
f"- 延期项目:{len(summary['delayed_projects'])}",
|
||||
f"- 超预算项目:{len(summary['over_budget_projects'])}",
|
||||
f"- 资金风险账户:{len(summary['fund_risks'])}",
|
||||
]
|
||||
result = {
|
||||
"command": "risk_summary",
|
||||
"reply_type": "card",
|
||||
"title": "风险预警",
|
||||
"content": "\n".join(lines),
|
||||
"lines": lines,
|
||||
}
|
||||
if auto_reply:
|
||||
provider_response = self._send_card_if_configured(chat_id, "风险预警", lines, actor)
|
||||
result["provider_response"] = provider_response
|
||||
return result
|
||||
|
||||
prompt = command_text
|
||||
for prefix in ["问 ", "ai ", "AI ", "/ask "]:
|
||||
if command_text.startswith(prefix):
|
||||
prompt = command_text[len(prefix) :].strip()
|
||||
break
|
||||
if not prompt:
|
||||
prompt = "请说明你能做什么。"
|
||||
ai_result = AIService(self.db).ask(prompt, context={}, actor=actor, source="feishu")
|
||||
content = ai_result["answer"]
|
||||
is_explicit_ai = lowered.startswith(("ai ", "/ask")) or command_text.startswith("问 ")
|
||||
result = {
|
||||
"command": "ai_ask" if is_explicit_ai else "fallback_ai",
|
||||
"reply_type": "text",
|
||||
"title": "AI 回复",
|
||||
"content": content,
|
||||
}
|
||||
if auto_reply:
|
||||
provider_response = self._send_text_if_configured(chat_id, content, actor)
|
||||
result["provider_response"] = provider_response
|
||||
return result
|
||||
|
||||
def _send_card_if_configured(
|
||||
self,
|
||||
chat_id: str | None,
|
||||
title: str,
|
||||
lines: list[str],
|
||||
actor: str,
|
||||
) -> dict[str, Any] | None:
|
||||
settings = get_settings()
|
||||
if not (settings.feishu_app_id and settings.feishu_app_secret):
|
||||
return None
|
||||
card = FeishuService.build_basic_card(title, lines)
|
||||
return self.feishu.send_card(card, receive_id=chat_id, actor=actor)
|
||||
|
||||
def _send_text_if_configured(
|
||||
self,
|
||||
chat_id: str | None,
|
||||
text: str,
|
||||
actor: str,
|
||||
) -> dict[str, Any] | None:
|
||||
settings = get_settings()
|
||||
if not (settings.feishu_app_id and settings.feishu_app_secret):
|
||||
return None
|
||||
return self.feishu.send_text(text, receive_id=chat_id, actor=actor)
|
||||
83
app/modules/feishu/routes.py
Normal file
83
app/modules/feishu/routes.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import require_api_key
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.feishu.commands import FeishuCommandService
|
||||
from app.modules.feishu.schemas import (
|
||||
FeishuCardMessage,
|
||||
FeishuCommandRequest,
|
||||
FeishuCommandResult,
|
||||
FeishuSendResult,
|
||||
FeishuTextMessage,
|
||||
)
|
||||
from app.modules.feishu.service import FeishuService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/webhook")
|
||||
async def feishu_webhook(request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
"""Handle Feishu webhook challenge and text command events."""
|
||||
|
||||
payload = await request.json()
|
||||
service = FeishuService(db)
|
||||
service.verify_event(payload)
|
||||
if payload.get("challenge"):
|
||||
return {"challenge": payload["challenge"]}
|
||||
service.audit.log(
|
||||
AuditLogCreate(
|
||||
actor="feishu",
|
||||
source="feishu",
|
||||
action="webhook_event",
|
||||
request_payload=payload,
|
||||
response_payload={"accepted": True},
|
||||
)
|
||||
)
|
||||
command = FeishuCommandService(db).extract_event_command(payload)
|
||||
if not command:
|
||||
return {"ok": True, "handled": False}
|
||||
result = FeishuCommandService(db).handle_text(
|
||||
command["text"],
|
||||
chat_id=command["chat_id"],
|
||||
actor=command["actor"],
|
||||
auto_reply=True,
|
||||
)
|
||||
return {"ok": True, "handled": True, "result": result}
|
||||
|
||||
|
||||
@router.post("/send-text", response_model=FeishuSendResult, dependencies=[Depends(require_api_key)])
|
||||
def send_text(payload: FeishuTextMessage, db: Session = Depends(get_db)) -> dict:
|
||||
result = FeishuService(db).send_text(
|
||||
payload.text,
|
||||
receive_id=payload.receive_id,
|
||||
receive_id_type=payload.receive_id_type,
|
||||
)
|
||||
return {"ok": result.get("code") == 0, "provider_response": result}
|
||||
|
||||
|
||||
@router.post("/send-card", response_model=FeishuSendResult, dependencies=[Depends(require_api_key)])
|
||||
def send_card(payload: FeishuCardMessage, db: Session = Depends(get_db)) -> dict:
|
||||
result = FeishuService(db).send_card(
|
||||
payload.card,
|
||||
receive_id=payload.receive_id,
|
||||
receive_id_type=payload.receive_id_type,
|
||||
)
|
||||
return {"ok": result.get("code") == 0, "provider_response": result}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/commands/preview",
|
||||
response_model=FeishuCommandResult,
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def preview_command(payload: FeishuCommandRequest, db: Session = Depends(get_db)) -> dict:
|
||||
"""Preview local Feishu command routing without requiring webhook delivery."""
|
||||
|
||||
return FeishuCommandService(db).handle_text(
|
||||
payload.text,
|
||||
chat_id=payload.chat_id,
|
||||
actor=payload.actor,
|
||||
auto_reply=payload.auto_reply,
|
||||
)
|
||||
47
app/modules/feishu/schemas.py
Normal file
47
app/modules/feishu/schemas.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class FeishuTextMessage(BaseModel):
|
||||
receive_id: str | None = Field(
|
||||
default=None,
|
||||
description="chat_id or open_id depending on type.",
|
||||
)
|
||||
receive_id_type: str = "chat_id"
|
||||
text: str
|
||||
|
||||
|
||||
class FeishuCardMessage(BaseModel):
|
||||
receive_id: str | None = None
|
||||
receive_id_type: str = "chat_id"
|
||||
card: dict[str, Any]
|
||||
|
||||
|
||||
class FeishuWebhookEvent(BaseModel):
|
||||
type: str | None = None
|
||||
challenge: str | None = None
|
||||
token: str | None = None
|
||||
schema_: str | None = Field(default=None, alias="schema")
|
||||
header: dict[str, Any] | None = None
|
||||
event: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class FeishuSendResult(BaseModel):
|
||||
ok: bool
|
||||
provider_response: dict[str, Any]
|
||||
|
||||
|
||||
class FeishuCommandRequest(BaseModel):
|
||||
text: str
|
||||
chat_id: str | None = None
|
||||
actor: str = "api"
|
||||
auto_reply: bool = False
|
||||
|
||||
|
||||
class FeishuCommandResult(BaseModel):
|
||||
command: str
|
||||
reply_type: str
|
||||
title: str
|
||||
content: str
|
||||
provider_response: dict[str, Any] | None = None
|
||||
84
app/modules/feishu/service.py
Normal file
84
app/modules/feishu/service.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.feishu.client import FeishuClient
|
||||
|
||||
|
||||
class FeishuService:
|
||||
"""Send Feishu messages and record audit entries for outbound actions."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.audit = AuditService(db)
|
||||
self.client = FeishuClient()
|
||||
|
||||
def verify_event(self, payload: dict[str, Any]) -> None:
|
||||
settings = get_settings()
|
||||
expected = settings.feishu_verification_token
|
||||
token = payload.get("token")
|
||||
if expected and token and token != expected:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid Feishu token",
|
||||
)
|
||||
|
||||
def send_text(
|
||||
self,
|
||||
text: str,
|
||||
receive_id: str | None = None,
|
||||
receive_id_type: str = "chat_id",
|
||||
actor: str = "system",
|
||||
) -> dict[str, Any]:
|
||||
result = self.client.send_text(text, receive_id, receive_id_type)
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source="feishu",
|
||||
action="send_text",
|
||||
request_payload={
|
||||
"receive_id": receive_id,
|
||||
"receive_id_type": receive_id_type,
|
||||
"text": text,
|
||||
},
|
||||
response_payload=result,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
def send_card(
|
||||
self,
|
||||
card: dict[str, Any],
|
||||
receive_id: str | None = None,
|
||||
receive_id_type: str = "chat_id",
|
||||
actor: str = "system",
|
||||
) -> dict[str, Any]:
|
||||
result = self.client.send_card(card, receive_id, receive_id_type)
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source="feishu",
|
||||
action="send_card",
|
||||
request_payload={
|
||||
"receive_id": receive_id,
|
||||
"receive_id_type": receive_id_type,
|
||||
"card": card,
|
||||
},
|
||||
response_payload=result,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def build_basic_card(title: str, lines: list[str]) -> dict[str, Any]:
|
||||
return {
|
||||
"config": {"wide_screen_mode": True},
|
||||
"header": {"title": {"tag": "plain_text", "content": title}},
|
||||
"elements": [
|
||||
{"tag": "div", "text": {"tag": "lark_md", "content": "\n".join(lines) or "暂无数据"}}
|
||||
],
|
||||
}
|
||||
1
app/modules/legacy_mysql/__init__.py
Normal file
1
app/modules/legacy_mysql/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Existing MySQL integration."""
|
||||
50
app/modules/legacy_mysql/routes.py
Normal file
50
app/modules/legacy_mysql/routes.py
Normal 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,
|
||||
)
|
||||
54
app/modules/legacy_mysql/schemas.py
Normal file
54
app/modules/legacy_mysql/schemas.py
Normal 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]]
|
||||
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
|
||||
1
app/modules/reports/__init__.py
Normal file
1
app/modules/reports/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Report module."""
|
||||
41
app/modules/reports/routes.py
Normal file
41
app/modules/reports/routes.py
Normal file
@@ -0,0 +1,41 @@
|
||||
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.reports.schemas import PushReportRequest, ReportResponse
|
||||
from app.modules.reports.service import ReportService
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||
|
||||
|
||||
@router.get("/daily-brief", response_model=ReportResponse)
|
||||
def daily_brief(db: Session = Depends(get_db)) -> dict:
|
||||
return ReportService(db).daily_brief()
|
||||
|
||||
|
||||
@router.get("/project-weekly", response_model=ReportResponse)
|
||||
def project_weekly(db: Session = Depends(get_db)) -> dict:
|
||||
return ReportService(db).project_weekly()
|
||||
|
||||
|
||||
@router.post("/daily-brief/push")
|
||||
def push_daily_brief(payload: PushReportRequest, db: Session = Depends(get_db)) -> dict:
|
||||
report = ReportService(db).daily_brief()
|
||||
return ReportService(db).push_report(
|
||||
report,
|
||||
payload.receive_id,
|
||||
payload.receive_id_type,
|
||||
payload.actor,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/project-weekly/push")
|
||||
def push_project_weekly(payload: PushReportRequest, db: Session = Depends(get_db)) -> dict:
|
||||
report = ReportService(db).project_weekly()
|
||||
return ReportService(db).push_report(
|
||||
report,
|
||||
payload.receive_id,
|
||||
payload.receive_id_type,
|
||||
payload.actor,
|
||||
)
|
||||
13
app/modules/reports/schemas.py
Normal file
13
app/modules/reports/schemas.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ReportResponse(BaseModel):
|
||||
title: str
|
||||
content: str
|
||||
lines: list[str]
|
||||
|
||||
|
||||
class PushReportRequest(BaseModel):
|
||||
receive_id: str | None = None
|
||||
receive_id_type: str = "chat_id"
|
||||
actor: str = "system"
|
||||
96
app/modules/reports/service.py
Normal file
96
app/modules/reports/service.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.business.models import Expense, FundAccount, Procurement, Project, WorkTask
|
||||
from app.modules.feishu.service import FeishuService
|
||||
from app.modules.risk.service import RiskService
|
||||
|
||||
|
||||
def _money(value: Decimal | int | float | None) -> str:
|
||||
"""Format a numeric value as a two-decimal money string."""
|
||||
|
||||
amount = Decimal(value or 0)
|
||||
return f"{amount:,.2f}"
|
||||
|
||||
|
||||
class ReportService:
|
||||
"""Build operational reports and push them through Feishu."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.risks = RiskService(db)
|
||||
|
||||
def daily_brief(self) -> dict:
|
||||
project_count = int(
|
||||
self.db.execute(select(func.count()).select_from(Project)).scalar() or 0
|
||||
)
|
||||
task_count = int(self.db.execute(select(func.count()).select_from(WorkTask)).scalar() or 0)
|
||||
procurement_pending = int(
|
||||
self.db.execute(
|
||||
select(func.count())
|
||||
.select_from(Procurement)
|
||||
.where(Procurement.approval_status.in_(["草稿", "审批中", "待审批"]))
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
expense_pending = int(
|
||||
self.db.execute(
|
||||
select(func.count())
|
||||
.select_from(Expense)
|
||||
.where(Expense.approval_status.in_(["草稿", "审批中", "待审批"]))
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
fund_total = (
|
||||
self.db.execute(select(func.sum(FundAccount.current_balance))).scalar()
|
||||
or Decimal("0")
|
||||
)
|
||||
risk_summary = self.risks.summary()
|
||||
lines = [
|
||||
f"- 项目总数:{project_count}",
|
||||
f"- 任务总数:{task_count}",
|
||||
f"- 待处理采购:{procurement_pending}",
|
||||
f"- 待处理费用:{expense_pending}",
|
||||
f"- 当前账户总余额:{_money(fund_total)}",
|
||||
f"- 逾期任务:{len(risk_summary['overdue_tasks'])}",
|
||||
f"- 延期项目:{len(risk_summary['delayed_projects'])}",
|
||||
f"- 超预算项目:{len(risk_summary['over_budget_projects'])}",
|
||||
f"- 资金风险账户:{len(risk_summary['fund_risks'])}",
|
||||
f"- 综合风险等级:{risk_summary['risk_level']}",
|
||||
]
|
||||
return {"title": "每日经营晨报", "lines": lines, "content": "\n".join(lines)}
|
||||
|
||||
def project_weekly(self) -> dict:
|
||||
active = int(
|
||||
self.db.execute(
|
||||
select(func.count())
|
||||
.select_from(Project)
|
||||
.where(Project.status.notin_(["验收", "已完成", "复盘", "归档", "关闭"]))
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
delayed = self.risks.delayed_projects()
|
||||
over_budget = self.risks.over_budget_projects()
|
||||
lines = [
|
||||
f"- 活跃项目:{active}",
|
||||
f"- 延期项目:{len(delayed)}",
|
||||
f"- 超预算项目:{len(over_budget)}",
|
||||
"- 需要管理层关注:",
|
||||
]
|
||||
for item in delayed[:10]:
|
||||
lines.append(f" - 延期:{item.get('code')} {item.get('name')},负责人 {item.get('owner')}")
|
||||
for item in over_budget[:10]:
|
||||
lines.append(f" - 超预算:{item.get('code')} {item.get('name')}")
|
||||
return {"title": "项目周报", "lines": lines, "content": "\n".join(lines)}
|
||||
|
||||
def push_report(
|
||||
self,
|
||||
report: dict,
|
||||
receive_id: str | None,
|
||||
receive_id_type: str,
|
||||
actor: str,
|
||||
) -> dict:
|
||||
card = FeishuService.build_basic_card(report["title"], report["lines"])
|
||||
return FeishuService(self.db).send_card(card, receive_id, receive_id_type, actor)
|
||||
1
app/modules/risk/__init__.py
Normal file
1
app/modules/risk/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Risk detection module."""
|
||||
33
app/modules/risk/routes.py
Normal file
33
app/modules/risk/routes.py
Normal file
@@ -0,0 +1,33 @@
|
||||
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.risk.service import RiskService
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
def risk_summary(db: Session = Depends(get_db)) -> dict:
|
||||
return RiskService(db).summary()
|
||||
|
||||
|
||||
@router.get("/overdue-tasks")
|
||||
def overdue_tasks(db: Session = Depends(get_db)) -> dict:
|
||||
return {"items": RiskService(db).overdue_tasks()}
|
||||
|
||||
|
||||
@router.get("/delayed-projects")
|
||||
def delayed_projects(db: Session = Depends(get_db)) -> dict:
|
||||
return {"items": RiskService(db).delayed_projects()}
|
||||
|
||||
|
||||
@router.get("/over-budget-projects")
|
||||
def over_budget_projects(db: Session = Depends(get_db)) -> dict:
|
||||
return {"items": RiskService(db).over_budget_projects()}
|
||||
|
||||
|
||||
@router.get("/funds")
|
||||
def fund_risks(db: Session = Depends(get_db)) -> dict:
|
||||
return {"items": RiskService(db).fund_risks()}
|
||||
69
app/modules/risk/service.py
Normal file
69
app/modules/risk/service.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.business.models import FundAccount, Project, WorkTask
|
||||
from app.modules.business.service import serialize_model
|
||||
|
||||
|
||||
class RiskService:
|
||||
"""Evaluate rule-based business risk signals from internal ledgers."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def overdue_tasks(self) -> list[dict[str, Any]]:
|
||||
stmt = select(WorkTask).where(
|
||||
WorkTask.due_date.is_not(None),
|
||||
WorkTask.due_date < date.today(),
|
||||
WorkTask.status.notin_(["完成", "已完成", "关闭"]),
|
||||
)
|
||||
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
||||
|
||||
def delayed_projects(self) -> list[dict[str, Any]]:
|
||||
stmt = select(Project).where(
|
||||
Project.due_date.is_not(None),
|
||||
Project.due_date < date.today(),
|
||||
Project.status.notin_(["验收", "已完成", "复盘", "归档", "关闭"]),
|
||||
)
|
||||
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
||||
|
||||
def over_budget_projects(self) -> list[dict[str, Any]]:
|
||||
stmt = select(Project).where(
|
||||
Project.budget_amount > 0,
|
||||
Project.actual_amount > Project.budget_amount,
|
||||
)
|
||||
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
||||
|
||||
def fund_risks(self) -> list[dict[str, Any]]:
|
||||
stmt = select(FundAccount).where(FundAccount.current_balance < FundAccount.safety_line)
|
||||
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
||||
|
||||
def summary(self) -> dict[str, Any]:
|
||||
overdue_tasks = self.overdue_tasks()
|
||||
delayed_projects = self.delayed_projects()
|
||||
over_budget_projects = self.over_budget_projects()
|
||||
fund_risks = self.fund_risks()
|
||||
risk_score = (
|
||||
len(overdue_tasks) * 1
|
||||
+ len(delayed_projects) * 3
|
||||
+ len(over_budget_projects) * 4
|
||||
+ len(fund_risks) * 5
|
||||
)
|
||||
if risk_score >= 15:
|
||||
level = "high"
|
||||
elif risk_score >= 5:
|
||||
level = "medium"
|
||||
else:
|
||||
level = "low"
|
||||
return {
|
||||
"risk_level": level,
|
||||
"risk_score": Decimal(risk_score),
|
||||
"overdue_tasks": overdue_tasks,
|
||||
"delayed_projects": delayed_projects,
|
||||
"over_budget_projects": over_budget_projects,
|
||||
"fund_risks": fund_risks,
|
||||
}
|
||||
1
app/tools/__init__.py
Normal file
1
app/tools/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Operational scripts."""
|
||||
37
app/tools/init_db.py
Normal file
37
app/tools/init_db.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from app.core.database import Base, engine
|
||||
from app.modules.approvals.models import ApprovalRequest
|
||||
from app.modules.audit.models import AuditLog
|
||||
from app.modules.business.models import (
|
||||
Expense,
|
||||
FundAccount,
|
||||
PerformanceMetric,
|
||||
Policy,
|
||||
Procurement,
|
||||
Project,
|
||||
Standard,
|
||||
Supplier,
|
||||
WorkTask,
|
||||
)
|
||||
|
||||
_MODELS = [
|
||||
ApprovalRequest,
|
||||
AuditLog,
|
||||
Project,
|
||||
WorkTask,
|
||||
Procurement,
|
||||
Expense,
|
||||
FundAccount,
|
||||
Policy,
|
||||
Standard,
|
||||
PerformanceMetric,
|
||||
Supplier,
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
print("Database schema initialized.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user