火山引擎SMS API|celery容灾优化|生成模型引擎积分列表API
This commit is contained in:
@@ -1,112 +1,70 @@
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.dependencies import get_db, get_current_user
|
||||
from app.dependencies import (
|
||||
get_current_user,
|
||||
get_current_user_allow_password_pending,
|
||||
get_db,
|
||||
)
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import LoginRequest, ChangePasswordRequest, RegisterRequest
|
||||
from app.schemas.auth import (
|
||||
ChangePasswordRequest,
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
SetPasswordRequest,
|
||||
SmsLoginRequest,
|
||||
)
|
||||
from app.schemas.user import UserOut
|
||||
from app.services.auth import (
|
||||
authenticate_user,
|
||||
create_access_token,
|
||||
decode_access_token,
|
||||
get_user_by_phone,
|
||||
hash_password,
|
||||
verify_password,
|
||||
)
|
||||
from app.services.sms import verify_sms_code
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
# Validate captcha in production (when SMS_MOCK is false)
|
||||
if not settings.SMS_MOCK:
|
||||
if not req.captcha_token:
|
||||
from fastapi import HTTPException, status
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="需要验证码",
|
||||
)
|
||||
token_sub = decode_access_token(req.captcha_token)
|
||||
if not token_sub or not token_sub.startswith("captcha:"):
|
||||
from fastapi import HTTPException, status
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="验证码无效或已过期",
|
||||
)
|
||||
|
||||
user = await authenticate_user(db, req.username, req.password)
|
||||
if not user:
|
||||
from fastapi import HTTPException, status
|
||||
def _validate_captcha_if_needed(captcha_token: str | None) -> None:
|
||||
# 保留原密码登录的图形验证码逻辑,不改成短信验证码。
|
||||
if settings.SMS_MOCK:
|
||||
return
|
||||
if not captcha_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户名或密码错误",
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="需要验证码",
|
||||
)
|
||||
token_sub = decode_access_token(captcha_token)
|
||||
if not token_sub or not token_sub.startswith("captcha:"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="验证码无效或已过期",
|
||||
)
|
||||
|
||||
# Only allow frontend users to login via this endpoint
|
||||
if user.user_type != "frontend":
|
||||
from fastapi import HTTPException, status
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="该账号不允许在此登录",
|
||||
)
|
||||
|
||||
user.last_login_at = datetime.now()
|
||||
def _token_response(user: User, remember_me: bool = False) -> dict:
|
||||
user.credits = round(user.credits, 2)
|
||||
await db.flush()
|
||||
|
||||
token = create_access_token(user.id, req.remember_me)
|
||||
return {"access_token": token, "token_type": "bearer", "user": UserOut.model_validate(user)}
|
||||
token = create_access_token(user.id, remember_me)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": UserOut.model_validate(user),
|
||||
"must_set_password": bool(user.user_type == "frontend" and not user.hashed_password),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Register a new user with phone + SMS code + password."""
|
||||
# Verify SMS code
|
||||
from app.services.sms import verify_sms_code
|
||||
ok = await verify_sms_code(req.phone, req.code)
|
||||
if not ok:
|
||||
from fastapi import HTTPException, status
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="验证码错误或已过期",
|
||||
)
|
||||
|
||||
# Check if phone already registered
|
||||
existing = await db.execute(select(User).where(User.phone == req.phone).limit(1))
|
||||
if existing.scalar_one_or_none():
|
||||
from fastapi import HTTPException, status
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该手机号已注册",
|
||||
)
|
||||
|
||||
# Create user with default username: 用户 + last 4 digits of phone
|
||||
import random
|
||||
username = f"用户{req.phone[-4:]}"
|
||||
existing_name = await db.execute(select(User).where(User.username == username).limit(1))
|
||||
if existing_name.scalar_one_or_none():
|
||||
username = f"用户{req.phone[-4:]}{random.randint(10, 99)}"
|
||||
|
||||
user = User(
|
||||
id=generate_id(),
|
||||
username=username,
|
||||
phone=req.phone,
|
||||
hashed_password=hash_password(req.password),
|
||||
credits=100,
|
||||
is_admin=False,
|
||||
user_type="frontend",
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
# Assign default menus to new user
|
||||
async def _assign_default_frontend_menus(db: AsyncSession, user: User) -> None:
|
||||
from app.models.menu_config import MenuConfig
|
||||
|
||||
result = await db.execute(
|
||||
select(MenuConfig).where(
|
||||
MenuConfig.is_default == True,
|
||||
@@ -119,22 +77,149 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
if default_menus:
|
||||
user.allowed_menus = [m.path for m in default_menus if m.path]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login",
|
||||
summary="客户端密码登录",
|
||||
description="保留原有用户名/手机号 + 密码登录。仅允许 frontend 用户登录;管理员仍使用 /auth/admin-login。",
|
||||
)
|
||||
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
_validate_captcha_if_needed(req.captcha_token)
|
||||
|
||||
user = await authenticate_user(db, req.username, req.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户名或密码错误",
|
||||
)
|
||||
|
||||
# Only allow frontend users to login via this endpoint
|
||||
if user.user_type != "frontend":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="该账号不允许在此登录",
|
||||
)
|
||||
|
||||
user.last_login_at = datetime.now()
|
||||
await db.flush()
|
||||
return _token_response(user, req.remember_me)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sms-login",
|
||||
summary="客户端短信验证码登录",
|
||||
description="新增兼容登录方式:手机号 + 短信验证码登录。不覆盖 /auth/login 密码登录。仅允许 frontend 用户登录。",
|
||||
)
|
||||
async def sms_login(req: SmsLoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
ok = await verify_sms_code(req.phone, req.code, "login")
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="验证码错误或已过期",
|
||||
)
|
||||
|
||||
user = await get_user_by_phone(db, req.phone)
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="账号不存在或已禁用",
|
||||
)
|
||||
if user.user_type != "frontend":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="该账号不允许在此登录",
|
||||
)
|
||||
|
||||
user.last_login_at = datetime.now()
|
||||
await db.flush()
|
||||
return _token_response(user, req.remember_me)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/register",
|
||||
summary="客户端手机号短信注册",
|
||||
description="手机号 + 注册短信验证码注册。注册成功后 username 默认等于手机号,不生成密码;前端需根据 must_set_password 引导用户设置密码。",
|
||||
)
|
||||
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
ok = await verify_sms_code(req.phone, req.code, "register")
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="验证码错误或已过期",
|
||||
)
|
||||
|
||||
existing_phone = await db.execute(select(User).where(User.phone == req.phone).limit(1))
|
||||
if existing_phone.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该手机号已注册",
|
||||
)
|
||||
|
||||
existing_username = await db.execute(select(User).where(User.username == req.phone).limit(1))
|
||||
if existing_username.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该手机号已注册",
|
||||
)
|
||||
|
||||
user = User(
|
||||
id=generate_id(),
|
||||
username=req.phone,
|
||||
phone=req.phone,
|
||||
hashed_password=None,
|
||||
password_set_at=None,
|
||||
credits=100,
|
||||
is_admin=False,
|
||||
user_type="frontend",
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
await _assign_default_frontend_menus(db, user)
|
||||
|
||||
user.credits = round(user.credits, 2)
|
||||
token = create_access_token(user.id)
|
||||
return {"access_token": token, "token_type": "bearer", "user": UserOut.model_validate(user)}
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": UserOut.model_validate(user),
|
||||
"must_set_password": True,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(current_user: User = Depends(get_current_user)):
|
||||
async def logout(current_user: User = Depends(get_current_user_allow_password_pending)):
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def get_me(current_user: User = Depends(get_current_user)):
|
||||
async def get_me(current_user: User = Depends(get_current_user_allow_password_pending)):
|
||||
current_user.credits = round(current_user.credits, 2)
|
||||
return current_user
|
||||
|
||||
|
||||
@router.post(
|
||||
"/set-password",
|
||||
summary="设置登录密码",
|
||||
description="短信注册或短信登录后,用户没有密码时调用该接口设置密码。该接口允许未设置密码用户访问。",
|
||||
)
|
||||
async def set_password(
|
||||
req: SetPasswordRequest,
|
||||
current_user: User = Depends(get_current_user_allow_password_pending),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if len(req.new_password) < 6:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="密码至少6位",
|
||||
)
|
||||
|
||||
current_user.hashed_password = hash_password(req.new_password)
|
||||
current_user.password_set_at = datetime.now()
|
||||
await db.flush()
|
||||
return {"message": "密码设置成功", "must_set_password": False}
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
req: ChangePasswordRequest,
|
||||
@@ -142,20 +227,19 @@ async def change_password(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not verify_password(req.old_password, current_user.hashed_password):
|
||||
from fastapi import HTTPException, status
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="原密码错误",
|
||||
)
|
||||
|
||||
if len(req.new_password) < 6:
|
||||
from fastapi import HTTPException, status
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="密码至少6位",
|
||||
)
|
||||
|
||||
current_user.hashed_password = hash_password(req.new_password)
|
||||
current_user.password_set_at = datetime.now()
|
||||
await db.flush()
|
||||
return {"message": "密码修改成功"}
|
||||
|
||||
@@ -183,22 +267,20 @@ async def admin_login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Admin-only login endpoint."""
|
||||
user = await authenticate_user(db, req.username, req.password)
|
||||
if not user:
|
||||
from fastapi import HTTPException, status
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户名或密码错误",
|
||||
)
|
||||
|
||||
if user.user_type != "admin":
|
||||
from fastapi import HTTPException, status
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="该账号不是管理员账号",
|
||||
)
|
||||
|
||||
user.last_login_at = datetime.now()
|
||||
user.credits = round(user.credits, 2)
|
||||
await db.flush()
|
||||
|
||||
token = create_access_token(user.id, req.remember_me)
|
||||
user.credits = round(user.credits, 2)
|
||||
return {"access_token": token, "token_type": "bearer", "user": UserOut.model_validate(user)}
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.models.video_engine import VideoEngine
|
||||
from app.models.image_engine import ImageEngine
|
||||
from app.schemas.credit import CreditBalanceOut, CreditRecordOut
|
||||
from app.schemas.credit_ratio import CreditRatioOut
|
||||
from app.services.credit_ratio_service import list_all_credit_ratios
|
||||
from app.services.credits import get_records
|
||||
|
||||
router = APIRouter(prefix="/credits", tags=["credits"])
|
||||
@@ -26,6 +27,19 @@ async def get_credits(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/credit-ratios",
|
||||
response_model=list[CreditRatioOut],
|
||||
summary="获取积分比例列表",
|
||||
description="客户端获取当前系统配置的积分计费规则列表。普通登录用户可访问,只读返回 credit_ratios 表中的图片/视频积分比例配置。",
|
||||
)
|
||||
async def list_client_credit_ratios(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await list_all_credit_ratios(db)
|
||||
|
||||
|
||||
@router.get("/ratios", response_model=dict)
|
||||
async def get_credit_ratios(
|
||||
current_user: User = Depends(get_current_user),
|
||||
|
||||
@@ -4,27 +4,45 @@ from app.config import settings
|
||||
from app.schemas.sms import SmsSendRequest, SmsVerifyRequest, SmsResponse
|
||||
from app.services.sms import generate_and_send_sms, verify_sms_code
|
||||
|
||||
router = APIRouter(prefix="/sms", tags=["sms"])
|
||||
router = APIRouter(prefix="/sms", tags=["短信验证码"])
|
||||
|
||||
|
||||
@router.post("/send", response_model=SmsResponse)
|
||||
def _validate_captcha_token(captcha_token: str | None) -> None:
|
||||
if settings.SMS_MOCK:
|
||||
return
|
||||
if not (req_captcha := captcha_token):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="需要验证码",
|
||||
)
|
||||
|
||||
from app.services.auth import decode_access_token
|
||||
|
||||
token_sub = decode_access_token(req_captcha)
|
||||
if not token_sub or not token_sub.startswith("captcha:"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="验证码无效",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/send",
|
||||
response_model=SmsResponse,
|
||||
summary="发送短信验证码",
|
||||
description="客户端发送短信验证码。scene=register 用于注册,scene=login 用于短信登录,scene=set_password 用于设置密码。正式环境会按配置校验图形验证码。",
|
||||
)
|
||||
async def send_sms_code(req: SmsSendRequest):
|
||||
"""Send SMS verification code. Requires captcha_token in production."""
|
||||
if not settings.SMS_MOCK:
|
||||
if not req.captcha_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="需要验证码",
|
||||
)
|
||||
from app.services.auth import decode_access_token
|
||||
token_sub = decode_access_token(req.captcha_token)
|
||||
if not token_sub or not token_sub.startswith("captcha:"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="验证码无效",
|
||||
)
|
||||
_validate_captcha_token(req.captcha_token)
|
||||
|
||||
try:
|
||||
ok = await generate_and_send_sms(req.phone, req.scene.value)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
ok = await generate_and_send_sms(req.phone)
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -33,10 +51,14 @@ async def send_sms_code(req: SmsSendRequest):
|
||||
return SmsResponse(message="验证码已发送", success=True)
|
||||
|
||||
|
||||
@router.post("/verify", response_model=SmsResponse)
|
||||
@router.post(
|
||||
"/verify",
|
||||
response_model=SmsResponse,
|
||||
summary="校验短信验证码",
|
||||
description="校验指定手机号、场景下的短信验证码。业务接口一般会内部校验,本接口主要用于前端调试或单独校验。",
|
||||
)
|
||||
async def verify_sms(req: SmsVerifyRequest):
|
||||
"""Verify SMS code."""
|
||||
ok = await verify_sms_code(req.phone, req.code)
|
||||
ok = await verify_sms_code(req.phone, req.code, req.scene.value)
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
|
||||
Reference in New Issue
Block a user