feat(backend): Phase 0 项目骨架完成 — 后端/前端/数据库/Docker

- 后端:FastAPI + SQLAlchemy + Alembic,7 张核心表迁移成功
- 前端:Next.js 16 + TailwindCSS 4 + 三端布局(投资人/创始人/Admin)
- 数据库:PostgreSQL 16,7 张核心实体表(tenants/users/companies/monthly_reports/health_scores/risk_events/audit_logs)
- Docker:docker-compose.yml + 前后端 Dockerfile
- 测试:健康检查 4 个测试全部 GREEN
- 文档:README/run.md/AGENTS.md/docs 体系完整
This commit is contained in:
selfrelease
2026-07-18 21:50:15 +08:00
parent f09aa33589
commit 51feae55ba
72 changed files with 8048 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# 环境变量示例
# 复制为 .env 并修改实际值
# ===== 数据库 =====
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/aiportpilot
# ===== Redis =====
REDIS_URL=redis://localhost:6379/0
# ===== JWT =====
JWT_SECRET_KEY=change-me-in-production
JWT_ALGORITHM=HS256
JWT_ACCESS_TOKEN_TTL_MINUTES=120
JWT_REFRESH_TOKEN_TTL_DAYS=7
# ===== AI =====
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=qwen2.5:7b
# ===== 前端 =====
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
# ===== 应用 =====
APP_ENV=development
APP_DEBUG=true
APP_LOG_LEVEL=info
+77
View File
@@ -0,0 +1,77 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
/lib/
/lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual Environment
.venv/
venv/
ENV/
# UV
uv.lock
# Node.js
node_modules/
.pnpm-store/
# Next.js
.next/
out/
build/
next-env.d.ts
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local
.env.*.local
# Docker
docker-compose.override.yml
# Database
*.sql.bak
backups/
# Logs
*.log
logs/
# Test
.coverage
htmlcov/
.pytest_cache/
coverage/
# Misc
*.tmp
*.bak
+48
View File
@@ -0,0 +1,48 @@
# AGENTS.md — Agent 协作规则
## 核心原则
1. **先想再写**:任何代码变更前,确认当前处于方法论七步流程的哪一步
2. **先测再码**TDD 强制,RED-GREEN-REFACTOR
3. **小步快跑**:任务粒度 2-5 分钟,每个 RED-GREEN 循环提交一次
4. **不删旧代码**:修改时保留无关代码,用快照隔离历史
5. **中文沟通**:所有对话、注释、文档使用中文
## 分工
| Agent | 职责 | 触发时机 |
|---|---|---|
| Brainstorm Agent | 需求澄清、方案探索 | 新功能提出时 |
| Plan Agent | 任务拆解、文件路径规划 | 设计批准后 |
| TDD Agent | 写测试 → 写实现 → 重构 | 任务执行时 |
| Review Agent | 代码审查、规格符合度检查 | 每个任务完成后 |
| Debug Agent | 根因分析、复现测试 | Bug 出现时 |
## 禁止事项
- 禁止跳过测试直接写代码
- 禁止删除无关代码
- 禁止 `alert()/confirm()`
- 禁止硬编码 hex 色值
- 禁止多 UI 库混用
- 禁止 `@skip/it.only` 进 PR
- 禁止 mock DB 跑集成测试
## 提交规范
```
type(scope): description
type ∈ feat/fix/refactor/test/docs/chore
scope ∈ frontend/backend/db/docs/infra
```
## 文件路径约定
- 前端页面:`frontend/src/app/(role)/page.tsx`
- 前端组件:`frontend/src/components/`
- 后端路由:`backend/app/routers/`
- 后端模型:`backend/app/models/`
- 后端服务:`backend/app/services/`
- 测试:`backend/tests/` / `frontend/__tests__/`
- 文档:`docs/`
+244
View File
@@ -0,0 +1,244 @@
# 应用构建方法论 v3.0(融合 Superpowers
> 基于 v2.5 + [obra/superpowers](https://github.com/obra/superpowers) 流程纪律,针对 AIPortPilot 项目优化。
> 核心理念:**结构化 > 自由发挥 / 快照 > 覆盖 / 演进 > 重写 / 先想再写 / 先测再码**
---
## 一、七步开发流程(强制执行)
每一步都是**必须执行的工作流**,不是建议。Agent 在任何任务前先检查当前处于哪一步。
### Step 1Brainstorming(头脑风暴)
**触发**:用户提出新功能/新模块需求时
**动作**
- 不急着写代码,先苏格拉底式提问
- 探索替代方案,权衡取舍
- 分段展示设计,每段短到用户能读完就消化
- 产出:`docs/1-prd.md`(产品设计文档)
**完成标志**:用户对 PRD 签字确认
### Step 2Design Document(设计文档)
**触发**PRD 签字后
**动作**
- 技术架构设计(技术栈、数据模型、API 设计、UI/UX 方案)
- 产出:`docs/1-prd.md` 中的技术设计章节
**完成标志**:用户对技术方案签字确认
### Step 3Writing Plans(任务拆解)
**触发**:设计批准后
**动作**
- 将工作拆成 **2-5 分钟** 的小任务
- 每个任务有:精确文件路径、完整代码描述、验证步骤
- 任务之间无循环依赖,可并行标注
- 产出:`docs/2-task.md`(任务清单)
**完成标志**:用户对任务清单签字确认
### Step 4Feature Branch(特性分支)
**触发**:任务清单批准后
**动作**
- `git checkout -b feature/{module-name}`
- 确保干净测试基线
**完成标志**:分支创建成功
### Step 5TDD Implementation(测试驱动实现)
**触发**:分支创建后
**动作**
- **RED**:先写失败测试,运行确认失败
- **GREEN**:写最小代码让测试通过
- **REFACTOR**:重构,保持测试绿色
- **COMMIT**:每个 RED-GREEN 循环提交一次
- **禁止**:先写代码后补测试
**完成标志**:所有任务测试通过
### Step 6Code Review(代码审查)
**触发**:每个任务完成后
**动作**
- 对照计划检查规格符合度
- 检查代码质量(命名、结构、安全、性能)
- 严重问题阻断进度,必须修复后继续
**完成标志**:审查通过
### Step 7Finishing Branch(收尾合并)
**触发**:所有任务完成且审查通过
**动作**
- 运行全量测试
- 提供选项:合并到 master / 创建 PR / 保留分支 / 丢弃
- 合并后清理分支
**完成标志**:代码进入 master
---
## 二、三原则
1. **结构化 > 自由发挥**:先文档后代码,先设计后实现
2. **快照 > 覆盖**:用 `*_snapshot` JSON 保存历史,不删旧数据
3. **演进 > 重写**:版本号 + `is_current` 指针,增量演进
---
## 三、文档骨架(必有)
| 文件 | 内容 | 何时写 |
|---|---|---|
| `README.md` | 项目介绍、技术栈、快速启动 | Step 2 |
| `run.md` | 10 板块运维手册 | Step 2 |
| `AGENTS.md` | Agent 协作规则 | Step 2 |
| `docs/0-req.md` | 需求文档(从方案文档提炼) | Step 1 |
| `docs/1-prd.md` | PRD + 技术设计 | Step 1-2 |
| `docs/2-task.md` | 任务清单(2-5 min 粒度) | Step 3 |
| `docs/daily/` | 日报 | 每日 |
### run.md 十板块
技术栈 / 首次准备 / 基础设施启停 / 应用启停 / DB 命令 / 排错 / 端口表 / env / 部署备份 / FAQ
### 铁律
- 命令可复制粘贴
- 标注 `[Docker]` / `[Native]`
- 危险操作标红
- 版本号写死
- 过时即同步
---
## 四、数据 8 铁律
1. 快照隔离历史(`*_snapshot` JSON
2. 版本号 + `is_current` 唯一指针,不删旧
3. 内部状态机与用户可见状态分离
4. 租户隔离走 `session.tenant_id`,不信任请求体
5. 配置粒度对齐"谁应该决定"(全局/租户/用户)
6. 字段演进:nullable + 默认值;枚举用字符串;时间戳 `*_at`
7. 审计字段:`created_by/updated_by/deleted_by + *_at`;关键操作落 `audit_logs` ≥ 6 月
8. UTC 存储;金额用 decimal/整数;禁止 float
---
## 五、安全与权限
- 密钥进 KMS/Vault**绝不进 Git**,仅 commit `.env.example`
- 后端必须独立校验权限,前端隐藏 ≠ 后端放权
- PII 全链路脱敏(手机/身份证/邮箱),日志中用 `138****1234`
- 注入防御:SQL 参数化 / 命令 `shell=False` / XSS 自动转义 + CSP / CSRF 走 SameSite
- 限流熔断:登录 5次/min/IP,写接口按用户限流,429 带 `Retry-After`
- JWT 短 TTL(≤2h+ refreshCookie 必 HttpOnly+Secure+SameSite
- **本项目特殊**:投后数据高度敏感,优先私有化部署,数据不出域
---
## 六、API 设计
- REST 资源命名(名词复数 + 层级),版本进 URL 不进 query
- HTTP 状态码语义化,禁全 200 塞 error
- 统一响应壳:`{code, message, data, trace_id, timestamp}`
- 分页 `?page&page_size&sort&filter[k]=v`,大数据集用 keyset 游标
- 写接口接受 `Idempotency-Key`,订单/支付**必须**
- `trace_id` 全链路(网关→后端→DB→前端 `X-Trace-Id`
- OpenAPI 自动生成进 Git,废弃接口 `Deprecation` + `Sunset`
---
## 七、AI / 智能集成
- 接口抽象,业务面对自家"智能服务接口"
- 多源容灾 + 兜底降级(规则/缓存/默认),不拖垮主流程
- 强制 Structured Output / JSON Schema,禁编造
- 决策证据化:输出 `score / confidence / evidence / concerns / fallback_used`
- Prompt 进 Git,不只在 DB;可 diff、可回滚
- 单次/用户/租户分级成本预算;缓存优先;慢路径异步化
- 用户输入与系统 prompt **分离 role**,加分隔符 `<<<USER_INPUT>>>`,防注入
- LLM 输出代码绝不直接 exec/eval,必经语法检查 + 沙箱
- PII 进 LLM 前脱敏,响应再回填
- `confidence < 0.6` 显式提示人工核对
---
## 八、客户端 + 多端 UIUX
### 三端角色化
- **投资人端(B 端专业)**:主色 `gray-900`,左 Sidebar`w-52 sticky`+ 内容 `bg-[#f8f9fb]`,信息密集
- **创始人端(C 端温暖)**:主色 `indigo-600`,顶部 sticky Header`h-14 bg-white/95 backdrop-blur`),渐变背景
- **Admin 端(警示)**Header `bg-slate-950` + 主色 `amber-400`,内容 `bg-slate-100`,必带 ADMIN 徽章
### 共享 Token
- Geist 字体 / oklch 色彩 / `--radius: 0.625rem`**禁硬编码 hex**
- shadcn + @base-ui + lucide + sonner + recharts**禁多 UI 库混用**
### Layout 统一
- 容器 `container mx-auto max-w-7xl px-4`Header 全应用 `h-14`
### 交互统一
- `<LoadingSpinner>` / `<EmptyState>` / sonner toast / `<Dialog>` 二次确认
- 状态色三件套:`emerald(success) / amber(warning) / rose(destructive)`
### 响应式
- sm/md/lg/xl/2xl 五断点
- Sidebar < md 折叠为抽屉/下拉,不丢功能入口
### a11y
- 键盘可达 / 对比度 ≥ 4.5:1 / 语义化标签 / aria-label**禁 `<div onClick>`**
### 其他
- i18n:文案进 JSON,用 `Intl.*` 格式化
- 报告页强制 `@media print`
- 流式输出 RAF 批量刷新,禁每 token 触发 React 重渲染
- **禁 `alert()/confirm()`、禁多 UI 库、禁硬编码主色 hex、禁三端共用 Header**
---
## 九、五防一兜底
1. 防异步穿越(AbortController / 引用保存)
2. 防外部单点(超时+重试+熔断+多源)
3. 防资源缺失(启动校验字体/翻译/配置)
4. 防 API 弃用(季度内替换 + 灰度升级)
5. 防数据丢失(事务 / 幂等键 / 持久化队列 / beforeunload
6. 一兜底:最差体验是"功能受限可用",不是白屏
---
## 十、部署运维
- 部署脚本"只清自己",禁 `pm2 delete all`、禁 `redis-cli FLUSHALL`、禁 `rm -rf /`
- 跨子域 cookie 名独立 + `domain=.example.com`
- DB migrate 走 CI/CD 自动应用
- 备份:每天 2 次 + 滚动 7 天 + 双副本(本机 + 异地)+ 每月恢复演练
- 监控 4 金指标:Latency / Traffic / Errors / Saturation
- 灰度 1% → 10% → 50% → 100%,每档观察 ≥ 30 分钟
- 回滚 SOP 必备,回滚比修复快
---
## 十一、测试纪律
- **70/20/10**:单元/集成/E2E
- **TDD 强制**RED-GREEN-REFACTOR,先写失败测试再写代码
- 关键路径必覆盖(登录/支付/导出/权限/AI 主流程)
- 集成测试用 testcontainers 跑真实 DB,禁 mock DB
- 修 bug 必先写复现测试,禁删测试、禁 `@skip/it.only` 进 PR
---
## 十二、协作纪律
- 每步完成后更新 `docs/2-task.md` 状态
- 日报写 `docs/daily/YYYY-MM-DD.md`
- 重大决策记 `docs/decisions/`ADR 格式)
- 代码审查问题按严重度分级:Critical(阻断)/ Major(必须修)/ Minor(建议)
- 提交信息格式:`type(scope): description`type ∈ feat/fix/refactor/test/docs/chore
+63
View File
@@ -0,0 +1,63 @@
# AIPortPilot — AI+ Portfolio Operating System
> AI 投后管理与组合协同平台:投资人和创始人的共同操作系统。
> 对投资收益负责,主动创造增长,持续进化管理知识。
## 技术栈
| 层 | 技术 | 版本 |
|---|---|---|
| 前端 | Next.js + React + TypeScript | 15.x / 19.x / 5.x |
| UI | TailwindCSS + shadcn/ui + lucide + recharts | 4.x |
| 后端 | Python + FastAPI | 3.12 / 0.115.x |
| 数据库 | PostgreSQL + Redis | 16 / 7.x |
| 向量库 | PgVector (PostgreSQL 扩展) | — |
| AI | Ollama (本地推理) + LangChain | — |
| 部署 | Docker Compose → K8s | — |
## 快速启动
```bash
# [Docker] 一键启动全部基础设施
docker compose up -d
# [Native] 后端
cd backend && uv sync && uv run uvicorn app.main:app --reload --port 8000
# [Native] 前端
cd frontend && pnpm install && pnpm dev
```
详见 [run.md](./run.md)。
## 项目结构
```
AIPortPilot/
├── frontend/ # Next.js 前端(投资人端 + 创始人端 + Admin)
├── backend/ # FastAPI 后端
├── docs/ # 文档(需求 / PRD / 任务 / 日报)
├── docker-compose.yml # 开发环境编排
├── BUILD-METHODOLOGY.md # 构建方法论 v3.0
└── AI+投后管理方案v2.0.md # 原始方案文档
```
## MVP 范围(Phase 1
- 企业档案管理
- 月报在线提交 + AI 解析
- 通用健康度评分(财务 + 经营)
- AI+ 专项健康度(商业化 + 成本)
- 投资机构驾驶舱
- 风险预警(指标越界)
- 投后报告自动生成
- 移动端:风险预警推送 + AI Copilot 基础问答
## 文档
- [构建方法论](./BUILD-METHODOLOGY.md)
- [运维手册](./run.md)
- [Agent 协作规则](./AGENTS.md)
- [需求文档](./docs/0-req.md)
- [PRD + 技术设计](./docs/1-prd.md)
- [任务清单](./docs/2-task.md)
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml ./
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple \
fastapi uvicorn[standard] sqlalchemy[asyncio] asyncpg alembic \
pydantic pydantic-settings python-jose[cryptography] passlib[bcrypt] \
python-multipart redis httpx structlog
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+149
View File
@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+77
View File
@@ -0,0 +1,77 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from app.core.config import settings
from app.core.database import Base
import app.models # noqa: F401 — 导入所有模型以便 Alembic 发现
config = context.config
# 使用项目配置的数据库 URL(同步驱动)
config.set_main_option("sqlalchemy.url", settings.database_url.replace("+asyncpg", "+psycopg2"))
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
@@ -0,0 +1,161 @@
"""create core tables: tenants users companies reports health_scores risks audit_logs
Revision ID: 278c8cfa6042
Revises:
Create Date: 2026-07-18 21:49:38.409216
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '278c8cfa6042'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('tenants',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False, comment='租户名称'),
sa.Column('type', sa.String(length=50), nullable=False, comment='租户类型:vc/cvc/gov/holdings'),
sa.Column('config_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True, comment='租户配置'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('companies',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False, comment='企业名称'),
sa.Column('industry', sa.String(length=100), nullable=True, comment='行业'),
sa.Column('stage', sa.String(length=50), nullable=True, comment='融资阶段:seed/a/b/c/ipo'),
sa.Column('logo_url', sa.String(length=500), nullable=True),
sa.Column('description', sa.Text(), nullable=True, comment='业务描述'),
sa.Column('founded_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('total_funding', sa.String(length=50), nullable=True, comment='累计融资额'),
sa.Column('website', sa.String(length=500), nullable=True),
sa.Column('extra_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True, comment='扩展字段'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_companies_tenant_id'), 'companies', ['tenant_id'], unique=False)
op.create_table('users',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('role', sa.String(length=50), nullable=False, comment='角色:gp/partner/post_invest_lead/investor/founder/admin'),
sa.Column('phone', sa.String(length=20), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_tenant_id'), 'users', ['tenant_id'], unique=False)
op.create_table('audit_logs',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('user_id', sa.String(length=36), nullable=True),
sa.Column('action', sa.String(length=100), nullable=False, comment='操作类型:login/view/create/update/delete/export/ai_call'),
sa.Column('resource_type', sa.String(length=50), nullable=True, comment='资源类型'),
sa.Column('resource_id', sa.String(length=36), nullable=True, comment='资源 ID'),
sa.Column('detail_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True, comment='操作详情'),
sa.Column('ip', postgresql.INET(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_audit_logs_tenant_id'), 'audit_logs', ['tenant_id'], unique=False)
op.create_table('health_scores',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('company_id', sa.String(length=36), nullable=False),
sa.Column('total_score', sa.Float(), nullable=False, comment='总分(0-100'),
sa.Column('financial_score', sa.Float(), nullable=True, comment='财务健康度'),
sa.Column('operational_score', sa.Float(), nullable=True, comment='经营健康度'),
sa.Column('ai_commercial_score', sa.Float(), nullable=True, comment='AI+ 商业化健康度'),
sa.Column('ai_cost_score', sa.Float(), nullable=True, comment='AI+ 成本健康度'),
sa.Column('trend', sa.String(length=20), nullable=True, comment='趋势:up/stable/down'),
sa.Column('evidence_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True, comment='评分依据'),
sa.Column('recommendations_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True, comment='建议动作'),
sa.Column('calculated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['company_id'], ['companies.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_health_scores_company_id'), 'health_scores', ['company_id'], unique=False)
op.create_table('monthly_reports',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('company_id', sa.String(length=36), nullable=False),
sa.Column('period_year', sa.Integer(), nullable=False, comment='报告年份'),
sa.Column('period_month', sa.Integer(), nullable=False, comment='报告月份(1-12'),
sa.Column('status', sa.String(length=50), nullable=False, comment='状态:draft/submitted/ai_parsed/reviewed'),
sa.Column('raw_content', sa.Text(), nullable=True, comment='原始内容'),
sa.Column('structured_data', postgresql.JSONB(astext_type=sa.Text()), nullable=True, comment='结构化指标数据'),
sa.Column('ai_summary', sa.Text(), nullable=True, comment='AI 生成的摘要'),
sa.Column('ai_concerns', postgresql.JSONB(astext_type=sa.Text()), nullable=True, comment='AI 关注点列表'),
sa.Column('submitted_by', sa.String(length=36), nullable=True),
sa.Column('submitted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('reviewed_by', sa.String(length=36), nullable=True),
sa.Column('reviewed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['company_id'], ['companies.id'], ),
sa.ForeignKeyConstraint(['reviewed_by'], ['users.id'], ),
sa.ForeignKeyConstraint(['submitted_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_monthly_reports_company_id'), 'monthly_reports', ['company_id'], unique=False)
op.create_table('risk_events',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('company_id', sa.String(length=36), nullable=False),
sa.Column('type', sa.String(length=50), nullable=False, comment='风险类型:financial/operational/org/ai_specific'),
sa.Column('severity', sa.String(length=20), nullable=False, comment='严重程度:low/medium/high/critical'),
sa.Column('status', sa.String(length=20), nullable=False, comment='状态:open/assigned/in_progress/resolved/closed'),
sa.Column('title', sa.String(length=200), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('evidence_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True, comment='证据链'),
sa.Column('suggested_action', sa.Text(), nullable=True, comment='建议动作'),
sa.Column('assigned_to', sa.String(length=36), nullable=True),
sa.Column('due_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('identified_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('closed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['assigned_to'], ['users.id'], ),
sa.ForeignKeyConstraint(['company_id'], ['companies.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_risk_events_company_id'), 'risk_events', ['company_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_risk_events_company_id'), table_name='risk_events')
op.drop_table('risk_events')
op.drop_index(op.f('ix_monthly_reports_company_id'), table_name='monthly_reports')
op.drop_table('monthly_reports')
op.drop_index(op.f('ix_health_scores_company_id'), table_name='health_scores')
op.drop_table('health_scores')
op.drop_index(op.f('ix_audit_logs_tenant_id'), table_name='audit_logs')
op.drop_table('audit_logs')
op.drop_index(op.f('ix_users_tenant_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_companies_tenant_id'), table_name='companies')
op.drop_table('companies')
op.drop_table('tenants')
# ### end Alembic commands ###
+1
View File
@@ -0,0 +1 @@
"""AIPortPilot 后端应用包。"""
+1
View File
@@ -0,0 +1 @@
"""应用配置模块。"""
+36
View File
@@ -0,0 +1,36 @@
"""应用配置。
从环境变量读取配置,支持 .env 文件。
"""
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""应用配置,从环境变量读取。"""
# 应用
app_env: str = "development"
app_debug: bool = True
app_log_level: str = "info"
# 数据库
database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/aiportpilot"
# Redis
redis_url: str = "redis://localhost:6379/0"
# JWT
jwt_secret_key: str = "change-me-in-production"
jwt_algorithm: str = "HS256"
jwt_access_token_ttl_minutes: int = 120
jwt_refresh_token_ttl_days: int = 7
# AI / Ollama
ollama_base_url: str = "http://localhost:11434"
ollama_model: str = "qwen2.5:7b"
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
settings = Settings()
+43
View File
@@ -0,0 +1,43 @@
"""数据库连接管理。
提供 SQLAlchemy 异步 engine 和 session 工厂。
"""
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
engine = create_async_engine(
settings.database_url,
echo=settings.app_debug,
pool_size=10,
max_overflow=20,
)
async_session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
"""SQLAlchemy ORM 基类。"""
pass
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""获取数据库 session 的依赖注入函数。"""
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
+46
View File
@@ -0,0 +1,46 @@
"""安全模块:JWT 生成与验证、密码哈希。"""
from datetime import datetime, timedelta, timezone
from typing import Any
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
"""密码哈希。"""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证密码。"""
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(subject: str, extra_claims: dict[str, Any] | None = None) -> str:
"""生成 JWT access token。"""
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.jwt_access_token_ttl_minutes
)
payload: dict[str, Any] = {"sub": subject, "exp": expire, "type": "access"}
if extra_claims:
payload.update(extra_claims)
return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
def create_refresh_token(subject: str) -> str:
"""生成 JWT refresh token。"""
expire = datetime.now(timezone.utc) + timedelta(
days=settings.jwt_refresh_token_ttl_days
)
payload = {"sub": subject, "exp": expire, "type": "refresh"}
return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
def decode_token(token: str) -> dict[str, Any]:
"""解码 JWT token。"""
return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
+65
View File
@@ -0,0 +1,65 @@
"""FastAPI 应用入口。
注册中间件、路由、异常处理。
"""
import uuid
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.schemas.common import error
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理。"""
# startup
yield
# shutdown
app = FastAPI(
title="AIPortPilot",
description="AI+ Portfolio Operating System — 投后管理与组合协同平台",
version="0.1.0",
lifespan=lifespan,
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def trace_id_middleware(request: Request, call_next):
"""为每个请求注入 trace_id。"""
trace_id = request.headers.get("X-Trace-Id", str(uuid.uuid4()))
request.state.trace_id = trace_id
response = await call_next(request)
response.headers["X-Trace-Id"] = trace_id
return response
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
"""全局异常处理。"""
trace_id = getattr(request.state, "trace_id", str(uuid.uuid4()))
return JSONResponse(
status_code=500,
content=error(code=-1, message="内部服务器错误"),
headers={"X-Trace-Id": trace_id},
)
@app.get("/health")
async def health_check():
"""健康检查端点。"""
return {"status": "ok", "service": "aiportpilot-backend", "version": "0.1.0"}
+22
View File
@@ -0,0 +1,22 @@
"""数据模型模块。
导入所有模型以便 Alembic 自动发现。
"""
from app.models.audit import AuditLog
from app.models.company import Company
from app.models.health_score import HealthScore
from app.models.report import MonthlyReport
from app.models.risk import RiskEvent
from app.models.tenant import Tenant
from app.models.user import User
__all__ = [
"AuditLog",
"Company",
"HealthScore",
"MonthlyReport",
"RiskEvent",
"Tenant",
"User",
]
+31
View File
@@ -0,0 +1,31 @@
"""审计日志模型。
全链路操作记录,保留 ≥ 6 月。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB, INET
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class AuditLog(Base):
"""审计日志。"""
__tablename__ = "audit_logs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False, index=True)
user_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True)
action: Mapped[str] = mapped_column(String(100), nullable=False, comment="操作类型:login/view/create/update/delete/export/ai_call")
resource_type: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="资源类型")
resource_id: Mapped[str | None] = mapped_column(String(36), nullable=True, comment="资源 ID")
detail_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True, comment="操作详情")
ip: Mapped[str | None] = mapped_column(INET, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+39
View File
@@ -0,0 +1,39 @@
"""企业模型。
被投企业档案,包含基本信息、业务描述、投资关系等。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class Company(Base):
"""被投企业。"""
__tablename__ = "companies"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False, comment="企业名称")
industry: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="行业")
stage: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="融资阶段:seed/a/b/c/ipo")
logo_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True, comment="业务描述")
founded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
total_funding: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="累计融资额")
website: Mapped[str | None] = mapped_column(String(500), nullable=True)
extra_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True, comment="扩展字段")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+33
View File
@@ -0,0 +1,33 @@
"""健康度评分模型。
多维度评分:财务、经营、AI+ 商业化、AI+ 成本。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, ForeignKey, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class HealthScore(Base):
"""健康度评分。"""
__tablename__ = "health_scores"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
total_score: Mapped[float] = mapped_column(Float, nullable=False, comment="总分(0-100")
financial_score: Mapped[float | None] = mapped_column(Float, nullable=True, comment="财务健康度")
operational_score: Mapped[float | None] = mapped_column(Float, nullable=True, comment="经营健康度")
ai_commercial_score: Mapped[float | None] = mapped_column(Float, nullable=True, comment="AI+ 商业化健康度")
ai_cost_score: Mapped[float | None] = mapped_column(Float, nullable=True, comment="AI+ 成本健康度")
trend: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="趋势:up/stable/down")
evidence_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True, comment="评分依据")
recommendations_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True, comment="建议动作")
calculated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+44
View File
@@ -0,0 +1,44 @@
"""月报模型。
被投企业按月提交的经营报告,支持 AI 解析。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class MonthlyReport(Base):
"""月报。"""
__tablename__ = "monthly_reports"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
period_year: Mapped[int] = mapped_column(Integer, nullable=False, comment="报告年份")
period_month: Mapped[int] = mapped_column(Integer, nullable=False, comment="报告月份(1-12")
status: Mapped[str] = mapped_column(
String(50), nullable=False, default="draft",
comment="状态:draft/submitted/ai_parsed/reviewed",
)
raw_content: Mapped[str | None] = mapped_column(Text, nullable=True, comment="原始内容")
structured_data: Mapped[dict | None] = mapped_column(JSONB, nullable=True, comment="结构化指标数据")
ai_summary: Mapped[str | None] = mapped_column(Text, nullable=True, comment="AI 生成的摘要")
ai_concerns: Mapped[dict | None] = mapped_column(JSONB, nullable=True, comment="AI 关注点列表")
submitted_by: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True)
submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
reviewed_by: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True)
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+43
View File
@@ -0,0 +1,43 @@
"""风险事件模型。
指标越界自动预警 + 人工处理闭环。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class RiskEvent(Base):
"""风险事件。"""
__tablename__ = "risk_events"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
type: Mapped[str] = mapped_column(String(50), nullable=False, comment="风险类型:financial/operational/org/ai_specific")
severity: Mapped[str] = mapped_column(String(20), nullable=False, default="medium", comment="严重程度:low/medium/high/critical")
status: Mapped[str] = mapped_column(String(20), nullable=False, default="open", comment="状态:open/assigned/in_progress/resolved/closed")
title: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
evidence_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True, comment="证据链")
suggested_action: Mapped[str | None] = mapped_column(Text, nullable=True, comment="建议动作")
assigned_to: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True)
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
identified_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+32
View File
@@ -0,0 +1,32 @@
"""租户模型。
投资机构和被投企业都属于某个租户。多租户隔离的基础。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class Tenant(Base):
"""租户(投资机构)。"""
__tablename__ = "tenants"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name: Mapped[str] = mapped_column(String(200), nullable=False, comment="租户名称")
type: Mapped[str] = mapped_column(String(50), nullable=False, default="vc", comment="租户类型:vc/cvc/gov/holdings")
config_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True, comment="租户配置")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+38
View File
@@ -0,0 +1,38 @@
"""用户模型。
支持多种角色:GP、投资经理、投后负责人、创始人、管理员等。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class User(Base):
"""用户。"""
__tablename__ = "users"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
role: Mapped[str] = mapped_column(
String(50), nullable=False, default="investor",
comment="角色:gp/partner/post_invest_lead/investor/founder/admin",
)
phone: Mapped[str | None] = mapped_column(String(20), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+1
View File
@@ -0,0 +1 @@
"""API 路由模块。"""
+1
View File
@@ -0,0 +1 @@
"""Pydantic schema 模块。"""
+35
View File
@@ -0,0 +1,35 @@
"""统一响应模型。
所有 API 返回统一壳:{code, message, data, trace_id, timestamp}
"""
import uuid
from datetime import datetime, timezone
from typing import Any, Generic, TypeVar
from pydantic import BaseModel, Field
T = TypeVar("T")
class ApiResponse(BaseModel, Generic[T]):
"""统一 API 响应壳。"""
code: int = Field(default=0, description="业务状态码,0 表示成功")
message: str = Field(default="success", description="提示信息")
data: T | None = Field(default=None, description="业务数据")
trace_id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="链路追踪 ID")
timestamp: str = Field(
default_factory=lambda: datetime.now(timezone.utc).isoformat(),
description="响应时间(UTC ISO 8601",
)
def success(data: Any = None, message: str = "success") -> dict[str, Any]:
"""构造成功响应。"""
return ApiResponse(code=0, message=message, data=data).model_dump()
def error(code: int = -1, message: str = "error", data: Any = None) -> dict[str, Any]:
"""构造错误响应。"""
return ApiResponse(code=code, message=message, data=data).model_dump()
+1
View File
@@ -0,0 +1 @@
"""业务服务模块。"""
+50
View File
@@ -0,0 +1,50 @@
[project]
name = "aiportpilot-backend"
version = "0.1.0"
description = "AI+ Portfolio Operating System - 后端"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.34.0",
"sqlalchemy[asyncio]>=2.0.36",
"asyncpg>=0.30.0",
"alembic>=1.14.0",
"pydantic>=2.10.0",
"pydantic-settings>=2.6.0",
"python-jose[cryptography]>=3.3.0",
"passlib[bcrypt]>=1.7.4",
"python-multipart>=0.0.17",
"redis[asyncio]>=5.2.0",
"httpx>=0.28.0",
"pgvector>=0.3.6",
"langchain>=0.3.0",
"langchain-ollama>=0.2.0",
"structlog>=24.4.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3.0",
"pytest-asyncio>=0.24.0",
"pytest-cov>=6.0.0",
"httpx>=0.28.0",
"testcontainers[postgres]>=4.0.0",
"ruff>=0.8.0",
"mypy>=1.13.0",
]
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
addopts = "-v --tb=short"
[tool.mypy]
python_version = "3.12"
strict = true
+1
View File
@@ -0,0 +1 @@
"""测试包。"""
+41
View File
@@ -0,0 +1,41 @@
"""健康检查端点测试。"""
import pytest
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture
def client():
"""创建测试客户端。"""
return TestClient(app)
class TestHealthCheck:
"""健康检查测试。"""
def test_health_returns_ok(self, client: TestClient):
"""RED: 健康检查应返回 200 和 status=ok。"""
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert data["service"] == "aiportpilot-backend"
def test_health_returns_version(self, client: TestClient):
"""RED: 健康检查应返回版本号。"""
response = client.get("/health")
data = response.json()
assert "version" in data
def test_trace_id_in_response_header(self, client: TestClient):
"""RED: 响应头应包含 X-Trace-Id。"""
response = client.get("/health")
assert "X-Trace-Id" in response.headers
def test_trace_id_echoed_from_request(self, client: TestClient):
"""RED: 请求头传入的 trace_id 应在响应头中原样返回。"""
custom_trace_id = "test-trace-12345"
response = client.get("/health", headers={"X-Trace-Id": custom_trace_id})
assert response.headers["X-Trace-Id"] == custom_trace_id
+48
View File
@@ -0,0 +1,48 @@
services:
postgres:
image: docker.1ms.run/library/postgres:16-alpine
container_name: aiportpilot-postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: aiportpilot
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: aiportpilot-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
ollama:
image: ollama/ollama:latest
container_name: aiportpilot-ollama
ports:
- "11434:11434"
volumes:
- ollama_data:/root/.ollama
healthcheck:
test: ["CMD-SHELL", "curl -s http://localhost:11434/api/version || exit 1"]
interval: 10s
timeout: 5s
retries: 3
volumes:
postgres_data:
redis_data:
ollama_data:
+93
View File
@@ -0,0 +1,93 @@
# 需求文档 (0-req)
> 来源:AI+投后管理方案v2.0.md
> 状态:已确认
---
## 一、系统定位
AI+ Portfolio Operating System —— 面向主投 AI、AI+ 和企业服务方向的投资机构,同时服务投资人和创始人的投后管理与组合协同平台。
## 二、核心用户
| 角色 | 端 | 核心诉求 |
|---|---|---|
| GP / 合伙人 | 投资人端(桌面) | Portfolio 全局健康度、退出时机、Alpha 归因 |
| 投后负责人 | 投资人端(桌面) | 风险工作台、干预闭环、协同推进 |
| 投资经理 | 投资人端(桌面+移动) | 负责企业跟进、月报审阅、风险处理 |
| 被投企业创始人 | 创始人端(移动优先) | AI 副驾驶、月报提交、融资规划、投资人沟通 |
| 系统管理员 | Admin 端 | 租户管理、权限配置、审计日志 |
## 三、Phase 1 MVP 需求
### 3.1 企业档案管理
- 投资机构创建/编辑/查看被投企业档案
- 企业基本信息、业务与产品、投资关系
- 支持按行业、阶段、基金筛选
- 企业列表 + 详情页
### 3.2 月报在线提交 + AI 解析
- 创始人端在线提交月报(结构化表单 + 文件上传)
- AI 自动解析月报文件,提取关键指标
- AI 生成投后摘要和关注点清单
- 投资经理追加评论和建议
- 追踪月报提交及时性
### 3.3 健康度评分
**通用健康度(先做 2 个维度)**
- 财务健康度:现金 Runway、月度收入与增长率、毛利率、经营性现金流
- 经营健康度:客户数量与增长、客户留存率、客单价
**AI+ 专项健康度(先做 2 个维度)**
- 商业化健康度:PoC 数量、转化率、付费客户数、MRR 增长、LTV/CAC
- 成本与单位经济:推理成本占收入比、单位推理成本、毛利率(扣除推理成本后)
每个评分附带:评分依据、趋势变化、主要扣分项、建议动作、负责人、复查时间。
### 3.4 投资机构驾驶舱
- Portfolio 健康度分布图
- 本周新增风险与处理状态
- AI 周报摘要
- 重大事项提醒
- 企业健康度趋势对比
### 3.5 风险预警
- 指标越界自动预警(先不做弱信号)
- 风险列表:按严重程度、类型、企业、状态筛选
- 每个风险附带证据链、建议动作、负责人和时限
- 风险处理闭环:识别→分派→处理→复查→关闭
### 3.6 投后报告自动生成
- AI 根据月报数据自动生成投后摘要
- 支持按企业、按基金、按组合生成报告
- 报告可导出 PDF
### 3.7 移动端轻量入口
- 风险预警推送(实时)
- AI Copilot 基础问答
- 待办事项查看与审批
## 四、非功能需求
| 维度 | 要求 |
|---|---|
| 性能 | 页面首屏 ≤ 2sAPI P95 ≤ 500ms |
| 安全 | 多租户隔离、字段级权限、AI 权限继承、全链路审计 |
| 部署 | Docker Compose(MVP)→ 边缘算力机私有化(长期) |
| 数据 | PostgreSQL 存储,Redis 缓存,PgVector 向量检索 |
| 兼容 | Chrome/Safari/Edge 最新版,iOS/Android Safari/Chrome |
| i18n | 中英文,文案进 JSON |
## 五、Phase 2-4 需求概要(后续迭代)
- **Phase 2**:财务数据接入校验、投资协议解析、董事会管理、弱信号采集基础版
- **Phase 3**:协同机会中心、创始人 AI 副驾驶、人才引力场、客户增长引擎、OKR 对齐
- **Phase 4**:投后 Alpha 归因、退出时机预测、组合再平衡、数字孪生、知识图谱
+273
View File
@@ -0,0 +1,273 @@
# PRD + 技术设计 (1-prd)
> 状态:待确认
> 范围:Phase 0 + Phase 1 MVP
---
## 一、产品概要
### 1.1 定位
AI+ Portfolio Operating System —— 投资人和创始人的共同操作系统。Phase 1 聚焦"投后信息与风险管理"。
### 1.2 核心用户旅程
**投资人端**
1. 登录 → 驾驶舱(Portfolio 全局视图)
2. 查看本周新增风险 → 进入风险工作台处理
3. 查看企业详情 → 审阅月报 AI 摘要 → 追加评论
4. 查看健康度趋势 → 识别下降企业
5. 导出投后报告
**创始人端**
1. 登录 → 企业经营概览
2. 提交月报(AI 辅助填充)
3. AI 副驾驶问答("投资人最关心什么")
4. 查看自身健康度评分和建议
---
## 二、技术架构
### 2.1 整体架构
```
┌─────────────────────────────────────────────┐
│ 前端 (Next.js) │
│ (investor) (founder) (admin) │
│ ↓ ↓ ↓ │
│ 统一 API Client + Auth │
└──────────────────┬──────────────────────────┘
│ REST API (JSON)
┌──────────────────┴──────────────────────────┐
│ 后端 (FastAPI) │
│ Routers → Services → Models │
│ AI Layer: Parser / Health / Risk / Report │
└──────┬──────────┬──────────┬────────────────┘
│ │ │
PostgreSQL Redis Ollama (本地AI)
+ PgVector
```
### 2.2 前端架构
**路由结构**
```
frontend/src/app/
├── (investor)/ # 投资人端(B 端专业风格)
│ ├── layout.tsx # 左 Sidebar + 灰色背景
│ ├── page.tsx # 驾驶舱
│ ├── companies/ # 企业列表 + 详情
│ ├── reports/ # 月报管理
│ ├── risks/ # 风险工作台
│ └── settings/
├── (founder)/ # 创始人端(C 端温暖风格)
│ ├── layout.tsx # 顶部 Header + indigo 主色
│ ├── page.tsx # 经营概览
│ ├── reports/submit/ # 月报提交
│ └── copilot/ # AI 副驾驶
├── (admin)/ # Admin 端
│ ├── layout.tsx # slate-950 Header + amber 主色
│ └── page.tsx # 管理后台
├── login/
└── layout.tsx # 根布局(字体 + Provider
```
**设计 Token**
- 字体:Geist Sans + Geist Mono
- 色彩:oklch 体系,CSS 变量定义
- 圆角:`--radius: 0.625rem`
- 投资人端:`--primary: oklch(0.21 0.006 285.885)` (gray-900)
- 创始人端:`--primary: oklch(0.546 0.245 262.881)` (indigo-600)
- Admin 端:`--primary: oklch(0.769 0.188 70.08)` (amber-400)
**共享组件**
- `LoadingSpinner` / `EmptyState` / `HealthScoreBadge`
- `HealthGauge`(仪表盘)/ `HealthRadar`(雷达图)/ `HealthTrend`sparkline
- `RiskCard` / `RiskTimeline`
- `AIChatDrawer`(底部抽屉式 AI 对话)
- `FilterBar`(筛选栏)
- `DataTable`(数据表格)
### 2.3 后端架构
**目录结构**
```
backend/
├── app/
│ ├── main.py # FastAPI app + 中间件 + 路由注册
│ ├── core/
│ │ ├── config.py # 环境变量配置 (pydantic-settings)
│ │ ├── database.py # SQLAlchemy engine + session
│ │ ├── security.py # JWT + 密码哈希
│ │ └── permissions.py # 权限中间件
│ ├── models/ # SQLAlchemy ORM 模型
│ │ ├── user.py
│ │ ├── company.py
│ │ ├── report.py
│ │ ├── health_score.py
│ │ └── risk.py
│ ├── schemas/ # Pydantic 请求/响应 schema
│ ├── routers/ # API 路由
│ │ ├── auth.py
│ │ ├── companies.py
│ │ ├── reports.py
│ │ ├── health.py
│ │ ├── risks.py
│ │ └── dashboard.py
│ └── services/ # 业务逻辑 + AI 服务
│ ├── ai_parser.py # 月报 AI 解析
│ ├── health_calculator.py
│ ├── risk_engine.py
│ └── report_generator.py
├── alembic/ # 数据库迁移
├── tests/ # 测试
├── pyproject.toml
└── Dockerfile
```
**API 设计**
- 版本:`/api/v1/`
- 响应壳:`{code, message, data, trace_id, timestamp}`
- 认证:Bearer JWT
- 分页:`?page=1&page_size=20&sort=-created_at`
### 2.4 数据库设计(Phase 1 核心表)
```sql
-- 租户
tenants (id, name, type, config_json, created_at, ...)
-- 用户
users (id, tenant_id, email, password_hash, role, name, phone, ...)
-- role: gp | partner | post_invest_lead | investor | founder | admin
-- 企业
companies (id, tenant_id, name, industry, stage, logo_url,
description, founded_at, total_funding, website, ...)
-- 月报
monthly_reports (id, company_id, period_year, period_month,
status, raw_content, ai_summary, ai_concerns,
submitted_by, submitted_at, reviewed_by, reviewed_at, ...)
-- 健康度
health_scores (id, company_id, total_score, financial_score,
operational_score, ai_commercial_score, ai_cost_score,
trend, evidence_json, recommendations_json,
calculated_at, ...)
-- 风险
risk_events (id, company_id, type, severity, status,
title, description, evidence_json,
suggested_action, assigned_to, due_at,
identified_at, closed_at, ...)
-- 审计
audit_logs (id, tenant_id, user_id, action, resource_type,
resource_id, detail_json, ip, created_at)
```
### 2.5 AI 服务设计
**月报解析 Agent**
- 输入:月报文件(Excel/PDF/结构化表单)
- 输出:结构化指标 JSON + 摘要文本 + 关注点列表
- 模型:Ollama 本地模型(qwen2.5 或 llama3.2
- 约束:Structured Output / JSON Schema
- 兜底:解析失败时返回原始文本 + 标记需人工审阅
**健康度计算引擎**
- 输入:月报指标 + 历史趋势
- 输出:分项评分 + 总分 + 趋势 + 扣分项 + 建议动作
- 实现:规则引擎(非 LLM),可配置权重
- AI+ 专项:商业化 + 成本维度,同样规则驱动
**风险检测引擎**
- 输入:最新指标 + 阈值规则
- 输出:RiskEvent 列表
- 实现:规则引擎,指标越界自动生成风险
- Phase 2 再加弱信号关联
---
## 三、UI/UX 设计
### 3.1 投资人端
**驾驶舱**
- 顶部:Portfolio 健康度分布热力图(企业 × 维度)
- 左中:本周新增风险卡片列表(红/黄/绿状态条)
- 右中:AI 周报摘要(可折叠)
- 底部:健康度趋势对比(多企业 sparkline)
**企业详情工作台**
- 左侧导航:基本信息 / 财务 / 经营 / 组织 / 风险 / AI+ 专项
- 中部:当前选中维度详情(表格 + 图表)
- 右侧:AI 建议 + 待办 + 风险预警(抽屉式)
- 底部:投后管理时间线
**风险工作台**
- 左侧:筛选栏(严重程度 / 类型 / 企业 / 状态)
- 中部:风险卡片列表(每张卡含证据链折叠面板)
- 右侧:选中风险的处理闭环时间线
### 3.2 创始人端
**经营概览**
- 顶部:大数字卡片(Runway / MRR / 客户数 / 健康度)
- 中部:关键指标趋势图
- 底部:AI 副驾驶入口(浮动按钮)
**月报提交**
- 分步表单:基本信息 → 财务数据 → 经营数据 → AI+ 专项
- AI 辅助:上传文件后自动填充建议值
- 提交前预览 AI 摘要
**AI 副驾驶**
- 底部抽屉式对话窗口
- 上下文感知:自动注入当前企业数据
- 快捷问题按钮:"投资人关心什么" / "融资建议" / "组织诊断"
### 3.3 响应式
- 投资人端:< md 时 Sidebar 折叠为抽屉,驾驶舱卡片单列
- 创始人端:移动端优先,卡片流 + 底部 Tab 导航
- Admin 端:< md 时表格横向滚动
---
## 四、部署架构
### 4.1 开发环境(Docker Compose
```yaml
services:
postgres: # PostgreSQL 16 + PgVector
redis: # Redis 7
ollama: # 本地 AI 推理
backend: # FastAPI + hot reload
frontend: # Next.js + hot reload
```
### 4.2 端口表
| 服务 | 端口 | 说明 |
|---|---|---|
| frontend | 3000 | Next.js dev server |
| backend | 8000 | FastAPI dev server |
| postgres | 5432 | PostgreSQL |
| redis | 6379 | Redis |
| ollama | 11434 | 本地 AI 推理 |
---
## 五、风险与对策
| 风险 | 对策 |
|---|---|
| AI 解析准确率不足 | Structured Output 约束 + 人工审阅兜底 |
| 月报格式差异大 | 先支持结构化表单,再支持文件上传 |
| 私有化部署复杂 | MVP 用 Docker Compose,长期才做边缘算力机 |
| 多租户数据隔离 | 从架构层面 tenant_id 贯穿,中间件强制 |
+147
View File
@@ -0,0 +1,147 @@
# 任务清单 (2-task)
> 粒度:每个任务 2-5 分钟
> 状态标记:[ ] 待办 / [~] 进行中 / [x] 完成 / [!] 阻塞
---
## Phase 0:项目骨架
### T0.1 项目目录结构
- [x] 创建 `frontend/` `backend/` `docs/daily/` `docs/decisions/` 目录
- [x] 创建 `.gitignore`Python + Node + IDE + .env
- [x] 创建 `.env.example`
- 验证:目录结构正确
### T0.2 后端项目初始化
- [x] `backend/pyproject.toml`FastAPI + SQLAlchemy + Alembic + Pydantic + pytest
- [x] `backend/app/__init__.py`
- [x] `backend/app/main.py`FastAPI app 实例 + 健康检查 `/health`
- [x] `backend/app/core/config.py`(环境变量配置)
- [x] `backend/app/core/database.py`SQLAlchemy engine + session
- [x] `backend/tests/__init__.py`
- [x] `backend/tests/test_health.py`4 个测试全部 GREEN
- 验证:`pytest tests/test_health.py -v` → 4 passed
### T0.3 前端项目初始化
- [x] `npx create-next-app@latest frontend`TypeScript + TailwindCSS 4 + App Router
- [x] 安装 lucide-react + recharts + sonner + radix-ui + cva + clsx + tailwind-merge
- [x] 配置 `globals.css`oklch 色彩、系统字体、`--radius: 0.625rem`、打印样式)
- [x] 创建三端布局:`(investor)` 路由组 + `founder/` + `admin/` 目录
- [x] 创建共享组件:`LoadingSpinner` / `EmptyState` / `HealthScoreBadge`
- [x] 创建 `lib/utils.ts`cn 函数)+ `lib/api.ts`API 客户端)
- 验证:`pnpm build` → 8 路由全部构建成功
### T0.4 Docker Compose 开发环境
- [x] `docker-compose.yml`PostgreSQL 16 + Redis 7 + Ollama
- [x] `backend/Dockerfile`
- [x] `frontend/Dockerfile`
- [ ] `docker-compose.dev.yml`(开发覆盖:挂载源码 + hot reload
- 验证:`docker compose up -d` 全部 healthy
### T0.5 数据库迁移基座
- [x] `backend/alembic.ini`
- [x] `backend/alembic/` 初始化
- [x] 首个 migration:创建 7 张核心表(tenants/users/companies/monthly_reports/health_scores/risk_events/audit_logs
- 验证:`alembic upgrade head` 成功,`\dt` 显示 8 张表
---
## Phase 1MVP 核心功能
### T1.1 认证与权限
- [ ] `backend/app/models/user.py`User + Role + Tenant
- [ ] `backend/app/routers/auth.py`(登录 / 注册 / refresh token
- [ ] `backend/app/core/security.py`JWT + 密码哈希)
- [ ] `backend/app/core/permissions.py`(角色级 + 字段级权限中间件)
- [ ] `backend/tests/test_auth.py`RED
- [ ] `frontend/src/app/login/page.tsx`
- [ ] `frontend/src/lib/auth.ts`token 管理 + 自动刷新)
- 验证:登录 → 获取 token → 访问受保护 API
### T1.2 企业档案管理
- [ ] `backend/app/models/company.py`CompanyProfile
- [ ] `backend/app/routers/companies.py`CRUD + 筛选)
- [ ] `backend/app/schemas/company.py`Pydantic schema
- [ ] `backend/tests/test_companies.py`RED
- [ ] `frontend/src/app/(investor)/companies/page.tsx`(列表 + 筛选)
- [ ] `frontend/src/app/(investor)/companies/[id]/page.tsx`(详情工作台骨架)
- [ ] `frontend/src/components/company/CompanyCard.tsx`
- 验证:创建企业 → 列表显示 → 详情页可访问
### T1.3 月报管理 + AI 解析
- [ ] `backend/app/models/report.py`MonthlyReport + ReportItem
- [ ] `backend/app/routers/reports.py`(提交 / 查看 / AI 解析)
- [ ] `backend/app/services/ai_parser.py`(月报 AI 解析服务)
- [ ] `backend/app/schemas/report.py`
- [ ] `backend/tests/test_reports.py`RED
- [ ] `frontend/src/app/(founder)/reports/submit/page.tsx`(创始人提交月报)
- [ ] `frontend/src/app/(investor)/reports/page.tsx`(投资人查看月报)
- 验证:创始人提交月报 → AI 解析 → 投资人查看摘要
### T1.4 健康度评分
- [ ] `backend/app/models/health_score.py`HealthScore + ScoreItem
- [ ] `backend/app/services/health_calculator.py`(财务 + 经营 + AI+ 专项)
- [ ] `backend/app/routers/health.py`(查询 / 重算)
- [ ] `backend/tests/test_health.py`RED
- [ ] `frontend/src/components/health/HealthGauge.tsx`(仪表盘组件)
- [ ] `frontend/src/components/health/HealthRadar.tsx`(雷达图组件)
- [ ] `frontend/src/components/health/HealthTrend.tsx`(趋势 sparkline
- 验证:月报数据 → 自动算分 → 前端展示仪表盘 + 雷达图
### T1.5 投资机构驾驶舱
- [ ] `backend/app/routers/dashboard.py`(聚合数据接口)
- [ ] `backend/tests/test_dashboard.py`RED
- [ ] `frontend/src/app/(investor)/page.tsx`(驾驶舱首页)
- [ ] `frontend/src/components/dashboard/HealthDistribution.tsx`
- [ ] `frontend/src/components/dashboard/RiskSummary.tsx`
- [ ] `frontend/src/components/dashboard/AIWeeklyBrief.tsx`
- 验证:登录后看到驾驶舱,数据来自后端
### T1.6 风险预警
- [ ] `backend/app/models/risk.py`RiskEvent + Evidence
- [ ] `backend/app/services/risk_engine.py`(指标越界检测引擎)
- [ ] `backend/app/routers/risks.py`(列表 / 处理 / 关闭)
- [ ] `backend/tests/test_risks.py`RED
- [ ] `frontend/src/app/(investor)/risks/page.tsx`(风险工作台)
- [ ] `frontend/src/components/risk/RiskCard.tsx` + `RiskTimeline.tsx`
- 验证:指标越界 → 自动生成风险 → 工作台展示 → 处理闭环
### T1.7 投后报告
- [ ] `backend/app/services/report_generator.py`AI 报告生成)
- [ ] `backend/app/routers/reports_export.py`PDF 导出)
- [ ] `backend/tests/test_report_export.py`RED
- [ ] `frontend/src/app/(investor)/reports/view/[id]/page.tsx`
- 验证:选择企业 → 生成报告 → 导出 PDF
### T1.8 移动端适配
- [ ] 投资人端响应式:< md Sidebar 折叠为抽屉
- [ ] 创始人端移动布局:卡片流 + 底部导航
- [ ] AI Copilot 浮动对话窗口(底部抽屉式)
- [ ] 风险预警 toast 推送
- 验证:Chrome DevTools 移动端模拟,三端布局正常
---
## 依赖关系
```
T0.1 → T0.2 → T0.5 → T1.1 → T1.2 → T1.3 → T1.4 → T1.5
T1.6 → T1.7
T0.3 → T1.2(前端依赖)
T0.4 → T0.5DB 依赖 Docker
T1.8 依赖 T1.1-T1.7 全部完成
```
## 里程碑
| 里程碑 | 任务 | 预期 |
|---|---|---|
| M0:骨架可运行 | T0.1-T0.5 | 1 周 |
| M1:认证 + 企业档案 | T1.1-T1.2 | 1 周 |
| M2:月报 + 健康度 | T1.3-T1.4 | 2 周 |
| M3:驾驶舱 + 风险 | T1.5-T1.6 | 1 周 |
| M4:报告 + 移动端 | T1.7-T1.8 | 1 周 |
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+21
View File
@@ -0,0 +1,21 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:22-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+9
View File
@@ -0,0 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
turbopack: {
root: __dirname,
},
};
export default nextConfig;
+35
View File
@@ -0,0 +1,35 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.19",
"@radix-ui/react-dropdown-menu": "^2.1.20",
"@radix-ui/react-slot": "^1.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.25.0",
"next": "16.2.10",
"react": "19.2.4",
"react-dom": "19.2.4",
"recharts": "^3.9.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.10",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+5188
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
ignoredBuiltDependencies:
- sharp
- unrs-resolver
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

