feat: 添加后端核心模块
用户认证: - User 模型(支持邮箱/手机号登录) - 双 Token JWT 认证(accessToken + refreshToken) - 注册/登录/刷新 Token API 组织模型: - Brand(品牌方)、Agency(代理商)、Creator(达人) - 多对多关系:品牌方↔代理商、代理商↔达人 项目与任务: - Project 模型(品牌方发布) - Task 模型(完整审核流程追踪) - Brief 模型(解析后的结构化内容) 文件上传: - 阿里云 OSS 直传签名服务 - 支持分片上传,最大 500MB 数据库迁移: - 003_user_org_project_task.py Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
4b8809efe2
commit
4caafdb50f
@@ -0,0 +1,246 @@
|
||||
"""
|
||||
认证 API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.schemas.auth import (
|
||||
RegisterRequest,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RefreshTokenRequest,
|
||||
RefreshTokenResponse,
|
||||
UserResponse,
|
||||
)
|
||||
from app.services.auth import (
|
||||
get_user_by_email,
|
||||
get_user_by_phone,
|
||||
get_user_by_id,
|
||||
create_user,
|
||||
authenticate_user,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
update_refresh_token,
|
||||
decode_token,
|
||||
get_user_organization_info,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=LoginResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
request: RegisterRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
用户注册
|
||||
|
||||
- 支持邮箱或手机号注册(至少提供一个)
|
||||
- 注册后自动登录,返回 Token
|
||||
"""
|
||||
# 验证至少提供邮箱或手机号
|
||||
if not request.email and not request.phone:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="请提供邮箱或手机号",
|
||||
)
|
||||
|
||||
# 检查邮箱是否已存在
|
||||
if request.email:
|
||||
existing = await get_user_by_email(db, request.email)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该邮箱已被注册",
|
||||
)
|
||||
|
||||
# 检查手机号是否已存在
|
||||
if request.phone:
|
||||
existing = await get_user_by_phone(db, request.phone)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该手机号已被注册",
|
||||
)
|
||||
|
||||
# 创建用户
|
||||
user = await create_user(
|
||||
db=db,
|
||||
email=request.email,
|
||||
phone=request.phone,
|
||||
password=request.password,
|
||||
name=request.name,
|
||||
role=request.role,
|
||||
)
|
||||
|
||||
# 生成 Token
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token, refresh_expires_at = create_refresh_token(user.id)
|
||||
|
||||
# 保存 refresh token
|
||||
await update_refresh_token(db, user, refresh_token, refresh_expires_at)
|
||||
await db.commit()
|
||||
|
||||
# 获取组织信息
|
||||
org_info = await get_user_organization_info(db, user)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
user=UserResponse(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
phone=user.phone,
|
||||
name=user.name,
|
||||
avatar=user.avatar,
|
||||
role=user.role,
|
||||
is_verified=user.is_verified,
|
||||
**org_info,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
request: LoginRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
用户登录
|
||||
|
||||
- 支持邮箱+密码 或 手机号+密码 登录
|
||||
- 返回 accessToken 和 refreshToken
|
||||
"""
|
||||
# 验证请求参数
|
||||
if not request.email and not request.phone:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="请提供邮箱或手机号",
|
||||
)
|
||||
|
||||
if not request.password:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="请提供密码",
|
||||
)
|
||||
|
||||
# 验证用户
|
||||
user = await authenticate_user(
|
||||
db=db,
|
||||
email=request.email,
|
||||
phone=request.phone,
|
||||
password=request.password,
|
||||
)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="邮箱/手机号或密码错误",
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="账号已被禁用",
|
||||
)
|
||||
|
||||
# 生成 Token
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token, refresh_expires_at = create_refresh_token(user.id)
|
||||
|
||||
# 保存 refresh token
|
||||
await update_refresh_token(db, user, refresh_token, refresh_expires_at)
|
||||
await db.commit()
|
||||
|
||||
# 获取组织信息
|
||||
org_info = await get_user_organization_info(db, user)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
user=UserResponse(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
phone=user.phone,
|
||||
name=user.name,
|
||||
avatar=user.avatar,
|
||||
role=user.role,
|
||||
is_verified=user.is_verified,
|
||||
**org_info,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=RefreshTokenResponse)
|
||||
async def refresh_token(
|
||||
request: RefreshTokenRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
刷新 Access Token
|
||||
|
||||
- 使用 refreshToken 获取新的 accessToken
|
||||
- refreshToken 有效期 7 天
|
||||
"""
|
||||
# 解码 refresh token
|
||||
payload = decode_token(request.refresh_token)
|
||||
if not payload:
|
||||
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")
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的 token",
|
||||
)
|
||||
|
||||
# 获取用户
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户不存在",
|
||||
)
|
||||
|
||||
# 验证 refresh token 是否匹配
|
||||
if user.refresh_token != request.refresh_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="refresh token 已失效",
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="账号已被禁用",
|
||||
)
|
||||
|
||||
# 生成新的 access token
|
||||
access_token = create_access_token(user.id)
|
||||
|
||||
return RefreshTokenResponse(access_token=access_token)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
# TODO: 添加认证依赖
|
||||
):
|
||||
"""
|
||||
退出登录
|
||||
|
||||
- 清除 refresh token
|
||||
"""
|
||||
# TODO: 实现退出登录
|
||||
return {"message": "已退出登录"}
|
||||
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
文件上传 API
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.services.oss import generate_upload_policy, get_file_url
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter(prefix="/upload", tags=["文件上传"])
|
||||
|
||||
|
||||
class UploadPolicyRequest(BaseModel):
|
||||
"""获取上传凭证请求"""
|
||||
file_type: str = "general" # script, video, image, general
|
||||
file_name: Optional[str] = None
|
||||
|
||||
|
||||
class UploadPolicyResponse(BaseModel):
|
||||
"""上传凭证响应"""
|
||||
access_key_id: str
|
||||
policy: str
|
||||
signature: str
|
||||
host: str
|
||||
dir: str
|
||||
expire: int
|
||||
max_size_mb: int
|
||||
|
||||
|
||||
class FileUploadedRequest(BaseModel):
|
||||
"""文件上传完成回调"""
|
||||
file_key: str
|
||||
file_name: str
|
||||
file_size: int
|
||||
file_type: str
|
||||
|
||||
|
||||
class FileUploadedResponse(BaseModel):
|
||||
"""文件上传完成响应"""
|
||||
url: str
|
||||
file_key: str
|
||||
file_name: str
|
||||
file_size: int
|
||||
file_type: str
|
||||
|
||||
|
||||
@router.post("/policy", response_model=UploadPolicyResponse)
|
||||
async def get_upload_policy(
|
||||
request: UploadPolicyRequest,
|
||||
):
|
||||
"""
|
||||
获取 OSS 直传凭证
|
||||
|
||||
前端使用此凭证直接上传文件到阿里云 OSS,无需经过后端。
|
||||
|
||||
文件类型说明:
|
||||
- script: 脚本文档 (docx, pdf, xlsx, txt, pptx)
|
||||
- video: 视频文件 (mp4, mov, webm)
|
||||
- image: 图片文件 (jpg, png, gif)
|
||||
- general: 通用文件
|
||||
"""
|
||||
# 根据文件类型设置上传目录
|
||||
now = datetime.now()
|
||||
base_dir = f"uploads/{now.year}/{now.month:02d}"
|
||||
|
||||
if request.file_type == "script":
|
||||
upload_dir = f"{base_dir}/scripts/"
|
||||
elif request.file_type == "video":
|
||||
upload_dir = f"{base_dir}/videos/"
|
||||
elif request.file_type == "image":
|
||||
upload_dir = f"{base_dir}/images/"
|
||||
else:
|
||||
upload_dir = f"{base_dir}/files/"
|
||||
|
||||
try:
|
||||
policy = generate_upload_policy(
|
||||
max_size_mb=settings.MAX_FILE_SIZE_MB,
|
||||
expire_seconds=3600,
|
||||
upload_dir=upload_dir,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
return UploadPolicyResponse(
|
||||
access_key_id=policy["accessKeyId"],
|
||||
policy=policy["policy"],
|
||||
signature=policy["signature"],
|
||||
host=policy["host"],
|
||||
dir=policy["dir"],
|
||||
expire=policy["expire"],
|
||||
max_size_mb=settings.MAX_FILE_SIZE_MB,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/complete", response_model=FileUploadedResponse)
|
||||
async def file_uploaded(
|
||||
request: FileUploadedRequest,
|
||||
):
|
||||
"""
|
||||
文件上传完成回调
|
||||
|
||||
前端上传完成后调用此接口,获取文件的完整 URL。
|
||||
"""
|
||||
url = get_file_url(request.file_key)
|
||||
|
||||
return FileUploadedResponse(
|
||||
url=url,
|
||||
file_key=request.file_key,
|
||||
file_name=request.file_name,
|
||||
file_size=request.file_size,
|
||||
file_type=request.file_type,
|
||||
)
|
||||
Reference in New Issue
Block a user