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.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")
+3 -3
View File
@@ -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)
)
+2 -2
View File
@@ -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)
)
+3 -3
View File
@@ -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)
)
+3 -3
View File
@@ -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)
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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)
)
+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 路由 + 前端登录页对接)