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

40
.env.example Normal file
View File

@@ -0,0 +1,40 @@
APP_NAME=Company AI Management Platform
APP_ENV=local
DEBUG=true
API_PREFIX=/api/v1
API_KEY=change-me
# Main application database. For production, point this to your service database.
DATABASE_URL=mysql+pymysql://root:password@127.0.0.1:3306/company_ai?charset=utf8mb4
# Existing project management system database. Keep this read-only at first.
LEGACY_DATABASE_URL=mysql+pymysql://readonly_user:readonly_password@127.0.0.1:3306/existing_project_system?charset=utf8mb4
LEGACY_PROJECT_QUERY=SELECT id, name, owner, status, progress, start_date, due_date, budget, actual_cost FROM projects ORDER BY id DESC LIMIT :limit
LEGACY_PROJECT_CODE_PREFIX=LEGACY
REDIS_URL=redis://127.0.0.1:6379/0
# Feishu / Lark Open Platform.
FEISHU_BASE_URL=https://open.feishu.cn/open-apis
FEISHU_APP_ID=
FEISHU_APP_SECRET=
FEISHU_VERIFICATION_TOKEN=
FEISHU_ENCRYPT_KEY=
FEISHU_DEFAULT_CHAT_ID=
# AI provider: openclaw, hermes, direct_llm, noop.
MODEL_PROVIDER=noop
OPENCLAW_BASE_URL=http://127.0.0.1:18789
OPENCLAW_API_KEY=
HERMES_BASE_URL=http://127.0.0.1:8080
HERMES_API_KEY=
DIRECT_LLM_BASE_URL=https://api.openai.com/v1
DIRECT_LLM_API_KEY=
DIRECT_LLM_MODEL=gpt-4.1-mini
SCHEDULER_ENABLED=false
DAILY_BRIEF_CRON_HOUR=9
DAILY_BRIEF_CRON_MINUTE=0
WEEKLY_PROJECT_REPORT_DAY_OF_WEEK=mon
WEEKLY_PROJECT_REPORT_CRON_HOUR=9
WEEKLY_PROJECT_REPORT_CRON_MINUTE=30

41
AGENTS.md Normal file
View File

@@ -0,0 +1,41 @@
# Repository Guidelines
## Project Structure & Module Organization
This is a FastAPI modular monolith for company AI management workflows. Source lives in `app/`. Shared infrastructure is under `app/core/`; `app/api/router.py` wires modules under `/api/v1`. Business capabilities live in `app/modules/`: `business`, `legacy_mysql`, `feishu`, `ai_agent`, `reports`, `risk`, `approvals`, and `audit`. Database tools are in `app/tools/`. Tests live in `tests/`, scripts in `scripts/`, and notes in `docs/`.
## Modular Design & Technology Direction
All new work must start with modular design: define boundaries, services, schemas, and adapters before coding. Prefer modern, maintained patterns and tech aligned with FastAPI, SQLAlchemy 2, Pydantic Settings, and async-ready integrations. Use service layer, adapter, dependency injection, and ports/adapters for external systems. Add dependencies only with clear benefit.
## Build, Test, and Development Commands
Common commands:
```powershell
conda env create -f environment.yml
conda env update -f environment.yml --prune
conda run -n company-ai-platform python -m app.tools.init_db
conda run -n company-ai-platform uvicorn app.main:app --reload --host 0.0.0.0 --port 8010
conda run -n company-ai-platform python -m compileall app tests scripts
conda run -n company-ai-platform python scripts\verify_smoke.py
conda run -n company-ai-platform pytest -q
```
## Coding Style & Naming Conventions
Use Python 3.11, 4-space indentation, type hints, and concise service classes. Follow `models.py`, `schemas.py`, `service.py`, and `routes.py`. Keep routes thin and put business logic in services. Use snake_case for functions, variables, filenames; use PascalCase for SQLAlchemy models and Pydantic schemas. Ruff uses line length `100`.
Follow the Google Python Style Guide: group imports as standard library, third-party, local; write docstrings for non-trivial public APIs; prefer explicit exceptions and early returns; and keep functions focused. Use type annotations instead of type comments.
## Testing Guidelines
Tests use `pytest` and FastAPI `TestClient`. Name files `test_*.py` and functions `test_*`. Prefer temporary SQLite databases, as in `tests/test_smoke.py`, so tests do not require MySQL, Feishu, or external AI providers. Cover approval gates, audit-sensitive flows, and API responses for high-risk modules.
## Commit & Pull Request Guidelines
No Git history is available. Use short, imperative commits such as `Add approval audit test`. Pull requests should describe the change, list verification, mention config or migration impacts, and link related issues. Include screenshots only for API docs or visible UI changes.
## Security & Configuration Tips
Do not commit real `.env` files or secrets. Start from `.env.example`. Keep `LEGACY_DATABASE_URL` read-only, set `API_KEY` outside local-only testing, and configure Feishu verification tokens before exposing webhooks. High-risk `fund-accounts` and `performance-metrics` updates must require approved tickets.

