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