“单设备登录(同端互斥)”功能和后台全局用户配置
This commit is contained in:
@@ -399,6 +399,45 @@ async def update_user_admin_status(
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/single-device-login-override")
|
||||
async def update_single_device_login_override(
|
||||
user_id: str,
|
||||
body: dict,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""设置用户级单设备登录覆盖。
|
||||
|
||||
body.override: True=强制启用, False=强制禁用, None=跟随全局
|
||||
"""
|
||||
override = body.get("override")
|
||||
if override is not None:
|
||||
override = bool(override)
|
||||
# None 表示跟随全局
|
||||
await db.execute(
|
||||
update(User).where(User.id == user_id).values(single_device_login_override=override)
|
||||
)
|
||||
await db.flush()
|
||||
label = "跟随全局" if override is None else ("强制启用" if override else "强制禁用")
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"单设备登录设置: {label}",
|
||||
"PUT",
|
||||
f"/admin/users/{user_id}/single-device-login-override",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"user_id": user_id,
|
||||
"override": override,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/frontend-kind", response_model=AdminUserOut)
|
||||
async def update_user_frontend_kind(
|
||||
user_id: str,
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import datetime, timezone, timedelta
|
||||
|
||||
CST = timezone(timedelta(hours=8))
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -11,7 +11,9 @@ from app.dependencies import (
|
||||
get_current_user,
|
||||
get_current_user_allow_password_pending,
|
||||
get_db,
|
||||
security,
|
||||
)
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import (
|
||||
@@ -31,6 +33,7 @@ from app.services.auth import (
|
||||
hash_password,
|
||||
verify_password,
|
||||
)
|
||||
from app.utils.device import detect_device_type
|
||||
from app.services.sms import verify_sms_code
|
||||
from app.services.resource_capacity_service import get_user_resource_capacity_usage
|
||||
from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel
|
||||
@@ -59,11 +62,16 @@ def _validate_captcha_if_needed(captcha_token: str | None) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _token_response(user: User, remember_me: bool = False) -> dict:
|
||||
def _token_response(user: User, remember_me: bool = False, device_type: str = "pc") -> dict:
|
||||
user.credits = round(user.credits, 2)
|
||||
# 递增 token_version — 单设备登录(全局互斥),使旧 token 全部失效
|
||||
user.token_version = (user.token_version or 0) + 1
|
||||
token = create_access_token(user.id, remember_me, user.token_version)
|
||||
# 按设备类型递增对应版本号 — 单设备登录(同端互斥)
|
||||
if device_type == "mobile":
|
||||
user.mobile_token_version = (user.mobile_token_version or 0) + 1
|
||||
version = user.mobile_token_version
|
||||
else:
|
||||
user.pc_token_version = (user.pc_token_version or 0) + 1
|
||||
version = user.pc_token_version
|
||||
token = create_access_token(user.id, remember_me, version, device_type)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
@@ -156,7 +164,7 @@ async def _handle_daily_login_credits(db: AsyncSession, user: User) -> None:
|
||||
summary="客户端密码登录",
|
||||
description="保留原有用户名/手机号 + 密码登录。仅允许 frontend 用户登录;管理员仍使用 /auth/admin-login。",
|
||||
)
|
||||
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
async def login(req: LoginRequest, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
_validate_captcha_if_needed(req.captcha_token)
|
||||
|
||||
user = await authenticate_user(db, req.username, req.password)
|
||||
@@ -176,7 +184,8 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
await _handle_daily_login_credits(db, user)
|
||||
user.last_login_at = datetime.now(CST)
|
||||
await db.flush()
|
||||
return _token_response(user, req.remember_me)
|
||||
device_type = detect_device_type(request.headers.get("user-agent"))
|
||||
return _token_response(user, req.remember_me, device_type)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -184,7 +193,7 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
summary="客户端短信验证码登录",
|
||||
description="新增兼容登录方式:手机号 + 短信验证码登录。不覆盖 /auth/login 密码登录。仅允许 frontend 用户登录。",
|
||||
)
|
||||
async def sms_login(req: SmsLoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
async def sms_login(req: SmsLoginRequest, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
ok = await verify_sms_code(req.phone, req.code, "login")
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
@@ -207,7 +216,8 @@ async def sms_login(req: SmsLoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
await _handle_daily_login_credits(db, user)
|
||||
user.last_login_at = datetime.now(CST)
|
||||
await db.flush()
|
||||
return _token_response(user, req.remember_me)
|
||||
device_type = detect_device_type(request.headers.get("user-agent"))
|
||||
return _token_response(user, req.remember_me, device_type)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -215,7 +225,7 @@ async def sms_login(req: SmsLoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
summary="客户端手机号短信注册",
|
||||
description="手机号 + 注册短信验证码注册。注册成功后 username 默认等于手机号,不生成密码;前端需根据 must_set_password 引导用户设置密码。",
|
||||
)
|
||||
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
async def register(req: RegisterRequest, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
ok = await verify_sms_code(req.phone, req.code, "register")
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
@@ -231,7 +241,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
)
|
||||
|
||||
register_credits = await _get_register_credits(db)
|
||||
|
||||
|
||||
user = User(
|
||||
id=generate_id(),
|
||||
username=req.phone,
|
||||
@@ -248,9 +258,15 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
await _assign_default_frontend_menus(db, user)
|
||||
|
||||
user.credits = round(user.credits, 2)
|
||||
# 递增 token_version — 单设备登录
|
||||
user.token_version = (user.token_version or 0) + 1
|
||||
token = create_access_token(user.id, False, user.token_version)
|
||||
# 按设备类型递增对应版本号
|
||||
device_type = detect_device_type(request.headers.get("user-agent"))
|
||||
if device_type == "mobile":
|
||||
user.mobile_token_version = (user.mobile_token_version or 0) + 1
|
||||
version = user.mobile_token_version
|
||||
else:
|
||||
user.pc_token_version = (user.pc_token_version or 0) + 1
|
||||
version = user.pc_token_version
|
||||
token = create_access_token(user.id, False, version, device_type)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
@@ -261,11 +277,20 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
current_user: User = Depends(get_current_user_allow_password_pending),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# 递增 token_version — 使当前 token 失效,实现主动退出后不可再用
|
||||
current_user.token_version = (current_user.token_version or 0) + 1
|
||||
# 按设备类型递增对应版本号 — 使当前 token 失效
|
||||
device_type = "pc"
|
||||
if credentials:
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
if payload:
|
||||
device_type = payload.get("dev", "pc")
|
||||
if device_type == "mobile":
|
||||
current_user.mobile_token_version = (current_user.mobile_token_version or 0) + 1
|
||||
else:
|
||||
current_user.pc_token_version = (current_user.pc_token_version or 0) + 1
|
||||
await db.flush()
|
||||
return {"message": "ok"}
|
||||
|
||||
@@ -392,7 +417,7 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/admin-login")
|
||||
async def admin_login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
async def admin_login(req: LoginRequest, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
"""Admin-only login endpoint."""
|
||||
user = await authenticate_user(db, req.username, req.password)
|
||||
if not user:
|
||||
@@ -410,6 +435,7 @@ async def admin_login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
user.last_login_at = datetime.now(CST)
|
||||
await db.flush()
|
||||
|
||||
token = create_access_token(user.id, req.remember_me)
|
||||
device_type = detect_device_type(request.headers.get("user-agent"))
|
||||
token = create_access_token(user.id, req.remember_me, 0, device_type)
|
||||
user.credits = round(user.credits, 2)
|
||||
return {"access_token": token, "token_type": "bearer", "user": UserOut.model_validate(user)}
|
||||
|
||||
Reference in New Issue
Block a user