25
Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY environment.yml /app/environment.yml
RUN pip install --no-cache-dir \
fastapi==0.115.6 \
"uvicorn[standard]==0.34.0" \
sqlalchemy==2.0.36 \
pymysql==1.1.1 \
pydantic-settings==2.7.1 \
python-dotenv==1.0.1 \
httpx==0.28.1 \
apscheduler==3.10.4 \
redis==5.2.1 \
celery==5.4.0 \
cryptography==44.0.0 \
pandas==2.2.3
COPY app /app/app
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8010"]

311
README.md Normal file
View File

@@ -0,0 +1,311 @@
# Company AI Management Platform
这是一个公司全生命周期 AI 管理系统的后端工程,定位是:
```text
现有项目管理系统 / MySQL
+
FastAPI AI 集成层
+
飞书推送与审批入口
+
OpenClaw / Hermes / 模型适配接口
```
第一版已经实现:
- FastAPI 后端工程
- Conda 独立环境配置
- MySQL 主库连接
- 现有项目管理系统 MySQL 只读接入
- 业务台账 CRUD项目、任务、采购、费用、资金、制度、规范、绩效、供应商
- 飞书文本和卡片推送接口
- 飞书 Webhook 入口
- 飞书消息命令路由日报、周报、风险、AI 问答
- AI 适配器OpenClaw、Hermes、OpenAI-compatible、noop
- 经营晨报、项目周报
- 风险检测:逾期任务、延期项目、超预算项目、资金低于安全线
- 审批单:高风险资金/绩效更新必须通过审批 ticket
- 现有 MySQL 项目数据同步到内部台账,支持 dry-run 和字段映射
- AI 操作审计日志
- Docker Compose 本地运行模板
## 1. 技术栈
```text
Python 3.11
FastAPI
SQLAlchemy 2
MySQL / PyMySQL
Redis
APScheduler / Celery 预留
httpx
Pydantic Settings
```
## 2. Conda 环境
重新创建专用环境:
```powershell
cd C:\Users\20143\Documents\Codex\2026-06-21\new-chat\outputs\company-ai-platform
conda env remove -n company-ai-platform
conda env create -f environment.yml
conda activate company-ai-platform
```
更新已有环境:
```powershell
conda env update -f environment.yml --prune
```
也可以直接运行:
```powershell
.\scripts\recreate_conda_env.ps1
```
## 3. 配置
复制配置文件:
```powershell
Copy-Item .env.example .env
```
至少配置:
```text
DATABASE_URL=你的新业务库
LEGACY_DATABASE_URL=现有项目管理系统的只读 MySQL 账号
LEGACY_PROJECT_QUERY=从现有系统读取项目的 SELECT 查询
API_KEY=你的内部 API Key
```
飞书和模型可以后续再配置。未配置模型时,`MODEL_PROVIDER=noop` 会返回可预测占位结果,确保项目先能启动。
## 4. 初始化数据库
```powershell
python -m app.tools.init_db
```
## 5. 启动服务
```powershell
uvicorn app.main:app --reload --host 0.0.0.0 --port 8010
```
访问:
```text
http://127.0.0.1:8010/docs
```
带 API Key 请求时加 header
```text
X-API-Key: 你的 API_KEY
```
## 6. 关键接口
### 系统健康
```text
GET /api/v1/health
```
### 业务台账
```text
GET /api/v1/business/domains
GET /api/v1/business/projects
POST /api/v1/business/projects
PATCH /api/v1/business/projects/{id}
```
支持的 domain
```text
projects
tasks
procurements
expenses
fund-accounts
policies
standards
performance-metrics
suppliers
```
### 现有 MySQL
```text
GET /api/v1/integrations/mysql/health
GET /api/v1/integrations/mysql/tables
GET /api/v1/integrations/mysql/tables/{table_name}
POST /api/v1/integrations/mysql/query
GET /api/v1/integrations/mysql/projects
POST /api/v1/integrations/mysql/projects/sync
```
只允许 `SELECT`,默认第一阶段不写回原系统。
项目同步默认 `dry_run=true`,确认映射无误后再设置为 `false`
### 飞书
```text
POST /api/v1/integrations/feishu/send-text
POST /api/v1/integrations/feishu/send-card
POST /api/v1/integrations/feishu/webhook
POST /api/v1/integrations/feishu/commands/preview
```
飞书命令支持:
```text
日报 / 晨报 / 经营日报
周报 / 项目周报
风险 / 预警
问 xxx / AI xxx / 普通问题
```
### AI
```text
POST /api/v1/ai/ask
POST /api/v1/ai/draft-policy
POST /api/v1/ai/investment-research
```
### 审批
```text
POST /api/v1/approvals
GET /api/v1/approvals
GET /api/v1/approvals/{ticket_id}
POST /api/v1/approvals/{ticket_id}/approve
POST /api/v1/approvals/{ticket_id}/reject
```
`fund-accounts``performance-metrics` 更新需要已批准的 `approval_ticket_id`
### 报表
```text
GET /api/v1/reports/daily-brief
GET /api/v1/reports/project-weekly
POST /api/v1/reports/daily-brief/push
POST /api/v1/reports/project-weekly/push
```
### 风险
```text
GET /api/v1/risks/summary
GET /api/v1/risks/overdue-tasks
GET /api/v1/risks/delayed-projects
GET /api/v1/risks/over-budget-projects
GET /api/v1/risks/funds
```
## 7. 接入现有项目管理系统
第一阶段建议只读接入:
```text
现有项目系统 MySQL -> FastAPI 只读查询 -> 报表/风险 -> 飞书推送
```
不要一开始让 AI 直接改原系统数据库。
配置示例:
```text
LEGACY_DATABASE_URL=mysql+pymysql://readonly_user:password@10.0.0.10:3306/project_system?charset=utf8mb4
LEGACY_PROJECT_QUERY=SELECT id, name, owner, status, progress, start_date, due_date, budget, actual_cost FROM project ORDER BY id DESC LIMIT :limit
```
## 8. 接入 OpenClaw / Hermes / 模型
OpenClaw
```text
MODEL_PROVIDER=openclaw
OPENCLAW_BASE_URL=http://127.0.0.1:18789
OPENCLAW_API_KEY=
```
Hermes
```text
MODEL_PROVIDER=hermes
HERMES_BASE_URL=http://127.0.0.1:8080
HERMES_API_KEY=
```
OpenAI-compatible
```text
MODEL_PROVIDER=direct_llm
DIRECT_LLM_BASE_URL=https://api.openai.com/v1
DIRECT_LLM_API_KEY=你的Key
DIRECT_LLM_MODEL=gpt-4.1-mini
```
## 9. 安全边界
已经内置的边界:
- 现有 MySQL 只允许 SELECT
- 高风险模块更新需要 `approval_ticket_id`
- 飞书和 AI 动作写入审计日志
- API 支持 `X-API-Key`
- AI 不直接审批付款、绩效、投资交易
后续建议补:
- 用户登录
- RBAC 角色权限
- 字段级脱敏
- 飞书审批回调
- 数据库迁移 Alembic
- 对接真实财务/采购系统 API
## 10. 下一步实施顺序
```text
1. 配置现有 MySQL 只读账号
2. 调通 /integrations/mysql/health
3. 调通 /integrations/mysql/projects
4. 初始化新业务库
5. 导入或同步项目数据
6. 配置飞书应用
7. 调通飞书 send-text/send-card
8. 配置 MODEL_PROVIDER
9. 开启日报、周报、风险推送
10. 再逐步做写回和审批闭环
```
## 11. 验证
编译:
```powershell
conda run -n company-ai-platform python -m compileall app tests scripts
```
Smoke 验证:
```powershell
conda run -n company-ai-platform python scripts\verify_smoke.py
```
测试:
```powershell
conda run -n company-ai-platform pytest -q
```

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Company AI management platform."""

1
app/api/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""API package."""

