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.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")
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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