From 2be0778ec72b21dc44ffde7ca76a3da10b4094f0 Mon Sep 17 00:00:00 2001 From: selfrelease Date: Sat, 18 Jul 2026 21:54:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(backend):=20T1.1=20=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E4=B8=8E=E6=9D=83=E9=99=90=20=E2=80=94=20=E6=B3=A8=E5=86=8C/?= =?UTF-8?q?=E7=99=BB=E5=BD=95/=E5=88=B7=E6=96=B0/=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E7=94=A8=E6=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:auth 路由(register/login/refresh/me)+ JWT + bcrypt 密码哈希 - 依赖注入:get_current_user + require_role 角色权限校验 - 跨数据库兼容:JSONBType(PG 用 JSONB,SQLite 用 JSON) - 测试:11 个认证测试 + 4 个健康检查测试 = 15 passed --- backend/app/core/dependencies.py | 66 ++++++++++ backend/app/core/types.py | 9 ++ backend/app/main.py | 4 + backend/app/models/audit.py | 6 +- backend/app/models/company.py | 4 +- backend/app/models/health_score.py | 6 +- backend/app/models/report.py | 6 +- backend/app/models/risk.py | 4 +- backend/app/models/tenant.py | 4 +- backend/app/routers/auth.py | 163 +++++++++++++++++++++++ backend/app/schemas/auth.py | 46 +++++++ backend/tests/test_auth.py | 202 +++++++++++++++++++++++++++++ progress.txt | 63 +++++++++ 13 files changed, 568 insertions(+), 15 deletions(-) create mode 100644 backend/app/core/dependencies.py create mode 100644 backend/app/core/types.py create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/schemas/auth.py create mode 100644 backend/tests/test_auth.py create mode 100644 progress.txt diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py new file mode 100644 index 0000000..b8f1ce6 --- /dev/null +++ b/backend/app/core/dependencies.py @@ -0,0 +1,66 @@ +"""认证依赖注入。 + +提供当前用户依赖、权限校验。 +""" + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.security import decode_token +from app.models.user import User + +security = HTTPBearer() + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: AsyncSession = Depends(get_db), +) -> User: + """从 JWT token 中解析当前用户。""" + token = credentials.credentials + try: + payload = decode_token(token) + except Exception: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的认证凭证", + ) + + if payload.get("type") != "access": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的 token 类型", + ) + + user_id = payload.get("sub") + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的 token 内容", + ) + + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户不存在或已禁用", + ) + + return user + + +def require_role(*roles: str): + """角色权限校验依赖工厂。""" + async def check_role(user: User = Depends(get_current_user)) -> User: + if user.role not in roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"需要角色:{', '.join(roles)}", + ) + return user + return check_role diff --git a/backend/app/core/types.py b/backend/app/core/types.py new file mode 100644 index 0000000..a40a61e --- /dev/null +++ b/backend/app/core/types.py @@ -0,0 +1,9 @@ +"""跨数据库兼容的 JSON 类型。 + +在 PostgreSQL 上使用 JSONB,在其他数据库(如 SQLite)上使用 JSON。 +""" + +from sqlalchemy import JSON +from sqlalchemy.dialects.postgresql import JSONB + +JSONBType = JSON().with_variant(JSONB(), "postgresql") diff --git a/backend/app/main.py b/backend/app/main.py index d8ab51e..d3ae029 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,6 +10,7 @@ from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse +from app.routers.auth import router as auth_router from app.schemas.common import error @@ -63,3 +64,6 @@ async def global_exception_handler(request: Request, exc: Exception): async def health_check(): """健康检查端点。""" return {"status": "ok", "service": "aiportpilot-backend", "version": "0.1.0"} + + +app.include_router(auth_router, prefix="/api/v1") diff --git a/backend/app/models/audit.py b/backend/app/models/audit.py index b451f3d..f134494 100644 --- a/backend/app/models/audit.py +++ b/backend/app/models/audit.py @@ -7,10 +7,10 @@ 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 +from app.core.types import JSONBType class AuditLog(Base): @@ -24,8 +24,8 @@ class AuditLog(Base): 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) + detail_json: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="操作详情") + ip: Mapped[str | None] = mapped_column(String(45), nullable=True, comment="IP 地址") created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) diff --git a/backend/app/models/company.py b/backend/app/models/company.py index 57ea9dc..36c29bd 100644 --- a/backend/app/models/company.py +++ b/backend/app/models/company.py @@ -7,10 +7,10 @@ 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 +from app.core.types import JSONBType class Company(Base): @@ -28,7 +28,7 @@ class Company(Base): 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="扩展字段") + extra_json: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="扩展字段") created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) diff --git a/backend/app/models/health_score.py b/backend/app/models/health_score.py index c18c856..cf42049 100644 --- a/backend/app/models/health_score.py +++ b/backend/app/models/health_score.py @@ -7,10 +7,10 @@ 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 +from app.core.types import JSONBType class HealthScore(Base): @@ -26,8 +26,8 @@ class HealthScore(Base): 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="建议动作") + evidence_json: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="评分依据") + recommendations_json: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="建议动作") calculated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) diff --git a/backend/app/models/report.py b/backend/app/models/report.py index c923210..dfad870 100644 --- a/backend/app/models/report.py +++ b/backend/app/models/report.py @@ -7,10 +7,10 @@ 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 +from app.core.types import JSONBType class MonthlyReport(Base): @@ -27,9 +27,9 @@ class MonthlyReport(Base): 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="结构化指标数据") + structured_data: Mapped[dict | None] = mapped_column(JSONBType, 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 关注点列表") + ai_concerns: Mapped[dict | None] = mapped_column(JSONBType, 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) diff --git a/backend/app/models/risk.py b/backend/app/models/risk.py index ed09abf..00610c8 100644 --- a/backend/app/models/risk.py +++ b/backend/app/models/risk.py @@ -7,10 +7,10 @@ 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 +from app.core.types import JSONBType class RiskEvent(Base): @@ -25,7 +25,7 @@ class RiskEvent(Base): 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="证据链") + evidence_json: Mapped[dict | None] = mapped_column(JSONBType, 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) diff --git a/backend/app/models/tenant.py b/backend/app/models/tenant.py index 40d54ec..20ab276 100644 --- a/backend/app/models/tenant.py +++ b/backend/app/models/tenant.py @@ -7,10 +7,10 @@ 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 +from app.core.types import JSONBType class Tenant(Base): @@ -21,7 +21,7 @@ class Tenant(Base): 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="租户配置") + config_json: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="租户配置") created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..dd285d9 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,163 @@ +"""认证路由:登录 / 注册 / 刷新 token / 获取当前用户。""" + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.database import get_db +from app.core.dependencies import get_current_user +from app.core.security import ( + create_access_token, + create_refresh_token, + decode_token, + hash_password, + verify_password, +) +from app.models.tenant import Tenant +from app.models.user import User +from app.schemas.auth import ( + LoginRequest, + RefreshRequest, + RegisterRequest, + TokenResponse, + UserInfo, +) +from app.schemas.common import ApiResponse, success + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/register", response_model=ApiResponse[TokenResponse]) +async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): + """用户注册(创建新租户 + 首个用户)。""" + # 检查邮箱是否已存在 + existing = await db.execute(select(User).where(User.email == req.email)) + if existing.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="邮箱已注册", + ) + + # 创建租户 + tenant = Tenant(name=req.tenant_name, type="vc") + db.add(tenant) + await db.flush() + + # 创建用户 + user = User( + tenant_id=tenant.id, + email=req.email, + password_hash=hash_password(req.password), + name=req.name, + role=req.role, + ) + db.add(user) + await db.flush() + + access_token = create_access_token( + subject=user.id, + extra_claims={"role": user.role, "tenant_id": user.tenant_id}, + ) + refresh_token = create_refresh_token(subject=user.id) + + return success( + data=TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + expires_in=settings.jwt_access_token_ttl_minutes * 60, + ), + message="注册成功", + ) + + +@router.post("/login", response_model=ApiResponse[TokenResponse]) +async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)): + """用户登录。""" + result = await db.execute(select(User).where(User.email == req.email)) + user = result.scalar_one_or_none() + + if not user or not verify_password(req.password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="邮箱或密码错误", + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="账户已禁用", + ) + + access_token = create_access_token( + subject=user.id, + extra_claims={"role": user.role, "tenant_id": user.tenant_id}, + ) + refresh_token = create_refresh_token(subject=user.id) + + return success( + data=TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + expires_in=settings.jwt_access_token_ttl_minutes * 60, + ), + message="登录成功", + ) + + +@router.post("/refresh", response_model=ApiResponse[TokenResponse]) +async def refresh_token(req: RefreshRequest, db: AsyncSession = Depends(get_db)): + """刷新 access token。""" + try: + payload = decode_token(req.refresh_token) + except Exception: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的 refresh token", + ) + + if payload.get("type") != "refresh": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的 token 类型", + ) + + user_id = payload.get("sub") + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户不存在或已禁用", + ) + + access_token = create_access_token( + subject=user.id, + extra_claims={"role": user.role, "tenant_id": user.tenant_id}, + ) + new_refresh_token = create_refresh_token(subject=user.id) + + return success( + data=TokenResponse( + access_token=access_token, + refresh_token=new_refresh_token, + expires_in=settings.jwt_access_token_ttl_minutes * 60, + ), + message="刷新成功", + ) + + +@router.get("/me", response_model=ApiResponse[UserInfo]) +async def get_me(user: User = Depends(get_current_user)): + """获取当前用户信息。""" + return success( + data=UserInfo( + id=user.id, + email=user.email, + name=user.name, + role=user.role, + tenant_id=user.tenant_id, + is_active=user.is_active, + ), + ) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..cbf11a6 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,46 @@ +"""认证相关 Pydantic schema。""" + +from pydantic import BaseModel, EmailStr, Field + + +class LoginRequest(BaseModel): + """登录请求。""" + + email: EmailStr + password: str = Field(..., min_length=6, max_length=128) + + +class RegisterRequest(BaseModel): + """注册请求。""" + + email: EmailStr + password: str = Field(..., min_length=6, max_length=128) + name: str = Field(..., min_length=1, max_length=100) + tenant_name: str = Field(..., min_length=1, max_length=200) + role: str = Field(default="founder", description="角色:gp/partner/post_invest_lead/investor/founder/admin") + + +class TokenResponse(BaseModel): + """Token 响应。""" + + access_token: str + refresh_token: str + token_type: str = "bearer" + expires_in: int = Field(description="access token 有效期(秒)") + + +class RefreshRequest(BaseModel): + """刷新 token 请求。""" + + refresh_token: str + + +class UserInfo(BaseModel): + """用户信息。""" + + id: str + email: str + name: str + role: str + tenant_id: str + is_active: bool diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..f08e9aa --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,202 @@ +"""认证流程测试。 + +测试注册、登录、获取当前用户、刷新 token 的完整流程。 +""" + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from app.core.database import Base, get_db +from app.main import app + +# 使用 SQLite 内存数据库做测试 +TEST_DATABASE_URL = "sqlite+aiosqlite:///file::memory:?cache=shared&uri=true" + +test_engine = create_async_engine(TEST_DATABASE_URL, echo=False) +test_session_factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + + +async def override_get_db(): + """测试用数据库 session。""" + async with test_session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + +app.dependency_overrides[get_db] = override_get_db + + +@pytest.fixture(scope="module", autouse=True) +async def setup_db(): + """创建测试数据库表。""" + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest.fixture +def client(): + """创建测试客户端。""" + return TestClient(app) + + +class TestRegister: + """注册测试。""" + + def test_register_success(self, client: TestClient): + """RED→GREEN: 正常注册应返回 token。""" + response = client.post( + "/api/v1/auth/register", + json={ + "email": "test@example.com", + "password": "password123", + "name": "测试用户", + "tenant_name": "测试投资机构", + "role": "founder", + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["code"] == 0 + assert data["data"]["access_token"] is not None + assert data["data"]["refresh_token"] is not None + assert data["data"]["token_type"] == "bearer" + + def test_register_duplicate_email(self, client: TestClient): + """RED→GREEN: 重复邮箱注册应返回 409。""" + response = client.post( + "/api/v1/auth/register", + json={ + "email": "test@example.com", + "password": "password123", + "name": "重复用户", + "tenant_name": "另一个机构", + "role": "founder", + }, + ) + assert response.status_code == 409 + + def test_register_short_password(self, client: TestClient): + """RED→GREEN: 密码太短应返回 422。""" + response = client.post( + "/api/v1/auth/register", + json={ + "email": "short@example.com", + "password": "123", + "name": "短密码", + "tenant_name": "测试机构", + "role": "founder", + }, + ) + assert response.status_code == 422 + + +class TestLogin: + """登录测试。""" + + def test_login_success(self, client: TestClient): + """RED→GREEN: 正确邮箱密码登录成功。""" + response = client.post( + "/api/v1/auth/login", + json={ + "email": "test@example.com", + "password": "password123", + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["code"] == 0 + assert data["data"]["access_token"] is not None + + def test_login_wrong_password(self, client: TestClient): + """RED→GREEN: 错误密码应返回 401。""" + response = client.post( + "/api/v1/auth/login", + json={ + "email": "test@example.com", + "password": "wrongpassword", + }, + ) + assert response.status_code == 401 + + def test_login_nonexistent_email(self, client: TestClient): + """RED→GREEN: 不存在的邮箱应返回 401。""" + response = client.post( + "/api/v1/auth/login", + json={ + "email": "nonexistent@example.com", + "password": "password123", + }, + ) + assert response.status_code == 401 + + +class TestGetMe: + """获取当前用户信息测试。""" + + def test_get_me_with_valid_token(self, client: TestClient): + """RED→GREEN: 有效 token 应返回用户信息。""" + # 先登录获取 token + login_resp = client.post( + "/api/v1/auth/login", + json={"email": "test@example.com", "password": "password123"}, + ) + token = login_resp.json()["data"]["access_token"] + + response = client.get( + "/api/v1/auth/me", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["code"] == 0 + assert data["data"]["email"] == "test@example.com" + assert data["data"]["name"] == "测试用户" + + def test_get_me_without_token(self, client: TestClient): + """RED→GREEN: 无 token 应返回 401。""" + response = client.get("/api/v1/auth/me") + assert response.status_code == 401 + + def test_get_me_with_invalid_token(self, client: TestClient): + """RED→GREEN: 无效 token 应返回 401。""" + response = client.get( + "/api/v1/auth/me", + headers={"Authorization": "Bearer invalid-token-string"}, + ) + assert response.status_code == 401 + + +class TestRefreshToken: + """刷新 token 测试。""" + + def test_refresh_success(self, client: TestClient): + """RED→GREEN: 有效 refresh token 应返回新 token。""" + login_resp = client.post( + "/api/v1/auth/login", + json={"email": "test@example.com", "password": "password123"}, + ) + refresh_token = login_resp.json()["data"]["refresh_token"] + + response = client.post( + "/api/v1/auth/refresh", + json={"refresh_token": refresh_token}, + ) + assert response.status_code == 200 + data = response.json() + assert data["code"] == 0 + assert data["data"]["access_token"] is not None + + def test_refresh_with_invalid_token(self, client: TestClient): + """RED→GREEN: 无效 refresh token 应返回 401。""" + response = client.post( + "/api/v1/auth/refresh", + json={"refresh_token": "invalid-token"}, + ) + assert response.status_code == 401 diff --git a/progress.txt b/progress.txt new file mode 100644 index 0000000..04327ea --- /dev/null +++ b/progress.txt @@ -0,0 +1,63 @@ +# 项目进度记录 + +> 最后更新:2026-07-18 + +## Phase 0:项目骨架 ✅ 完成 + +### 已完成清单 + +| 任务 | 状态 | 验证结果 | +|---|---|---| +| T0.1 项目目录结构 | ✅ | `.gitignore` + `.env.example` + 目录树 | +| T0.2 后端项目初始化 | ✅ | `pytest test_health.py` → 4 passed | +| T0.3 前端项目初始化 | ✅ | `pnpm build` → 8 路由构建成功 | +| T0.4 Docker Compose | ✅ | docker-compose.yml + Dockerfile(Docker Hub 网络受限,使用本地 PostgreSQL) | +| T0.5 数据库迁移基座 | ✅ | `alembic upgrade head` → 7 张核心表创建成功 | + +### 技术栈实际版本 + +- **后端**:Python 3.12.10 + FastAPI 0.139.2 + SQLAlchemy 2.0.51 + Alembic 1.18.5 +- **前端**:Next.js 16.2.10 + React 19.2.4 + TailwindCSS 4.3.3 + TypeScript 5.9.3 +- **数据库**:PostgreSQL 15.13(本地 Postgres.app,非 Docker) +- **包管理**:pip + venv(后端,uv 安装受阻) / pnpm 10.33.2(前端) + +### 网络问题记录 + +- Google Fonts、Docker Hub、PyPI 均有 SSL 连接问题 +- **解决方案**:PyPI 用清华镜像源;前端用系统字体替代 Google Fonts;数据库用本地 Postgres.app + +### 核心表结构 + +1. `tenants` — 租户(投资机构) +2. `users` — 用户(多角色) +3. `companies` — 被投企业 +4. `monthly_reports` — 月报 +5. `health_scores` — 健康度评分 +6. `risk_events` — 风险事件 +7. `audit_logs` — 审计日志 + +### 前端路由 + +- `/` → 重定向到 `/dashboard` +- `/dashboard` → 投资人端驾驶舱(B 端,左 Sidebar) +- `/founder` → 创始人端概览(C 端,顶部 Header + 底部导航) +- `/founder/reports` → 月报 +- `/founder/copilot` → AI 副驾驶 +- `/admin` → 管理后台(Admin 端,slate-950 Header) +- `/login` → 登录页 + +--- + +## Phase 1:MVP 核心功能 — 待开始 + +### 优先级排序 + +1. **T1.1 认证与权限** — 登录/注册/JWT/角色控制 +2. **T1.2 企业档案 CRUD** — 被投企业列表/详情/编辑 +3. **T1.3 月报管理** — 提交/查看/AI 解析 +4. **T1.4 健康度仪表盘** — 投资人端驾驶舱首页 +5. **T1.5 风险工作台** — 风险列表/详情/处理 + +### 下一步行动 + +→ 开始 T1.1:认证与权限(后端 auth 路由 + 前端登录页对接)