29
app/api/router.py Normal file
View 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
View File

@@ -0,0 +1 @@
"""Core infrastructure."""

60
app/core/config.py Normal file
View 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
View 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
View 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
View 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
View 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()

View File

@@ -0,0 +1 @@
"""AI agent integration."""

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

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

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

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

View File

@@ -0,0 +1 @@
"""Approval workflow module."""

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

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

View 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

View 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}", "*"}

View File

@@ -0,0 +1 @@
"""Audit module."""

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

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

View 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

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

View File

@@ -0,0 +1 @@
"""Business lifecycle module."""

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

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

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

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

View 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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,277 @@
from datetime import date, datetime
from decimal import Decimal
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import inspect, text
from sqlalchemy.engine import Engine, RowMapping
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.database import legacy_engine
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
from app.modules.business.models import Project
from app.modules.business.service import serialize_model
FORBIDDEN_SQL_TOKENS = {
"insert",
"update",
"delete",
"drop",
"alter",
"truncate",
"create",
"replace",
"grant",
"revoke",
}
def _jsonable(value: Any) -> Any:
"""Convert database scalar values into JSON-friendly values."""
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, Decimal):
return float(value)
return value
def _row_to_dict(row: RowMapping) -> dict[str, Any]:
"""Convert a SQLAlchemy row mapping to a serializable dictionary."""
return {key: _jsonable(value) for key, value in row.items()}
class LegacyMySQLService:
"""Read legacy MySQL data and sync projects into the internal ledger."""
def __init__(self, db: Session | None):
self.db = db
@staticmethod
def _ensure_engine() -> Engine:
if legacy_engine is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="LEGACY_DATABASE_URL is not configured",
)
return legacy_engine
@staticmethod
def _ensure_readonly(sql: str) -> None:
stripped = sql.strip().lower()
if not stripped.startswith("select"):
raise HTTPException(status_code=400, detail="Only SELECT statements are allowed")
tokens = {token.strip(" ;,\n\t") for token in stripped.replace("(", " ").split()}
if tokens & FORBIDDEN_SQL_TOKENS:
raise HTTPException(status_code=400, detail="Forbidden SQL token in readonly query")
def health(self) -> dict[str, str]:
engine = self._ensure_engine()
try:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
except SQLAlchemyError as exc:
raise HTTPException(status_code=503, detail=f"MySQL connection failed: {exc}") from exc
return {"status": "ok"}
def list_tables(self) -> list[str]:
engine = self._ensure_engine()
return sorted(inspect(engine).get_table_names())
def describe_table(self, table_name: str) -> list[dict[str, Any]]:
engine = self._ensure_engine()
inspector = inspect(engine)
if table_name not in inspector.get_table_names():
raise HTTPException(status_code=404, detail="Table not found")
columns = []
for column in inspector.get_columns(table_name):
columns.append(
{
"name": column["name"],
"type": str(column["type"]),
"nullable": column.get("nullable", True),
"default": (
str(column.get("default"))
if column.get("default") is not None
else None
),
}
)
return columns
def execute_readonly(
self,
sql: str,
params: dict[str, Any] | None = None,
limit: int = 100,
) -> dict[str, Any]:
self._ensure_readonly(sql)
engine = self._ensure_engine()
params = dict(params or {})
params.setdefault("limit", min(limit, 500))
limited_sql = sql
if " limit " not in sql.lower():
limited_sql = f"{sql.rstrip(';')} LIMIT :limit"
with engine.connect() as conn:
result = conn.execute(text(limited_sql), params)
rows = [_row_to_dict(row) for row in result.mappings().all()]
columns = list(rows[0].keys()) if rows else []
return {"columns": columns, "rows": rows, "row_count": len(rows)}
def fetch_default_projects(self, limit: int = 100) -> dict[str, Any]:
settings = get_settings()
if not settings.legacy_project_query:
raise HTTPException(
status_code=400,
detail="LEGACY_PROJECT_QUERY is not configured. Configure it in .env first.",
)
return self.execute_readonly(settings.legacy_project_query, {"limit": limit}, limit=limit)
@staticmethod
def _value(
row: dict[str, Any],
field_map: dict[str, str],
internal_name: str,
fallback: Any = None,
) -> Any:
source_name = field_map.get(internal_name, internal_name)
if source_name in row:
return row[source_name]
return fallback
def _project_payload(self, row: dict[str, Any], field_map: dict[str, str]) -> dict[str, Any]:
settings = get_settings()
external_id = self._value(row, field_map, "external_id", row.get("id"))
raw_code = self._value(row, field_map, "code", None)
code = str(raw_code) if raw_code else f"{settings.legacy_project_code_prefix}-{external_id}"
return {
"code": code,
"external_id": str(external_id) if external_id is not None else code,
"source_system": "legacy_mysql",
"name": self._value(row, field_map, "name", "未命名项目"),
"owner": self._value(row, field_map, "owner", None),
"status": self._value(row, field_map, "status", "未知"),
"progress_percent": int(
self._value(
row,
field_map,
"progress_percent",
row.get("progress") or 0,
)
or 0
),
"start_date": self._value(row, field_map, "start_date", None),
"due_date": self._value(row, field_map, "due_date", None),
"budget_amount": (
self._value(row, field_map, "budget_amount", row.get("budget") or 0) or 0
),
"actual_amount": (
self._value(row, field_map, "actual_amount", row.get("actual_cost") or 0) or 0
),
"description": self._value(row, field_map, "description", None),
}
def sync_projects(
self,
source_query: str | None = None,
field_map: dict[str, str] | None = None,
limit: int = 100,
dry_run: bool = True,
actor: str = "api",
) -> dict[str, Any]:
if self.db is None:
raise HTTPException(
status_code=503,
detail="Application database session is not available",
)
settings = get_settings()
query = source_query or settings.legacy_project_query
if not query:
raise HTTPException(status_code=400, detail="Project sync query is not configured")
rows = self.execute_readonly(query, {"limit": limit}, limit=limit)["rows"]
field_map = field_map or {}
created = 0
updated = 0
skipped = 0
items: list[dict[str, Any]] = []
for row in rows:
payload = self._project_payload(row, field_map)
if not payload["external_id"] and not payload["code"]:
skipped += 1
items.append(
{
"action": "skipped",
"reason": "missing external_id/code",
"source": row,
}
)
continue
stmt = select(Project).where(
Project.source_system == "legacy_mysql",
Project.external_id == payload["external_id"],
)
record = self.db.execute(stmt).scalar_one_or_none()
if record is None:
record = self.db.execute(
select(Project).where(Project.code == payload["code"])
).scalar_one_or_none()
if record is None:
created += 1
action = "create"
result = payload
if not dry_run:
record = Project(**payload)
self.db.add(record)
self.db.flush()
result = serialize_model(record)
else:
updated += 1
action = "update"
if not dry_run:
for key, value in payload.items():
setattr(record, key, value)
self.db.flush()
result = serialize_model(record)
else:
result = payload
items.append({"action": action, "project": result, "source": row})
if not dry_run:
self.db.commit()
result = {
"dry_run": dry_run,
"created": created,
"updated": updated,
"skipped": skipped,
"items": items,
}
AuditService(self.db).log(
AuditLogCreate(
actor=actor,
source="legacy_mysql",
action="sync_projects",
target_type="projects",
risk_level="medium",
request_payload={
"source_query": source_query or "LEGACY_PROJECT_QUERY",
"field_map": field_map,
"limit": limit,
"dry_run": dry_run,
},
response_payload={
key: result[key] for key in ["dry_run", "created", "updated", "skipped"]
},
)
)
return result

