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
|
||||
Reference in New Issue
Block a user