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:
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 路由 + 前端登录页对接)
|
||||
Reference in New Issue
Block a user