View File

@@ -0,0 +1 @@
"""Report module."""

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

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

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

View File

@@ -0,0 +1 @@
"""Risk detection module."""

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

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

@@ -0,0 +1 @@
"""Operational scripts."""

37
app/tools/init_db.py Normal file
View 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()

40
docker-compose.yml Normal file
View File

@@ -0,0 +1,40 @@
services:
api:
build: .
env_file:
- .env
environment:
DATABASE_URL: mysql+pymysql://company_ai:company_ai_password@mysql:3306/company_ai?charset=utf8mb4
REDIS_URL: redis://redis:6379/0
ports:
- "8010:8010"
depends_on:
- mysql
- redis
mysql:
image: mysql:8.4
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: company_ai
MYSQL_USER: company_ai
MYSQL_PASSWORD: company_ai_password
ports:
- "3307:3306"
volumes:
- mysql_data:/var/lib/mysql
redis:
image: redis:7-alpine
ports:
- "6379:6379"
adminer:
image: adminer:4
ports:
- "8088:8080"
depends_on:
- mysql
volumes:
mysql_data:

73
docs/ai_integration.md Normal file
View File

@@ -0,0 +1,73 @@
# AI 接入说明
## Provider 选择
```text
MODEL_PROVIDER=noop
MODEL_PROVIDER=openclaw
MODEL_PROVIDER=hermes
MODEL_PROVIDER=direct_llm
```
## 统一接口
业务层只调用:
```text
AIService.ask(prompt, context)
```
所以后续切换 OpenClaw、Hermes 或 OpenAI-compatible 模型时,不需要改业务模块。
## OpenClaw
```text
MODEL_PROVIDER=openclaw
OPENCLAW_BASE_URL=http://127.0.0.1:18789
OPENCLAW_API_KEY=
```
适合:
- 飞书/桌面/手机协同
- 工具调用
- 执行网关
## Hermes
```text
MODEL_PROVIDER=hermes
HERMES_BASE_URL=http://127.0.0.1:8080
HERMES_API_KEY=
```
适合:
- 长期记忆
- 技能沉淀
- 自学习
## Direct LLM
```text
MODEL_PROVIDER=direct_llm
DIRECT_LLM_BASE_URL=https://api.openai.com/v1
DIRECT_LLM_API_KEY=
DIRECT_LLM_MODEL=gpt-4.1-mini
```
## 安全原则
AI 可以:
- 查询数据
- 生成报告
- 生成草稿
- 推送提醒
AI 不可以自动:
- 审批付款
- 修改绩效最终分
- 删除业务数据
- 自动投资下单

