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

- 后端:FastAPI + SQLAlchemy + Alembic,7 张核心表迁移成功
- 前端:Next.js 16 + TailwindCSS 4 + 三端布局(投资人/创始人/Admin)
- 数据库:PostgreSQL 16,7 张核心实体表(tenants/users/companies/monthly_reports/health_scores/risk_events/audit_logs)
- Docker:docker-compose.yml + 前后端 Dockerfile
- 测试:健康检查 4 个测试全部 GREEN
- 文档:README/run.md/AGENTS.md/docs 体系完整
This commit is contained in:
selfrelease
2026-07-18 21:50:15 +08:00
parent f09aa33589
commit 51feae55ba
72 changed files with 8048 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""AIPortPilot 后端应用包。"""
+1
View File
@@ -0,0 +1 @@
"""应用配置模块。"""
+36
View File
@@ -0,0 +1,36 @@
"""应用配置。
从环境变量读取配置,支持 .env 文件。
"""
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""应用配置,从环境变量读取。"""
# 应用
app_env: str = "development"
app_debug: bool = True
app_log_level: str = "info"
# 数据库
database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/aiportpilot"
# Redis
redis_url: str = "redis://localhost:6379/0"
# JWT
jwt_secret_key: str = "change-me-in-production"
jwt_algorithm: str = "HS256"
jwt_access_token_ttl_minutes: int = 120
jwt_refresh_token_ttl_days: int = 7
# AI / Ollama
ollama_base_url: str = "http://localhost:11434"
ollama_model: str = "qwen2.5:7b"
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
settings = Settings()
+43
View File
@@ -0,0 +1,43 @@
"""数据库连接管理。
提供 SQLAlchemy 异步 engine 和 session 工厂。
"""
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
engine = create_async_engine(
settings.database_url,
echo=settings.app_debug,
pool_size=10,
max_overflow=20,
)
async_session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
"""SQLAlchemy ORM 基类。"""
pass
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""获取数据库 session 的依赖注入函数。"""
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
+46
View File
@@ -0,0 +1,46 @@
"""安全模块:JWT 生成与验证、密码哈希。"""
from datetime import datetime, timedelta, timezone
from typing import Any
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
"""密码哈希。"""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证密码。"""
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(subject: str, extra_claims: dict[str, Any] | None = None) -> str:
"""生成 JWT access token。"""
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.jwt_access_token_ttl_minutes
)
payload: dict[str, Any] = {"sub": subject, "exp": expire, "type": "access"}
if extra_claims:
payload.update(extra_claims)
return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
def create_refresh_token(subject: str) -> str:
"""生成 JWT refresh token。"""
expire = datetime.now(timezone.utc) + timedelta(
days=settings.jwt_refresh_token_ttl_days
)
payload = {"sub": subject, "exp": expire, "type": "refresh"}
return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
def decode_token(token: str) -> dict[str, Any]:
"""解码 JWT token。"""
return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
+65
View File
@@ -0,0 +1,65 @@
"""FastAPI 应用入口。
注册中间件、路由、异常处理。
"""
import uuid
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.schemas.common import error
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理。"""
# startup
yield
# shutdown
app = FastAPI(
title="AIPortPilot",
description="AI+ Portfolio Operating System — 投后管理与组合协同平台",
version="0.1.0",
lifespan=lifespan,
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def trace_id_middleware(request: Request, call_next):
"""为每个请求注入 trace_id。"""
trace_id = request.headers.get("X-Trace-Id", str(uuid.uuid4()))
request.state.trace_id = trace_id
response = await call_next(request)
response.headers["X-Trace-Id"] = trace_id
return response
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
"""全局异常处理。"""
trace_id = getattr(request.state, "trace_id", str(uuid.uuid4()))
return JSONResponse(
status_code=500,
content=error(code=-1, message="内部服务器错误"),
headers={"X-Trace-Id": trace_id},
)
@app.get("/health")
async def health_check():
"""健康检查端点。"""
return {"status": "ok", "service": "aiportpilot-backend", "version": "0.1.0"}
+22
View File
@@ -0,0 +1,22 @@
"""数据模型模块。
导入所有模型以便 Alembic 自动发现。
"""
from app.models.audit import AuditLog
from app.models.company import Company
from app.models.health_score import HealthScore
from app.models.report import MonthlyReport
from app.models.risk import RiskEvent
from app.models.tenant import Tenant
from app.models.user import User
__all__ = [
"AuditLog",
"Company",
"HealthScore",
"MonthlyReport",
"RiskEvent",
"Tenant",
"User",
]
+31
View File
@@ -0,0 +1,31 @@
"""审计日志模型。
全链路操作记录,保留 ≥ 6 月。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB, INET
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class AuditLog(Base):
"""审计日志。"""
__tablename__ = "audit_logs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False, index=True)
user_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True)
action: Mapped[str] = mapped_column(String(100), nullable=False, comment="操作类型:login/view/create/update/delete/export/ai_call")
resource_type: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="资源类型")
resource_id: Mapped[str | None] = mapped_column(String(36), nullable=True, comment="资源 ID")
detail_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True, comment="操作详情")
ip: Mapped[str | None] = mapped_column(INET, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+39
View File
@@ -0,0 +1,39 @@
"""企业模型。
被投企业档案,包含基本信息、业务描述、投资关系等。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class Company(Base):
"""被投企业。"""
__tablename__ = "companies"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False, comment="企业名称")
industry: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="行业")
stage: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="融资阶段:seed/a/b/c/ipo")
logo_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True, comment="业务描述")
founded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
total_funding: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="累计融资额")
website: Mapped[str | None] = mapped_column(String(500), nullable=True)
extra_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True, comment="扩展字段")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+33
View File
@@ -0,0 +1,33 @@
"""健康度评分模型。
多维度评分:财务、经营、AI+ 商业化、AI+ 成本。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, ForeignKey, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class HealthScore(Base):
"""健康度评分。"""
__tablename__ = "health_scores"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
total_score: Mapped[float] = mapped_column(Float, nullable=False, comment="总分(0-100")
financial_score: Mapped[float | None] = mapped_column(Float, nullable=True, comment="财务健康度")
operational_score: Mapped[float | None] = mapped_column(Float, nullable=True, comment="经营健康度")
ai_commercial_score: Mapped[float | None] = mapped_column(Float, nullable=True, comment="AI+ 商业化健康度")
ai_cost_score: Mapped[float | None] = mapped_column(Float, nullable=True, comment="AI+ 成本健康度")
trend: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="趋势:up/stable/down")
evidence_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True, comment="评分依据")
recommendations_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True, comment="建议动作")
calculated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+44
View File
@@ -0,0 +1,44 @@
"""月报模型。
被投企业按月提交的经营报告,支持 AI 解析。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class MonthlyReport(Base):
"""月报。"""
__tablename__ = "monthly_reports"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
period_year: Mapped[int] = mapped_column(Integer, nullable=False, comment="报告年份")
period_month: Mapped[int] = mapped_column(Integer, nullable=False, comment="报告月份(1-12")
status: Mapped[str] = mapped_column(
String(50), nullable=False, default="draft",
comment="状态:draft/submitted/ai_parsed/reviewed",
)
raw_content: Mapped[str | None] = mapped_column(Text, nullable=True, comment="原始内容")
structured_data: Mapped[dict | None] = mapped_column(JSONB, nullable=True, comment="结构化指标数据")
ai_summary: Mapped[str | None] = mapped_column(Text, nullable=True, comment="AI 生成的摘要")
ai_concerns: Mapped[dict | None] = mapped_column(JSONB, nullable=True, comment="AI 关注点列表")
submitted_by: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True)
submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
reviewed_by: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True)
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+43
View File
@@ -0,0 +1,43 @@
"""风险事件模型。
指标越界自动预警 + 人工处理闭环。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class RiskEvent(Base):
"""风险事件。"""
__tablename__ = "risk_events"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
type: Mapped[str] = mapped_column(String(50), nullable=False, comment="风险类型:financial/operational/org/ai_specific")
severity: Mapped[str] = mapped_column(String(20), nullable=False, default="medium", comment="严重程度:low/medium/high/critical")
status: Mapped[str] = mapped_column(String(20), nullable=False, default="open", comment="状态:open/assigned/in_progress/resolved/closed")
title: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
evidence_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True, comment="证据链")
suggested_action: Mapped[str | None] = mapped_column(Text, nullable=True, comment="建议动作")
assigned_to: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True)
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
identified_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+32
View File
@@ -0,0 +1,32 @@
"""租户模型。
投资机构和被投企业都属于某个租户。多租户隔离的基础。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class Tenant(Base):
"""租户(投资机构)。"""
__tablename__ = "tenants"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name: Mapped[str] = mapped_column(String(200), nullable=False, comment="租户名称")
type: Mapped[str] = mapped_column(String(50), nullable=False, default="vc", comment="租户类型:vc/cvc/gov/holdings")
config_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True, comment="租户配置")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+38
View File
@@ -0,0 +1,38 @@
"""用户模型。
支持多种角色:GP、投资经理、投后负责人、创始人、管理员等。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class User(Base):
"""用户。"""
__tablename__ = "users"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
role: Mapped[str] = mapped_column(
String(50), nullable=False, default="investor",
comment="角色:gp/partner/post_invest_lead/investor/founder/admin",
)
phone: Mapped[str | None] = mapped_column(String(20), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+1
View File
@@ -0,0 +1 @@
"""API 路由模块。"""
+1
View File
@@ -0,0 +1 @@
"""Pydantic schema 模块。"""
+35
View File
@@ -0,0 +1,35 @@
"""统一响应模型。
所有 API 返回统一壳:{code, message, data, trace_id, timestamp}
"""
import uuid
from datetime import datetime, timezone
from typing import Any, Generic, TypeVar
from pydantic import BaseModel, Field
T = TypeVar("T")
class ApiResponse(BaseModel, Generic[T]):
"""统一 API 响应壳。"""
code: int = Field(default=0, description="业务状态码,0 表示成功")
message: str = Field(default="success", description="提示信息")
data: T | None = Field(default=None, description="业务数据")
trace_id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="链路追踪 ID")
timestamp: str = Field(
default_factory=lambda: datetime.now(timezone.utc).isoformat(),
description="响应时间(UTC ISO 8601",
)
def success(data: Any = None, message: str = "success") -> dict[str, Any]:
"""构造成功响应。"""
return ApiResponse(code=0, message=message, data=data).model_dump()
def error(code: int = -1, message: str = "error", data: Any = None) -> dict[str, Any]:
"""构造错误响应。"""
return ApiResponse(code=code, message=message, data=data).model_dump()
+1
View File
@@ -0,0 +1 @@
"""业务服务模块。"""