feat(backend): T1.1 认证与权限 — 注册/登录/刷新/获取用户

- 后端:auth 路由(register/login/refresh/me)+ JWT + bcrypt 密码哈希
- 依赖注入:get_current_user + require_role 角色权限校验
- 跨数据库兼容:JSONBType(PG 用 JSONB,SQLite 用 JSON)
- 测试:11 个认证测试 + 4 个健康检查测试 = 15 passed
This commit is contained in:
selfrelease
2026-07-18 21:54:06 +08:00
parent 51feae55ba
commit 2be0778ec7
13 changed files with 568 additions and 15 deletions
+66
View File
@@ -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
+9
View File
@@ -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")
+4
View File
@@ -10,6 +10,7 @@ from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.routers.auth import router as auth_router
from app.schemas.common import error from app.schemas.common import error
@@ -63,3 +64,6 @@ async def global_exception_handler(request: Request, exc: Exception):
async def health_check(): async def health_check():
"""健康检查端点。""" """健康检查端点。"""
return {"status": "ok", "service": "aiportpilot-backend", "version": "0.1.0"} return {"status": "ok", "service": "aiportpilot-backend", "version": "0.1.0"}
app.include_router(auth_router, prefix="/api/v1")
+3 -3
View File
@@ -7,10 +7,10 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB, INET
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base from app.core.database import Base
from app.core.types import JSONBType
class AuditLog(Base): 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") 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_type: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="资源类型")
resource_id: Mapped[str | None] = mapped_column(String(36), nullable=True, comment="资源 ID") resource_id: Mapped[str | None] = mapped_column(String(36), nullable=True, comment="资源 ID")
detail_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True, comment="操作详情") detail_json: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="操作详情")
ip: Mapped[str | None] = mapped_column(INET, nullable=True) ip: Mapped[str | None] = mapped_column(String(45), nullable=True, comment="IP 地址")
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
) )
+2 -2
View File
@@ -7,10 +7,10 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base from app.core.database import Base
from app.core.types import JSONBType
class Company(Base): class Company(Base):
@@ -28,7 +28,7 @@ class Company(Base):
founded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) founded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
total_funding: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="累计融资额") total_funding: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="累计融资额")
website: Mapped[str | None] = mapped_column(String(500), nullable=True) 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( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
) )
+3 -3
View File
@@ -7,10 +7,10 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, ForeignKey, String from sqlalchemy import DateTime, Float, ForeignKey, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base from app.core.database import Base
from app.core.types import JSONBType
class HealthScore(Base): class HealthScore(Base):
@@ -26,8 +26,8 @@ class HealthScore(Base):
ai_commercial_score: Mapped[float | None] = mapped_column(Float, nullable=True, comment="AI+ 商业化健康度") 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+ 成本健康度") 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") 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="评分依据") evidence_json: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="评分依据")
recommendations_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True, comment="建议动作") recommendations_json: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="建议动作")
calculated_at: Mapped[datetime] = mapped_column( calculated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
) )
+3 -3
View File
@@ -7,10 +7,10 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base from app.core.database import Base
from app.core.types import JSONBType
class MonthlyReport(Base): class MonthlyReport(Base):
@@ -27,9 +27,9 @@ class MonthlyReport(Base):
comment="状态:draft/submitted/ai_parsed/reviewed", comment="状态:draft/submitted/ai_parsed/reviewed",
) )
raw_content: Mapped[str | None] = mapped_column(Text, nullable=True, comment="原始内容") 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_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_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) 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_by: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True)
+2 -2
View File
@@ -7,10 +7,10 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base from app.core.database import Base
from app.core.types import JSONBType
class RiskEvent(Base): 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") 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) title: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True) 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="建议动作") 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) 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) due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+2 -2
View File
@@ -7,10 +7,10 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import DateTime, String from sqlalchemy import DateTime, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base from app.core.database import Base
from app.core.types import JSONBType
class Tenant(Base): 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())) 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="租户名称") 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") 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( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
) )
+163
View File
@@ -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,
),
)
+46
View File
@@ -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
+202
View File
@@ -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
+63
View File
@@ -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 + DockerfileDocker 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 1MVP 核心功能 — 待开始
### 优先级排序
1. **T1.1 认证与权限** — 登录/注册/JWT/角色控制
2. **T1.2 企业档案 CRUD** — 被投企业列表/详情/编辑
3. **T1.3 月报管理** — 提交/查看/AI 解析
4. **T1.4 健康度仪表盘** — 投资人端驾驶舱首页
5. **T1.5 风险工作台** — 风险列表/详情/处理
### 下一步行动
→ 开始 T1.1:认证与权限(后端 auth 路由 + 前端登录页对接)