55
docs/architecture.md Normal file
View File

@@ -0,0 +1,55 @@
# 架构说明
## 总体架构
```text
用户 / 管理层 / 财务 / 项目负责人
|
v
飞书入口:群聊、私聊、卡片、审批、文档
|
v
FastAPI 集成层
|
|-- legacy_mysql读取现有项目管理系统 MySQL
|-- business内部业务台账
|-- reports日报、周报、经营报告
|-- risk延期、超预算、资金风险
|-- feishu飞书推送和 Webhook
|-- ai_agentOpenClaw / Hermes / 模型适配
|-- audit审计日志
|
v
MySQL / Redis / OpenClaw / Hermes / Feishu Open Platform
```
## 为什么先做模块化单体
当前最重要的是把流程跑通,而不是上来拆微服务。
模块化单体的好处:
- 开发和部署简单
- 业务边界清晰
- 后期可以按模块拆服务
- 适合已有 MySQL 系统的外挂式增强
后期可以拆分:
```text
ai-service
feishu-service
report-service
risk-service
finance-service
investment-service
```
## 数据边界
```text
现有项目系统 MySQL事实源第一阶段只读
新业务库 MySQLAI 中台自有数据、审计日志、补充台账
飞书:协同入口和消息入口
AI 记忆:偏好、经验、技能,不保存财务事实
```

