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:
@@ -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
@@ -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
|
||||
@@ -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/`
|
||||
@@ -0,0 +1,244 @@
|
||||
# 应用构建方法论 v3.0(融合 Superpowers)
|
||||
|
||||
> 基于 v2.5 + [obra/superpowers](https://github.com/obra/superpowers) 流程纪律,针对 AIPortPilot 项目优化。
|
||||
> 核心理念:**结构化 > 自由发挥 / 快照 > 覆盖 / 演进 > 重写 / 先想再写 / 先测再码**
|
||||
|
||||
---
|
||||
|
||||
## 一、七步开发流程(强制执行)
|
||||
|
||||
每一步都是**必须执行的工作流**,不是建议。Agent 在任何任务前先检查当前处于哪一步。
|
||||
|
||||
### Step 1:Brainstorming(头脑风暴)
|
||||
|
||||
**触发**:用户提出新功能/新模块需求时
|
||||
**动作**:
|
||||
- 不急着写代码,先苏格拉底式提问
|
||||
- 探索替代方案,权衡取舍
|
||||
- 分段展示设计,每段短到用户能读完就消化
|
||||
- 产出:`docs/1-prd.md`(产品设计文档)
|
||||
**完成标志**:用户对 PRD 签字确认
|
||||
|
||||
### Step 2:Design Document(设计文档)
|
||||
|
||||
**触发**:PRD 签字后
|
||||
**动作**:
|
||||
- 技术架构设计(技术栈、数据模型、API 设计、UI/UX 方案)
|
||||
- 产出:`docs/1-prd.md` 中的技术设计章节
|
||||
**完成标志**:用户对技术方案签字确认
|
||||
|
||||
### Step 3:Writing Plans(任务拆解)
|
||||
|
||||
**触发**:设计批准后
|
||||
**动作**:
|
||||
- 将工作拆成 **2-5 分钟** 的小任务
|
||||
- 每个任务有:精确文件路径、完整代码描述、验证步骤
|
||||
- 任务之间无循环依赖,可并行标注
|
||||
- 产出:`docs/2-task.md`(任务清单)
|
||||
**完成标志**:用户对任务清单签字确认
|
||||
|
||||
### Step 4:Feature Branch(特性分支)
|
||||
|
||||
**触发**:任务清单批准后
|
||||
**动作**:
|
||||
- `git checkout -b feature/{module-name}`
|
||||
- 确保干净测试基线
|
||||
**完成标志**:分支创建成功
|
||||
|
||||
### Step 5:TDD Implementation(测试驱动实现)
|
||||
|
||||
**触发**:分支创建后
|
||||
**动作**:
|
||||
- **RED**:先写失败测试,运行确认失败
|
||||
- **GREEN**:写最小代码让测试通过
|
||||
- **REFACTOR**:重构,保持测试绿色
|
||||
- **COMMIT**:每个 RED-GREEN 循环提交一次
|
||||
- **禁止**:先写代码后补测试
|
||||
**完成标志**:所有任务测试通过
|
||||
|
||||
### Step 6:Code Review(代码审查)
|
||||
|
||||
**触发**:每个任务完成后
|
||||
**动作**:
|
||||
- 对照计划检查规格符合度
|
||||
- 检查代码质量(命名、结构、安全、性能)
|
||||
- 严重问题阻断进度,必须修复后继续
|
||||
**完成标志**:审查通过
|
||||
|
||||
### Step 7:Finishing 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)+ refresh;Cookie 必 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
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
@@ -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()
|
||||
@@ -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 ###
|
||||
@@ -0,0 +1 @@
|
||||
"""AIPortPilot 后端应用包。"""
|
||||
@@ -0,0 +1 @@
|
||||
"""应用配置模块。"""
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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])
|
||||
@@ -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"}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""API 路由模块。"""
|
||||
@@ -0,0 +1 @@
|
||||
"""Pydantic schema 模块。"""
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""业务服务模块。"""
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""测试包。"""
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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 基础问答
|
||||
- 待办事项查看与审批
|
||||
|
||||
## 四、非功能需求
|
||||
|
||||
| 维度 | 要求 |
|
||||
|---|---|
|
||||
| 性能 | 页面首屏 ≤ 2s,API 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
@@ -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
@@ -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 1:MVP 核心功能
|
||||
|
||||
### 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.5(DB 依赖 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 周 |
|
||||
@@ -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
|
||||
@@ -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 -->
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
turbopack: {
|
||||
root: __dirname,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+5188
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 端。
|
||||
Reference in New Issue
Block a user