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

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

View File

@@ -0,0 +1 @@
"""Feishu integration."""

View 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)

View 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)

View 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,
)

View 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

View 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 "暂无数据"}}
],
}