102
docs/custom_flows.md Normal file
View File

@@ -0,0 +1,102 @@
# 定制化流程
## 项目跟踪
```text
现有 MySQL 读取项目
-> 风险引擎检查延期/超预算
-> AI 生成项目摘要
-> 飞书项目群推送
-> 负责人确认
-> 必要时进入审批或整改
```
## 采购
```text
采购申请
-> 关联项目和预算
-> AI 检查重复采购、供应商风险、是否三方比价
-> 生成比价报告
-> 飞书审批
-> 下单/验收/付款
-> 审计归档
```
## 费用
```text
费用申请
-> 分类
-> 关联项目/部门/预算
-> 检查发票和凭证
-> 飞书审批
-> 财务付款
-> 月度分析
```
## 账户资金
```text
导入账户余额
-> 汇总应收应付
-> 计算安全线
-> 识别资金缺口
-> 飞书资金日报
```
## 制度和规范
```text
制度草案
-> AI 辅助起草
-> 管理层审批
-> 飞书 Wiki 发布
-> 签收
-> AI 抽取检查项
-> 执行检查
```
## 绩效
```text
指标定义
-> 绑定数据来源
-> 自动计算草稿
-> AI 解释
-> 部门负责人确认
-> 员工申诉
-> 管理层最终确认
```
AI 不能自动最终定绩效。
## 金融投资
```text
研究
-> 模拟交易
-> 风控验证
-> 人工审批
-> 半自动执行
```
第一版只做投资研究接口,不做真实交易。
## 审批闭环
```text
高风险动作
-> 创建审批单
-> 管理者批准或拒绝
-> 系统校验 approval_ticket_id
-> 执行业务更新
-> 写入审计日志
```
当前强制审批的模块:
```text
fund-accounts
performance-metrics
```

View File

@@ -0,0 +1,84 @@
# 飞书接入说明
## 飞书应用权限
建议先申请最小权限:
- 发送消息
- 读取群信息
- 接收消息事件
- 卡片消息
后续再逐步增加:
- 多维表格
- 审批
- 文档/Wiki
- 任务
- 日历
## 配置项
```text
FEISHU_APP_ID=
FEISHU_APP_SECRET=
FEISHU_VERIFICATION_TOKEN=
FEISHU_DEFAULT_CHAT_ID=
```
## 推送文本
```text
POST /api/v1/integrations/feishu/send-text
```
```json
{
"receive_id": "oc_xxx",
"receive_id_type": "chat_id",
"text": "项目日报测试"
}
```
## 推送卡片
```text
POST /api/v1/integrations/feishu/send-card
```
## Webhook
```text
POST /api/v1/integrations/feishu/webhook
```
当前 Webhook 已支持 challenge 验证和事件审计。后续可在这里接入:
- 飞书群聊问答
- 审批回调
- 卡片按钮回调
- 任务状态同步
## 消息命令
Webhook 已支持基础命令路由:
```text
日报 / 晨报 -> 每日经营晨报
周报 / 项目周报 -> 项目周报
风险 / 预警 -> 风险摘要
问 xxx / AI xxx -> AI 问答
```
本地预览:
```text
POST /api/v1/integrations/feishu/commands/preview
```
```json
{
"text": "日报",
"auto_reply": false
}
```

94
docs/mysql_integration.md Normal file
View File

@@ -0,0 +1,94 @@
# 现有 MySQL 接入说明
## 推荐方式
先创建只读账号:
```sql
CREATE USER 'company_ai_ro'@'%' IDENTIFIED BY 'strong_password';
GRANT SELECT ON existing_project_system.* TO 'company_ai_ro'@'%';
FLUSH PRIVILEGES;
```
配置:
```text
LEGACY_DATABASE_URL=mysql+pymysql://company_ai_ro:strong_password@host:3306/existing_project_system?charset=utf8mb4
```
## 表结构探查
```text
GET /api/v1/integrations/mysql/tables
GET /api/v1/integrations/mysql/tables/{table_name}
```
## 只读查询
```json
{
"sql": "SELECT id, name, status FROM projects WHERE status != :status",
"params": {"status": "closed"},
"limit": 100
}
```
系统会拒绝:
```text
INSERT
UPDATE
DELETE
DROP
ALTER
TRUNCATE
CREATE
```
## 项目同步
预览同步:
```text
POST /api/v1/integrations/mysql/projects/sync
```
```json
{
"dry_run": true,
"limit": 100,
"field_map": {
"name": "project_name",
"owner": "manager_name",
"status": "project_status",
"progress_percent": "progress",
"budget_amount": "budget",
"actual_amount": "actual_cost"
}
}
```
确认无误后正式同步:
```json
{
"dry_run": false,
"limit": 100,
"actor": "admin"
}
```
同步只写入本系统内部 `projects` 表,不写回原项目管理系统。
## 写回策略
第一阶段不要写回。
第二阶段如果必须写回,优先走原项目管理系统 API没有 API 时,再做受控写回,并必须包含:
- 权限校验
- 参数校验
- 审批单号
- 事务
- 审计日志
- 回滚方案