@@ -0,0 +1,15 @@
/** 投资人端驾驶舱首页 — 占位页面。 */
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
export default function InvestorHomePage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground"></h1>
<p className="mt-1 text-sm text-muted-foreground">Portfolio </p>
</div>
<LoadingSpinner className="py-20" size={32} />
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
/** 投资人端布局 — B 端专业风格:左 Sidebar + 灰色内容区。 */
import { LayoutDashboard, Building2, FileText, AlertTriangle, Settings } from "lucide-react";
import Link from "next/link";
const navItems = [
{ href: "/dashboard", label: "驾驶舱", icon: LayoutDashboard },
{ href: "/companies", label: "企业档案", icon: Building2 },
{ href: "/reports", label: "月报管理", icon: FileText },
{ href: "/risks", label: "风险工作台", icon: AlertTriangle },
{ href: "/settings", label: "设置", icon: Settings },
];
export default function InvestorLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen">
{/* 左侧 Sidebar */}
<aside className="sticky top-0 hidden h-screen w-52 shrink-0 bg-[var(--investor-sidebar-bg)] text-white md:block">
<div className="flex h-14 items-center px-4 font-bold">AIPortPilot</div>
<nav className="flex flex-col gap-1 px-3 py-2">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-white/70 transition-colors hover:bg-white/10 hover:text-white"
>
<item.icon size={16} aria-hidden="true" />
{item.label}
</Link>
))}
</nav>
</aside>
{/* 内容区 */}
<main className="flex-1 bg-[var(--investor-content-bg)]">
{/* 移动端顶部 Header */}
<header className="sticky top-0 z-10 flex h-14 items-center bg-[var(--investor-sidebar-bg)] px-4 text-white md:hidden">
<span className="font-bold">AIPortPilot</span>
</header>
<div className="container mx-auto max-w-7xl px-4 py-6">{children}</div>
</main>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
/** Admin 端布局 — 警示风格:slate-950 Header + amber 主色。 */
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-slate-100">
<header className="sticky top-0 z-10 flex h-14 items-center bg-[var(--admin-header-bg)] px-4">
<span className="font-bold text-[var(--admin-primary)]">AIPortPilot</span>
<span className="ml-2 inline-flex items-center rounded bg-[var(--admin-primary)]/20 px-1.5 py-0.5 text-xs font-medium text-[var(--admin-primary)]">
ADMIN
</span>
</header>
<main className="container mx-auto max-w-7xl px-4 py-6">{children}</main>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
/** Admin 端首页 — 占位页面。 */
import { EmptyState } from "@/components/shared/EmptyState";
export default function AdminHomePage() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-foreground"></h1>
<EmptyState title="暂无数据" description="租户管理与权限配置功能开发中" />
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+12
View File
@@ -0,0 +1,12 @@
/** 创始人端 AI 副驾驶页面 — 占位。 */
import { EmptyState } from "@/components/shared/EmptyState";
export default function FounderCopilotPage() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-foreground">AI </h1>
<EmptyState title="AI 副驾驶" description="对话功能开发中" />
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
/** 创始人端布局 — C 端温暖风格:顶部 Header + indigo 主色。 */
import { Home, FileText, Sparkle } from "lucide-react";
import Link from "next/link";
const navItems = [
{ href: "/founder", label: "概览", icon: Home },
{ href: "/founder/reports", label: "月报", icon: FileText },
{ href: "/founder/copilot", label: "AI 副驾驶", icon: Sparkle },
];
export default function FounderLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-gradient-to-b from-indigo-50 to-white">
{/* 顶部 Header */}
<header className="sticky top-0 z-10 flex h-14 items-center bg-white/95 px-4 backdrop-blur md:px-6">
<span className="font-bold text-[var(--founder-primary)]">AIPortPilot</span>
<span className="ml-2 text-xs text-muted-foreground"></span>
</header>
{/* 内容区 */}
<main className="container mx-auto max-w-7xl px-4 py-6 pb-20 md:pb-6">{children}</main>
{/* 移动端底部导航 */}
<nav className="fixed bottom-0 left-0 right-0 flex h-14 border-t bg-white md:hidden">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="flex flex-1 flex-col items-center justify-center gap-0.5 text-xs text-muted-foreground"
>
<item.icon size={20} aria-hidden="true" />
{item.label}
</Link>
))}
</nav>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
/** 创始人端经营概览 — 占位页面。 */
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
export default function FounderHomePage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground"></h1>
<p className="mt-1 text-sm text-muted-foreground"></p>
</div>
<LoadingSpinner className="py-20" size={32} />
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
/** 创始人端月报页面 — 占位。 */
import { EmptyState } from "@/components/shared/EmptyState";
export default function FounderReportsPage() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-foreground"></h1>
<EmptyState title="暂无月报" description="月报提交功能开发中" />
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
@import "tailwindcss";
/* ===== 设计 Token ===== */
:root {
/* 圆角 */
--radius: 0.625rem;
/* 通用色彩 - oklch 体系 */
--background: oklch(1 0 0);
--foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.55 0.006 285.885);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.55 0.006 285.885);
/* 状态色三件套 */
--success: oklch(0.7 0.17 162.48);
--warning: oklch(0.77 0.18 70.08);
--destructive: oklch(0.64 0.21 25.77);
/* 投资人端主色 - gray-900 */
--investor-primary: oklch(0.21 0.006 285.885);
--investor-sidebar-bg: oklch(0.21 0.006 285.885);
--investor-content-bg: oklch(0.97 0.001 286.32);
/* 创始人端主色 - indigo-600 */
--founder-primary: oklch(0.546 0.245 262.881);
--founder-header-bg: oklch(1 0 0);
/* Admin 端主色 - amber-400 + slate-950 */
--admin-primary: oklch(0.769 0.188 70.08);
--admin-header-bg: oklch(0.21 0.006 285.885);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-success: var(--success);
--color-warning: var(--warning);
--color-destructive: var(--destructive);
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
--font-mono: "SF Mono", "Cascadia Code", "Fira Code", "JetBrains Mono", monospace;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
}
/* ===== 打印样式 ===== */
@media print {
header,
nav,
.no-print {
display: none !important;
}
.print-card {
box-shadow: none !important;
border: 1px solid #ccc !important;
}
}
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "AIPortPilot — AI+ 投后管理平台",
description: "投资人和创始人的共同操作系统",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN" className="h-full antialiased">
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}
+20
View File
@@ -0,0 +1,20 @@
/** 登录页 — 占位页面。 */
import { Lock } from "lucide-react";
export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-b from-indigo-50 to-white">
<div className="w-full max-w-sm rounded-lg border bg-white p-8 shadow-sm">
<div className="mb-6 flex flex-col items-center">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-[var(--founder-primary)]/10">
<Lock className="text-[var(--founder-primary)]" size={24} aria-hidden="true" />
</div>
<h1 className="text-xl font-bold">AIPortPilot</h1>
<p className="mt-1 text-sm text-muted-foreground">AI+ </p>
</div>
<p className="text-center text-sm text-muted-foreground"></p>
</div>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
/** 根路径重定向到投资人端驾驶舱。 */
export default function Home() {
redirect("/dashboard");
}
@@ -0,0 +1,28 @@
/** 空状态占位组件。 */
import { Inbox } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* 空状态占位。
* @param title - 标题
* @param description - 描述
* @param className - 额外类名
*/
export function EmptyState({
title = "暂无数据",
description,
className,
}: {
title?: string;
description?: string;
className?: string;
}) {
return (
<div className={cn("flex flex-col items-center justify-center py-12 text-center", className)}>
<Inbox className="mb-3 text-muted-foreground" size={40} aria-hidden="true" />
<p className="text-sm font-medium text-foreground">{title}</p>
{description && <p className="mt-1 text-xs text-muted-foreground">{description}</p>}
</div>
);
}
@@ -0,0 +1,29 @@
/** 健康度评分徽章组件。 */
import { cn } from "@/lib/utils";
/**
* 健康度评分徽章。
* @param score - 分数(0-100
* @param label - 标签文字
*/
export function HealthScoreBadge({ score, label }: { score: number; label?: string }) {
const level = score >= 80 ? "good" : score >= 60 ? "fair" : "poor";
const colors = {
good: "bg-[var(--success)]/10 text-[var(--success)] border-[var(--success)]/30",
fair: "bg-[var(--warning)]/10 text-[var(--warning)] border-[var(--warning)]/30",
poor: "bg-[var(--destructive)]/10 text-[var(--destructive)] border-[var(--destructive)]/30",
};
return (
<span
className={cn(
"inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium",
colors[level],
)}
>
{label && <span className="mr-1">{label}</span>}
{score}
</span>
);
}
@@ -0,0 +1,17 @@
/** 加载中旋转图标组件。 */
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* 加载中旋转图标。
* @param className - 额外类名
* @param size - 图标尺寸(像素)
*/
export function LoadingSpinner({ className, size = 24 }: { className?: string; size?: number }) {
return (
<div className={cn("flex items-center justify-center", className)} role="status">
<Loader2 className="animate-spin text-muted-foreground" size={size} aria-label="加载中" />
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
/** API 客户端:统一请求封装。 */
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1";
/**
* 统一 API 响应壳。
*/
export interface ApiResponse<T = unknown> {
code: number;
message: string;
data: T | null;
trace_id: string;
timestamp: string;
}
/**
* 发起 API 请求。
* @param path - API 路径(不含 base URL
* @param options - fetch 选项
* @returns 解析后的响应数据
*/
export async function apiFetch<T = unknown>(
path: string,
options: RequestInit = {},
): Promise<ApiResponse<T>> {
const token = typeof window !== "undefined" ? localStorage.getItem("token") : null;
const response = await fetch(`${API_BASE_URL}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
const json: ApiResponse<T> = await response.json();
if (!response.ok || json.code !== 0) {
throw new Error(json.message || `请求失败: ${response.status}`);
}
return json;
}
+13
View File
@@ -0,0 +1,13 @@
/** 工具函数:合并 TailwindCSS 类名。 */
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* 合并 TailwindCSS 类名,解决冲突。
* @param inputs - 类名列表
* @returns 合并后的类名字符串
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
+179
View File
@@ -0,0 +1,179 @@
# run.md — 运维手册
## 1. 技术栈
| 层 | 技术 | 版本 |
|---|---|---|
| 前端 | Next.js + React + TypeScript | 15.x / 19.x / 5.x |
| UI | TailwindCSS 4.x + shadcn/ui + lucide + recharts + sonner | — |
| 后端 | Python 3.12 + FastAPI 0.115.x | — |
| ORM | SQLAlchemy 2.x + Alembic | — |
| 数据库 | PostgreSQL 16 + PgVector | — |
| 缓存 | Redis 7.x | — |
| AI | Ollama(本地推理)+ LangChain | — |
| 包管理 | pnpm(前端)+ uv(后端) | — |
| 部署 | Docker Compose | — |
## 2. 首次准备
```bash
# [Native] 安装依赖
brew install pnpm uv docker ollama
# [Native] 拉取 AI 模型
ollama pull qwen2.5:7b
# [Docker] 启动基础设施
docker compose up -d postgres redis ollama
# [Native] 后端
cd backend
uv sync
cp ../.env.example ../.env
uv run alembic upgrade head
uv run uvicorn app.main:app --reload --port 8000
# [Native] 前端
cd frontend
pnpm install
pnpm dev
```
## 3. 基础设施启停
```bash
# [Docker] 启动
docker compose up -d postgres redis ollama
# [Docker] 停止
docker compose stop postgres redis ollama
# [Docker] 查看状态
docker compose ps
# [Docker] 查看日志
docker compose logs -f postgres
docker compose logs -f redis
```
## 4. 应用启停
```bash
# [Native] 后端启动
cd backend && uv run uvicorn app.main:app --reload --port 8000
# [Native] 后端停止
Ctrl+C
# [Native] 前端启动
cd frontend && pnpm dev
# [Native] 前端停止
Ctrl+C
# [Docker] 全部启动
docker compose up -d
# [Docker] 全部停止
docker compose down
```
## 5. DB 命令
```bash
# [Docker] 进入 psql
docker compose exec postgres psql -U postgres -d aiportpilot
# [Native] 创建迁移
cd backend && uv run alembic revision --autogenerate -m "description"
# [Native] 执行迁移
cd backend && uv run alembic upgrade head
# [Native] 回滚一个版本
cd backend && uv run alembic downgrade -1
# [Native] 查看当前版本
cd backend && uv run alembic current
# [Docker] 备份数据库
docker compose exec postgres pg_dump -U postgres aiportpilot > backup_$(date +%Y%m%d).sql
# ⚠️ [Docker] 恢复数据库(危险!会覆盖现有数据)
# docker compose exec -T postgres psql -U postgres -d aiportpilot < backup_20250718.sql
```
## 6. 排错
| 问题 | 排查 |
|---|---|
| 后端启动失败 | 检查 `.env` 配置、PostgreSQL 是否运行 |
| 前端 API 401 | 检查 token 是否过期、`frontend/.env.local` 的 API URL |
| AI 解析超时 | 检查 Ollama 是否运行、模型是否已拉取 |
| 数据库连接失败 | `docker compose ps` 检查 PostgreSQL 状态 |
| 迁移冲突 | `alembic history` 检查,手动解决后重新生成 |
## 7. 端口表
| 服务 | 端口 | 说明 |
|---|---|---|
| Next.js | 3000 | 前端 dev server |
| FastAPI | 8000 | 后端 API server |
| PostgreSQL | 5432 | 数据库 |
| Redis | 6379 | 缓存 |
| Ollama | 11434 | 本地 AI 推理 |
## 8. 环境变量
`.env.example`,关键项:
```env
# 数据库
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/aiportpilot
# Redis
REDIS_URL=redis://localhost:6379/0
# JWT
JWT_SECRET_KEY=change-me-in-production
JWT_ALGORITHM=HS256
JWT_ACCESS_TOKEN_TTL_MINUTES=120
JWT_REFRESH_TOKEN_TTL_DAYS=7
# AI
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=qwen2.5:7b
# 前端
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
```
## 9. 部署备份
```bash
# [Docker] 全量备份(数据库 + 文件)
docker compose exec postgres pg_dump -U postgres aiportpilot > backups/db_$(date +%Y%m%d_%H%M).sql
# [Native] 代码备份(Git
git tag backup-$(date +%Y%m%d)
git push origin --tags
# ⚠️ [Docker] 恢复(危险!先停止应用)
# docker compose stop backend frontend
# docker compose exec -T postgres psql -U postgres -d aiportpilot < backups/db_20250718.sql
# docker compose start backend frontend
```
## 10. FAQ
**Q: 如何切换 AI 模型?**
A: 修改 `.env``OLLAMA_MODEL`,然后 `ollama pull <model_name>`
**Q: 如何添加新企业行业选项?**
A: 修改 `backend/app/core/enums.py` 中的 `Industry` 枚举,执行迁移。
**Q: 如何调整健康度权重?**
A: 修改 `backend/app/services/health_calculator.py` 中的权重配置。
**Q: 前端如何切换投资人/创始人端?**
A: 路由组自动隔离:`/` → 投资人端,`/founder` → 创始人端,`/admin` → Admin 端。