commit 71ca804764af291a86ba044a1c4c80a2249f76b7 Author: JiuContinent Date: Sun Jun 21 21:57:28 2026 +0800 ``` feat: 添加公司AI管理平台基础架构 添加了完整的FastAPI后端项目结构,包括: - 环境配置文件(.env.example)和项目说明文档(README.md、AGENTS.md) - Dockerfile用于容器化部署 - 核心基础设施:配置管理、数据库连接、调度器、安全认证 - 模块化设计:AI代理、审批流程、审计日志、业务台账等功能模块 - 支持多数据库连接(主库和遗留系统只读库) - AI适配器支持OpenClaw、Hermes、OpenAI兼容接口 - 飞书集成、报表生成、风险监控等企业级功能 - 完整的依赖管理和测试指南 ``` diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..760b464 --- /dev/null +++ b/.env.example @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..42ab329 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d2c8619 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..8b874b0 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..ca46291 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""Company AI management platform.""" diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..dff53e5 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ +"""API package.""" diff --git a/app/api/router.py b/app/api/router.py new file mode 100644 index 0000000..8920b5a --- /dev/null +++ b/app/api/router.py @@ -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"]) diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..b6a9e3d --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ +"""Core infrastructure.""" diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..be795cd --- /dev/null +++ b/app/core/config.py @@ -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() diff --git a/app/core/database.py b/app/core/database.py new file mode 100644 index 0000000..e8e78bb --- /dev/null +++ b/app/core/database.py @@ -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() diff --git a/app/core/scheduler.py b/app/core/scheduler.py new file mode 100644 index 0000000..1232711 --- /dev/null +++ b/app/core/scheduler.py @@ -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) diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..377aa0d --- /dev/null +++ b/app/core/security.py @@ -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") diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..e433e30 --- /dev/null +++ b/app/main.py @@ -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() diff --git a/app/modules/ai_agent/__init__.py b/app/modules/ai_agent/__init__.py new file mode 100644 index 0000000..f339d08 --- /dev/null +++ b/app/modules/ai_agent/__init__.py @@ -0,0 +1 @@ +"""AI agent integration.""" diff --git a/app/modules/ai_agent/adapters.py b/app/modules/ai_agent/adapters.py new file mode 100644 index 0000000..997d12c --- /dev/null +++ b/app/modules/ai_agent/adapters.py @@ -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() diff --git a/app/modules/ai_agent/routes.py b/app/modules/ai_agent/routes.py new file mode 100644 index 0000000..809b527 --- /dev/null +++ b/app/modules/ai_agent/routes.py @@ -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, + ) diff --git a/app/modules/ai_agent/schemas.py b/app/modules/ai_agent/schemas.py new file mode 100644 index 0000000..130909b --- /dev/null +++ b/app/modules/ai_agent/schemas.py @@ -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" diff --git a/app/modules/ai_agent/service.py b/app/modules/ai_agent/service.py new file mode 100644 index 0000000..6760e82 --- /dev/null +++ b/app/modules/ai_agent/service.py @@ -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") diff --git a/app/modules/approvals/__init__.py b/app/modules/approvals/__init__.py new file mode 100644 index 0000000..17daab2 --- /dev/null +++ b/app/modules/approvals/__init__.py @@ -0,0 +1 @@ +"""Approval workflow module.""" diff --git a/app/modules/approvals/models.py b/app/modules/approvals/models.py new file mode 100644 index 0000000..740cb39 --- /dev/null +++ b/app/modules/approvals/models.py @@ -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) diff --git a/app/modules/approvals/routes.py b/app/modules/approvals/routes.py new file mode 100644 index 0000000..e4dfcc8 --- /dev/null +++ b/app/modules/approvals/routes.py @@ -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) diff --git a/app/modules/approvals/schemas.py b/app/modules/approvals/schemas.py new file mode 100644 index 0000000..a65c8b9 --- /dev/null +++ b/app/modules/approvals/schemas.py @@ -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 diff --git a/app/modules/approvals/service.py b/app/modules/approvals/service.py new file mode 100644 index 0000000..0c2b4da --- /dev/null +++ b/app/modules/approvals/service.py @@ -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}", "*"} diff --git a/app/modules/audit/__init__.py b/app/modules/audit/__init__.py new file mode 100644 index 0000000..13b9710 --- /dev/null +++ b/app/modules/audit/__init__.py @@ -0,0 +1 @@ +"""Audit module.""" diff --git a/app/modules/audit/models.py b/app/modules/audit/models.py new file mode 100644 index 0000000..4ac08a2 --- /dev/null +++ b/app/modules/audit/models.py @@ -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) diff --git a/app/modules/audit/routes.py b/app/modules/audit/routes.py new file mode 100644 index 0000000..13478cd --- /dev/null +++ b/app/modules/audit/routes.py @@ -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) diff --git a/app/modules/audit/schemas.py b/app/modules/audit/schemas.py new file mode 100644 index 0000000..dcbe697 --- /dev/null +++ b/app/modules/audit/schemas.py @@ -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 diff --git a/app/modules/audit/service.py b/app/modules/audit/service.py new file mode 100644 index 0000000..6fce6e3 --- /dev/null +++ b/app/modules/audit/service.py @@ -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()) diff --git a/app/modules/business/__init__.py b/app/modules/business/__init__.py new file mode 100644 index 0000000..86dd5e7 --- /dev/null +++ b/app/modules/business/__init__.py @@ -0,0 +1 @@ +"""Business lifecycle module.""" diff --git a/app/modules/business/models.py b/app/modules/business/models.py new file mode 100644 index 0000000..f53ac02 --- /dev/null +++ b/app/modules/business/models.py @@ -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) diff --git a/app/modules/business/registry.py b/app/modules/business/registry.py new file mode 100644 index 0000000..5337fae --- /dev/null +++ b/app/modules/business/registry.py @@ -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] diff --git a/app/modules/business/routes.py b/app/modules/business/routes.py new file mode 100644 index 0000000..f1f06d3 --- /dev/null +++ b/app/modules/business/routes.py @@ -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} diff --git a/app/modules/business/schemas.py b/app/modules/business/schemas.py new file mode 100644 index 0000000..ced9cdb --- /dev/null +++ b/app/modules/business/schemas.py @@ -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]] diff --git a/app/modules/business/service.py b/app/modules/business/service.py new file mode 100644 index 0000000..eaec554 --- /dev/null +++ b/app/modules/business/service.py @@ -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 diff --git a/app/modules/feishu/__init__.py b/app/modules/feishu/__init__.py new file mode 100644 index 0000000..9fcafc7 --- /dev/null +++ b/app/modules/feishu/__init__.py @@ -0,0 +1 @@ +"""Feishu integration.""" diff --git a/app/modules/feishu/client.py b/app/modules/feishu/client.py new file mode 100644 index 0000000..6a5fa09 --- /dev/null +++ b/app/modules/feishu/client.py @@ -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) diff --git a/app/modules/feishu/commands.py b/app/modules/feishu/commands.py new file mode 100644 index 0000000..5b9f2f7 --- /dev/null +++ b/app/modules/feishu/commands.py @@ -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) diff --git a/app/modules/feishu/routes.py b/app/modules/feishu/routes.py new file mode 100644 index 0000000..e0ef34a --- /dev/null +++ b/app/modules/feishu/routes.py @@ -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, + ) diff --git a/app/modules/feishu/schemas.py b/app/modules/feishu/schemas.py new file mode 100644 index 0000000..5690def --- /dev/null +++ b/app/modules/feishu/schemas.py @@ -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 diff --git a/app/modules/feishu/service.py b/app/modules/feishu/service.py new file mode 100644 index 0000000..5816370 --- /dev/null +++ b/app/modules/feishu/service.py @@ -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 "暂无数据"}} + ], + } diff --git a/app/modules/legacy_mysql/__init__.py b/app/modules/legacy_mysql/__init__.py new file mode 100644 index 0000000..8a192cd --- /dev/null +++ b/app/modules/legacy_mysql/__init__.py @@ -0,0 +1 @@ +"""Existing MySQL integration.""" diff --git a/app/modules/legacy_mysql/routes.py b/app/modules/legacy_mysql/routes.py new file mode 100644 index 0000000..56da8fc --- /dev/null +++ b/app/modules/legacy_mysql/routes.py @@ -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, + ) diff --git a/app/modules/legacy_mysql/schemas.py b/app/modules/legacy_mysql/schemas.py new file mode 100644 index 0000000..b8cc084 --- /dev/null +++ b/app/modules/legacy_mysql/schemas.py @@ -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]] diff --git a/app/modules/legacy_mysql/service.py b/app/modules/legacy_mysql/service.py new file mode 100644 index 0000000..8f905bd --- /dev/null +++ b/app/modules/legacy_mysql/service.py @@ -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 diff --git a/app/modules/reports/__init__.py b/app/modules/reports/__init__.py new file mode 100644 index 0000000..f067bde --- /dev/null +++ b/app/modules/reports/__init__.py @@ -0,0 +1 @@ +"""Report module.""" diff --git a/app/modules/reports/routes.py b/app/modules/reports/routes.py new file mode 100644 index 0000000..b9a1d48 --- /dev/null +++ b/app/modules/reports/routes.py @@ -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, + ) diff --git a/app/modules/reports/schemas.py b/app/modules/reports/schemas.py new file mode 100644 index 0000000..168481e --- /dev/null +++ b/app/modules/reports/schemas.py @@ -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" diff --git a/app/modules/reports/service.py b/app/modules/reports/service.py new file mode 100644 index 0000000..2593a4d --- /dev/null +++ b/app/modules/reports/service.py @@ -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) diff --git a/app/modules/risk/__init__.py b/app/modules/risk/__init__.py new file mode 100644 index 0000000..e8ad812 --- /dev/null +++ b/app/modules/risk/__init__.py @@ -0,0 +1 @@ +"""Risk detection module.""" diff --git a/app/modules/risk/routes.py b/app/modules/risk/routes.py new file mode 100644 index 0000000..4effb31 --- /dev/null +++ b/app/modules/risk/routes.py @@ -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()} diff --git a/app/modules/risk/service.py b/app/modules/risk/service.py new file mode 100644 index 0000000..51b9979 --- /dev/null +++ b/app/modules/risk/service.py @@ -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, + } diff --git a/app/tools/__init__.py b/app/tools/__init__.py new file mode 100644 index 0000000..7da308d --- /dev/null +++ b/app/tools/__init__.py @@ -0,0 +1 @@ +"""Operational scripts.""" diff --git a/app/tools/init_db.py b/app/tools/init_db.py new file mode 100644 index 0000000..252787c --- /dev/null +++ b/app/tools/init_db.py @@ -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() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..92eade0 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/ai_integration.md b/docs/ai_integration.md new file mode 100644 index 0000000..8b7754e --- /dev/null +++ b/docs/ai_integration.md @@ -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 不可以自动: + +- 审批付款 +- 修改绩效最终分 +- 删除业务数据 +- 自动投资下单 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..33654df --- /dev/null +++ b/docs/architecture.md @@ -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 记忆:偏好、经验、技能,不保存财务事实 +``` diff --git a/docs/custom_flows.md b/docs/custom_flows.md new file mode 100644 index 0000000..ad39789 --- /dev/null +++ b/docs/custom_flows.md @@ -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 +``` diff --git a/docs/feishu_integration.md b/docs/feishu_integration.md new file mode 100644 index 0000000..aa314a9 --- /dev/null +++ b/docs/feishu_integration.md @@ -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 +} +``` diff --git a/docs/mysql_integration.md b/docs/mysql_integration.md new file mode 100644 index 0000000..2b4efdb --- /dev/null +++ b/docs/mysql_integration.md @@ -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 时,再做受控写回,并必须包含: + +- 权限校验 +- 参数校验 +- 审批单号 +- 事务 +- 审计日志 +- 回滚方案 diff --git a/environment.yml b/environment.yml new file mode 100644 index 0000000..8a7ad59 --- /dev/null +++ b/environment.yml @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..815ee71 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/read.md b/read.md new file mode 100644 index 0000000..1644785 --- /dev/null +++ b/read.md @@ -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 +``` + diff --git a/scripts/init_db.ps1 b/scripts/init_db.ps1 new file mode 100644 index 0000000..9da0927 --- /dev/null +++ b/scripts/init_db.ps1 @@ -0,0 +1,3 @@ +$ErrorActionPreference = "Stop" + +python -m app.tools.init_db diff --git a/scripts/recreate_conda_env.ps1 b/scripts/recreate_conda_env.ps1 new file mode 100644 index 0000000..9a905d3 --- /dev/null +++ b/scripts/recreate_conda_env.ps1 @@ -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" diff --git a/scripts/run_dev.ps1 b/scripts/run_dev.ps1 new file mode 100644 index 0000000..965cb81 --- /dev/null +++ b/scripts/run_dev.ps1 @@ -0,0 +1,3 @@ +$ErrorActionPreference = "Stop" + +uvicorn app.main:app --reload --host 0.0.0.0 --port 8010 diff --git a/scripts/sample_requests.http b/scripts/sample_requests.http new file mode 100644 index 0000000..ac8c121 --- /dev/null +++ b/scripts/sample_requests.http @@ -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" + } +} diff --git a/scripts/verify_smoke.py b/scripts/verify_smoke.py new file mode 100644 index 0000000..bd42e41 --- /dev/null +++ b/scripts/verify_smoke.py @@ -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() diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..1d680ce --- /dev/null +++ b/tests/test_smoke.py @@ -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"