20
environment.yml Normal file
View File

@@ -0,0 +1,20 @@
name: company-ai-platform
channels:
- conda-forge
dependencies:
- python=3.11
- pip
- pip:
- fastapi==0.115.6
- uvicorn[standard]==0.34.0
- sqlalchemy==2.0.36
- pymysql==1.1.1
- pydantic-settings==2.7.1
- python-dotenv==1.0.1
- httpx==0.28.1
- apscheduler==3.10.4
- redis==5.2.1
- celery==5.4.0
- cryptography==44.0.0
- pandas==2.2.3
- pytest==8.3.4

13
pyproject.toml Normal file
View File

@@ -0,0 +1,13 @@
[project]
name = "company-ai-platform"
version = "0.1.0"
description = "Company lifecycle management AI integration platform for MySQL, Feishu, OpenClaw, Hermes, and model providers."
requires-python = ">=3.11"
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]

48
read.md Normal file
View File

@@ -0,0 +1,48 @@
# 项目位置与启动说明
## 项目位置
当前项目已经移动到:
```text
D:\Python\AI\company-ai-platform
```
原来的临时输出目录已经不再使用:
```text
C:\Users\20143\Documents\Codex\2026-06-21\new-chat\outputs\company-ai-platform
```
## Conda 环境
Conda 环境名:
```text
company-ai-platform
```
## 启动命令
在 PowerShell 中执行:
```powershell
cd D:\Python\AI\company-ai-platform
conda activate company-ai-platform
uvicorn app.main:app --reload --host 0.0.0.0 --port 8010
```
启动后访问接口文档:
```text
http://127.0.0.1:8010/docs
```
## 常用验证命令
```powershell
conda run -n company-ai-platform python -m compileall app tests scripts
conda run -n company-ai-platform python scripts\verify_smoke.py
conda run -n company-ai-platform pytest -q
```

3
scripts/init_db.ps1 Normal file
View File

@@ -0,0 +1,3 @@
$ErrorActionPreference = "Stop"
python -m app.tools.init_db

View File

@@ -0,0 +1,11 @@
$ErrorActionPreference = "Stop"
$envList = conda env list --json | ConvertFrom-Json
$exists = $envList.envs | Where-Object { $_ -like "*company-ai-platform" }
if ($exists) {
conda env remove -n company-ai-platform -y
}
conda env create -f environment.yml
Write-Host "Conda environment recreated: company-ai-platform"
Write-Host "Run: conda activate company-ai-platform"

3
scripts/run_dev.ps1 Normal file
View File

@@ -0,0 +1,3 @@
$ErrorActionPreference = "Stop"
uvicorn app.main:app --reload --host 0.0.0.0 --port 8010

View File

@@ -0,0 +1,39 @@
### Health
GET http://127.0.0.1:8010/api/v1/health
X-API-Key: change-me
### Create project
POST http://127.0.0.1:8010/api/v1/business/projects
Content-Type: application/json
X-API-Key: change-me
{
"actor": "demo",
"data": {
"code": "P-2026-001",
"name": "公司AI管理系统一期",
"owner": "负责人A",
"status": "执行中",
"budget_amount": 100000,
"actual_amount": 25000,
"progress_percent": 30,
"risk_level": "medium"
}
}
### Daily brief
GET http://127.0.0.1:8010/api/v1/reports/daily-brief
X-API-Key: change-me
### AI ask
POST http://127.0.0.1:8010/api/v1/ai/ask
Content-Type: application/json
X-API-Key: change-me
{
"actor": "demo",
"prompt": "总结当前项目风险。",
"context": {
"project": "P-2026-001"
}
}

74
scripts/verify_smoke.py Normal file
View File

