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,215 @@
|
||||
"""
|
||||
认证服务
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
import secrets
|
||||
from jose import jwt, JWTError
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import settings
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.organization import Brand, Agency, Creator
|
||||
|
||||
# 密码加密上下文
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""验证密码"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""哈希密码"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def generate_id(prefix: str) -> str:
|
||||
"""生成语义化 ID"""
|
||||
# 格式: BR123456, AG123456, CR123456
|
||||
random_part = secrets.randbelow(900000) + 100000 # 100000-999999
|
||||
return f"{prefix}{random_part}"
|
||||
|
||||
|
||||
def create_access_token(user_id: str, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""创建访问 Token"""
|
||||
if expires_delta is None:
|
||||
expires_delta = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
to_encode = {
|
||||
"sub": user_id,
|
||||
"exp": expire,
|
||||
"type": "access",
|
||||
}
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(user_id: str, expires_days: int = 7) -> tuple[str, datetime]:
|
||||
"""创建刷新 Token"""
|
||||
expire = datetime.utcnow() + timedelta(days=expires_days)
|
||||
to_encode = {
|
||||
"sub": user_id,
|
||||
"exp": expire,
|
||||
"type": "refresh",
|
||||
}
|
||||
token = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return token, expire
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
"""解码 Token"""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
async def get_user_by_email(db: AsyncSession, email: str) -> Optional[User]:
|
||||
"""通过邮箱获取用户"""
|
||||
result = await db.execute(
|
||||
select(User).where(User.email == email)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user_by_phone(db: AsyncSession, phone: str) -> Optional[User]:
|
||||
"""通过手机号获取用户"""
|
||||
result = await db.execute(
|
||||
select(User).where(User.phone == phone)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user_by_id(db: AsyncSession, user_id: str) -> Optional[User]:
|
||||
"""通过 ID 获取用户"""
|
||||
result = await db.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_user(
|
||||
db: AsyncSession,
|
||||
email: Optional[str],
|
||||
phone: Optional[str],
|
||||
password: str,
|
||||
name: str,
|
||||
role: UserRole,
|
||||
) -> User:
|
||||
"""创建用户"""
|
||||
user_id = generate_id("U")
|
||||
|
||||
user = User(
|
||||
id=user_id,
|
||||
email=email,
|
||||
phone=phone,
|
||||
password_hash=hash_password(password),
|
||||
name=name,
|
||||
role=role,
|
||||
is_active=True,
|
||||
is_verified=False,
|
||||
)
|
||||
db.add(user)
|
||||
|
||||
# 根据角色创建对应的组织实体
|
||||
if role == UserRole.BRAND:
|
||||
brand = Brand(
|
||||
id=generate_id("BR"),
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
)
|
||||
db.add(brand)
|
||||
elif role == UserRole.AGENCY:
|
||||
agency = Agency(
|
||||
id=generate_id("AG"),
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
)
|
||||
db.add(agency)
|
||||
elif role == UserRole.CREATOR:
|
||||
creator = Creator(
|
||||
id=generate_id("CR"),
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
)
|
||||
db.add(creator)
|
||||
|
||||
await db.flush()
|
||||
return user
|
||||
|
||||
|
||||
async def authenticate_user(
|
||||
db: AsyncSession,
|
||||
email: Optional[str] = None,
|
||||
phone: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
) -> Optional[User]:
|
||||
"""验证用户登录"""
|
||||
user = None
|
||||
|
||||
if email:
|
||||
user = await get_user_by_email(db, email)
|
||||
elif phone:
|
||||
user = await get_user_by_phone(db, phone)
|
||||
|
||||
if not user:
|
||||
return None
|
||||
|
||||
if password and not verify_password(password, user.password_hash):
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def update_refresh_token(db: AsyncSession, user: User, refresh_token: str, expires_at: datetime) -> None:
|
||||
"""更新用户的刷新 Token"""
|
||||
user.refresh_token = refresh_token
|
||||
user.refresh_token_expires_at = expires_at
|
||||
user.last_login_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def get_user_organization_info(db: AsyncSession, user: User) -> dict:
|
||||
"""获取用户的组织信息"""
|
||||
info = {
|
||||
"brand_id": None,
|
||||
"agency_id": None,
|
||||
"creator_id": None,
|
||||
"tenant_id": None,
|
||||
"tenant_name": None,
|
||||
}
|
||||
|
||||
if user.role == UserRole.BRAND:
|
||||
result = await db.execute(
|
||||
select(Brand).where(Brand.user_id == user.id)
|
||||
)
|
||||
brand = result.scalar_one_or_none()
|
||||
if brand:
|
||||
info["brand_id"] = brand.id
|
||||
info["tenant_id"] = brand.id
|
||||
info["tenant_name"] = brand.name
|
||||
|
||||
elif user.role == UserRole.AGENCY:
|
||||
result = await db.execute(
|
||||
select(Agency).where(Agency.user_id == user.id)
|
||||
)
|
||||
agency = result.scalar_one_or_none()
|
||||
if agency:
|
||||
info["agency_id"] = agency.id
|
||||
# 代理商可能服务多个品牌,这里暂时不设置 tenant
|
||||
|
||||
elif user.role == UserRole.CREATOR:
|
||||
result = await db.execute(
|
||||
select(Creator).where(Creator.user_id == user.id)
|
||||
)
|
||||
creator = result.scalar_one_or_none()
|
||||
if creator:
|
||||
info["creator_id"] = creator.id
|
||||
# 达人可能服务多个代理商,这里暂时不设置 tenant
|
||||
|
||||
return info
|
||||
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
阿里云 OSS 服务
|
||||
"""
|
||||
import time
|
||||
import hmac
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def generate_upload_policy(
|
||||
max_size_mb: int = 500,
|
||||
expire_seconds: int = 3600,
|
||||
upload_dir: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
生成前端直传 OSS 所需的 Policy 和签名
|
||||
|
||||
Returns:
|
||||
{
|
||||
"accessKeyId": "...",
|
||||
"policy": "base64 encoded policy",
|
||||
"signature": "...",
|
||||
"host": "https://bucket.oss-cn-hangzhou.aliyuncs.com",
|
||||
"dir": "uploads/2026/02/",
|
||||
"expire": 1234567890
|
||||
}
|
||||
"""
|
||||
if not settings.OSS_ACCESS_KEY_ID or not settings.OSS_ACCESS_KEY_SECRET:
|
||||
raise ValueError("OSS 配置未设置")
|
||||
|
||||
# 计算过期时间
|
||||
expire_time = int(time.time()) + expire_seconds
|
||||
expire_date = datetime.utcfromtimestamp(expire_time).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
# 默认上传目录:uploads/年/月/
|
||||
if upload_dir is None:
|
||||
now = datetime.now()
|
||||
upload_dir = f"uploads/{now.year}/{now.month:02d}/"
|
||||
|
||||
# 构建 Policy
|
||||
policy_dict = {
|
||||
"expiration": expire_date,
|
||||
"conditions": [
|
||||
{"bucket": settings.OSS_BUCKET_NAME},
|
||||
["starts-with", "$key", upload_dir],
|
||||
["content-length-range", 0, max_size_mb * 1024 * 1024],
|
||||
]
|
||||
}
|
||||
|
||||
# Base64 编码 Policy
|
||||
policy_json = json.dumps(policy_dict)
|
||||
policy_base64 = base64.b64encode(policy_json.encode()).decode()
|
||||
|
||||
# 计算签名
|
||||
signature = base64.b64encode(
|
||||
hmac.new(
|
||||
settings.OSS_ACCESS_KEY_SECRET.encode(),
|
||||
policy_base64.encode(),
|
||||
hashlib.sha1
|
||||
).digest()
|
||||
).decode()
|
||||
|
||||
# 构建 Host
|
||||
host = settings.OSS_BUCKET_DOMAIN
|
||||
if not host:
|
||||
host = f"https://{settings.OSS_BUCKET_NAME}.{settings.OSS_ENDPOINT}"
|
||||
|
||||
return {
|
||||
"accessKeyId": settings.OSS_ACCESS_KEY_ID,
|
||||
"policy": policy_base64,
|
||||
"signature": signature,
|
||||
"host": host,
|
||||
"dir": upload_dir,
|
||||
"expire": expire_time,
|
||||
}
|
||||
|
||||
|
||||
def generate_sts_token(
|
||||
role_arn: str,
|
||||
session_name: str = "miaosi-upload",
|
||||
duration_seconds: int = 3600,
|
||||
) -> dict:
|
||||
"""
|
||||
生成 STS 临时凭证(需要配置 RAM 角色)
|
||||
|
||||
注意:此方法需要安装 aliyun-python-sdk-sts
|
||||
如果不使用 STS,可以使用上面的 generate_upload_policy 方法
|
||||
"""
|
||||
# TODO: 实现 STS 临时凭证生成
|
||||
# 需要安装 aliyun-python-sdk-core 和 aliyun-python-sdk-sts
|
||||
raise NotImplementedError("STS 临时凭证生成暂未实现,请使用 generate_upload_policy")
|
||||
|
||||
|
||||
def get_file_url(file_key: str) -> str:
|
||||
"""
|
||||
获取文件的公开访问 URL
|
||||
|
||||
Args:
|
||||
file_key: 文件在 OSS 中的 key,如 "uploads/2026/02/video.mp4"
|
||||
|
||||
Returns:
|
||||
完整的访问 URL
|
||||
"""
|
||||
host = settings.OSS_BUCKET_DOMAIN
|
||||
if not host:
|
||||
host = f"https://{settings.OSS_BUCKET_NAME}.{settings.OSS_ENDPOINT}"
|
||||
|
||||
# 确保 host 以 https:// 开头
|
||||
if not host.startswith("http"):
|
||||
host = f"https://{host}"
|
||||
|
||||
# 确保 host 不以 / 结尾
|
||||
host = host.rstrip("/")
|
||||
|
||||
# 确保 file_key 不以 / 开头
|
||||
file_key = file_key.lstrip("/")
|
||||
|
||||
return f"{host}/{file_key}"
|
||||
|
||||
|
||||
def parse_file_key_from_url(url: str) -> str:
|
||||
"""
|
||||
从完整 URL 解析出文件 key
|
||||
|
||||
Args:
|
||||
url: 完整的 OSS URL
|
||||
|
||||
Returns:
|
||||
文件 key
|
||||
"""
|
||||
host = settings.OSS_BUCKET_DOMAIN
|
||||
if not host:
|
||||
host = f"https://{settings.OSS_BUCKET_NAME}.{settings.OSS_ENDPOINT}"
|
||||
|
||||
# 移除 host 前缀
|
||||
if url.startswith(host):
|
||||
return url[len(host):].lstrip("/")
|
||||
|
||||
# 尝试其他格式
|
||||
if settings.OSS_BUCKET_NAME in url:
|
||||
# 格式: https://bucket.endpoint/key
|
||||
parts = url.split(settings.OSS_BUCKET_NAME + ".")
|
||||
if len(parts) > 1:
|
||||
key_part = parts[1].split("/", 1)
|
||||
if len(key_part) > 1:
|
||||
return key_part[1]
|
||||
|
||||
return url
|
||||
Reference in New Issue
Block a user