@@ -0,0 +1,74 @@
import os
import sys
import tempfile
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT))
db = tempfile.NamedTemporaryFile(delete=False, suffix=".db")
db.close()
os.environ["DATABASE_URL"] = "sqlite:///" + db.name.replace("\\", "/")
os.environ["API_KEY"] = "test-key"
os.environ["MODEL_PROVIDER"] = "noop"
os.environ["SCHEDULER_ENABLED"] = "false"
from fastapi.testclient import TestClient
from app.core.database import Base, engine
from app.main import app
def request(method: str, url: str, **kwargs):
client = TestClient(app)
headers = kwargs.pop("headers", {})
headers.setdefault("X-API-Key", "test-key")
response = getattr(client, method)(url, headers=headers, **kwargs)
response.raise_for_status()
return response
try:
Base.metadata.create_all(bind=engine)
project = request(
"post",
"/api/v1/business/projects",
json={
"actor": "smoke",
"data": {
"code": "P-VERIFY-001",
"name": "Verify Project",
"owner": "tester",
"status": "执行中",
"budget_amount": 1000,
"actual_amount": 200,
},
},
).json()
print("project:", project["data"]["code"])
report = request("get", "/api/v1/reports/daily-brief").json()
print("report:", report["title"])
command = request(
"post",
"/api/v1/integrations/feishu/commands/preview",
json={"text": "日报", "auto_reply": False},
).json()
print("command:", command["command"])
ai = request(
"post",
"/api/v1/ai/ask",
json={"prompt": "生成项目摘要", "context": {"project": "P-VERIFY-001"}},
).json()
print("ai:", ai["provider"])
print("smoke verification ok")
finally:
engine.dispose()
path = Path(db.name)
if path.exists():
path.unlink()

141
tests/test_smoke.py Normal file
View File

@@ -0,0 +1,141 @@
import os
import tempfile
from pathlib import Path
_db = tempfile.NamedTemporaryFile(delete=False, suffix=".db")
_db.close()
os.environ["DATABASE_URL"] = "sqlite:///" + _db.name.replace("\\", "/")
os.environ["API_KEY"] = "test-key"
os.environ["MODEL_PROVIDER"] = "noop"
os.environ["SCHEDULER_ENABLED"] = "false"
from fastapi.testclient import TestClient
from app.core.database import Base, engine
from app.main import app
Base.metadata.create_all(bind=engine)
client = TestClient(app)
headers = {"X-API-Key": "test-key"}
def teardown_module() -> None:
engine.dispose()
path = Path(_db.name)
if path.exists():
path.unlink()
def test_project_report_and_feishu_command_preview() -> None:
response = client.post(
"/api/v1/business/projects",
headers=headers,
json={
"actor": "pytest",
"data": {
"code": "P-SMOKE-001",
"name": "Smoke Project",
"owner": "tester",
"status": "执行中",
"budget_amount": 1000,
"actual_amount": 200,
},
},
)
assert response.status_code == 200
assert response.json()["data"]["code"] == "P-SMOKE-001"
response = client.get("/api/v1/reports/daily-brief", headers=headers)
assert response.status_code == 200
assert response.json()["title"] == "每日经营晨报"
response = client.post(
"/api/v1/integrations/feishu/commands/preview",
headers=headers,
json={"text": "日报", "auto_reply": False},
)
assert response.status_code == 200
assert response.json()["command"] == "daily_brief"
def test_approval_gate_for_high_risk_update() -> None:
create_response = client.post(
"/api/v1/business/fund-accounts",
headers=headers,
json={
"actor": "pytest",
"data": {
"code": "FUND-SMOKE-001",
"name": "Main Account",
"current_balance": 1000,
"safety_line": 500,
},
},
)
assert create_response.status_code == 200
record_id = create_response.json()["data"]["id"]
blocked_response = client.patch(
f"/api/v1/business/fund-accounts/{record_id}",
headers=headers,
json={"actor": "pytest", "data": {"current_balance": 100}},
)
assert blocked_response.status_code == 409
approval_response = client.post(
"/api/v1/approvals",
headers=headers,
json={
"domain": "fund-accounts",
"record_id": str(record_id),
"action": "update:fund-accounts",
"applicant": "pytest",
"reason": "Smoke test balance adjustment",
"payload": {"current_balance": 100},
},
)
assert approval_response.status_code == 200
ticket_id = approval_response.json()["ticket_id"]
pending_response = client.patch(
f"/api/v1/business/fund-accounts/{record_id}",
headers=headers,
json={
"actor": "pytest",
"approval_ticket_id": ticket_id,
"data": {"current_balance": 100},
},
)
assert pending_response.status_code == 403
approve_response = client.post(
f"/api/v1/approvals/{ticket_id}/approve",
headers=headers,
json={"approver": "manager", "comment": "ok"},
)
assert approve_response.status_code == 200
assert approve_response.json()["status"] == "approved"
update_response = client.patch(
f"/api/v1/business/fund-accounts/{record_id}",
headers=headers,
json={
"actor": "pytest",
"approval_ticket_id": ticket_id,
"data": {"current_balance": 100},
},
)
assert update_response.status_code == 200
assert update_response.json()["data"]["current_balance"] == 100.0
def test_ai_noop_provider() -> None:
response = client.post(
"/api/v1/ai/ask",
headers=headers,
json={"prompt": "生成项目摘要", "actor": "pytest", "context": {"project": "P-SMOKE-001"}},
)
assert response.status_code == 200
assert response.json()["provider"] == "noop"