“单设备登录(同端互斥)”功能和后台全局用户配置

This commit is contained in:
2026-08-13 10:29:42 +08:00
parent 780769ddee
commit 437be9dac0
16 changed files with 511 additions and 237 deletions
@@ -0,0 +1,30 @@
"""单设备登录拆分设备类型 + 用户级覆盖
Revision ID: 20260813_split_device_type
Revises: 20260813_token_version
Create Date: 2026-08-13 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20260813_split_device_type'
down_revision = '20260813_token_version'
branch_labels = None
depends_on = None
def upgrade():
# 拆分为按设备类型管理
op.add_column('users', sa.Column('pc_token_version', sa.Integer(), nullable=False, server_default='0'))
op.add_column('users', sa.Column('mobile_token_version', sa.Integer(), nullable=False, server_default='0'))
op.drop_column('users', 'token_version')
# 用户级覆盖:None=跟随全局, True=强制启用, False=强制禁用
op.add_column('users', sa.Column('single_device_login_override', sa.Boolean(), nullable=True))
def downgrade():
op.add_column('users', sa.Column('token_version', sa.Integer(), nullable=False, server_default='0'))
op.drop_column('users', 'single_device_login_override')
op.drop_column('users', 'mobile_token_version')
op.drop_column('users', 'pc_token_version')
+39
View File
@@ -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,
+44 -18
View File
@@ -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)}
+24 -6
View File
@@ -7,6 +7,7 @@ from app.models.base import async_session
from app.models.user import User
from app.services.auth import decode_access_token, user_must_set_password
from app.services.credit.query_service import attach_credit_snapshot, get_available_credits
from app.services.system_config_cache import get_system_config_value
security = HTTPBearer(auto_error=False)
@@ -42,6 +43,7 @@ async def get_current_user_allow_password_pending(
user_id = payload.get("sub")
token_version = payload.get("ver", 0)
device_type = payload.get("dev", "pc")
# Skip captcha tokens
if user_id and user_id.startswith("captcha:"):
@@ -58,12 +60,28 @@ async def get_current_user_allow_password_pending(
detail="账号不存在或已禁用",
)
# 单设备登录校验 — token 版本号不匹配说明已被踢出
if token_version != user.token_version:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="账号已在其他设备登录,请重新登录",
)
# 单设备登录校验 — 根据全局开关 + 用户级覆盖决定是否启用
override = getattr(user, "single_device_login_override", None)
if override is True:
enabled = True
elif override is False:
enabled = False
else:
# 跟随全局设置
config_val = await get_system_config_value(db, "single_device_login_enabled")
enabled = config_val is not None and config_val.lower() in ("true", "1", "yes")
if enabled:
# 按设备类型比对对应版本号
if device_type == "mobile":
current_version = user.mobile_token_version
else:
current_version = user.pc_token_version
if token_version != current_version:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="账号已在其他设备登录,请重新登录",
)
attach_credit_snapshot(user, await get_available_credits(db, user.id))
return user
+10 -2
View File
@@ -38,10 +38,18 @@ class User(Base, TimestampMixin):
Integer, default=50, server_default="50", nullable=False
)
# Token 版本号 — 每次登录/退出时递增,用于实现单设备登录(全局互斥)
token_version: Mapped[int] = mapped_column(
# 按设备类型分别管理 Token 版本号 — 单设备登录(同端互斥)
pc_token_version: Mapped[int] = mapped_column(
Integer, default=0, server_default="0", nullable=False
)
mobile_token_version: Mapped[int] = mapped_column(
Integer, default=0, server_default="0", nullable=False
)
# 单设备登录用户级覆盖:None=跟随全局, True=强制启用, False=强制禁用
single_device_login_override: Mapped[bool | None] = mapped_column(
Boolean, nullable=True
)
@property
def credits(self) -> float:
+1
View File
@@ -66,6 +66,7 @@ class AdminUserOut(BaseModel):
allowed_menus: list | None = None
resource_capacity: ResourceCapacityUsageOut | None = None
private_portrait_asset_limit: int = 50
single_device_login_override: bool | None = None
model_config = {"from_attributes": True}
+7 -2
View File
@@ -22,10 +22,15 @@ def verify_password(plain: str, hashed: str | None) -> bool:
return False
def create_access_token(user_id: str, remember_me: bool = False, token_version: int = 0) -> str:
def create_access_token(
user_id: str,
remember_me: bool = False,
token_version: int = 0,
device_type: str = "pc",
) -> str:
minutes = settings.JWT_EXPIRE_REMEMBER_MINUTES if remember_me else settings.JWT_EXPIRE_MINUTES
expire = datetime.now(timezone.utc) + timedelta(minutes=minutes)
payload = {"sub": user_id, "exp": expire, "ver": token_version}
payload = {"sub": user_id, "exp": expire, "ver": token_version, "dev": device_type}
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
+16
View File
@@ -0,0 +1,16 @@
"""设备类型检测工具。"""
def detect_device_type(user_agent: str | None) -> str:
"""根据 User-Agent 判断设备类型:'pc''mobile'
返回 'mobile' 表示手机/平板等移动设备,返回 'pc' 表示桌面设备或无法识别。
"""
if not user_agent:
return "pc"
ua = user_agent.lower()
mobile_keywords = [
"mobile", "android", "iphone", "ipad", "ipod",
"windows phone", "blackberry", "opera mini", "opera mobi",
]
return "mobile" if any(kw in ua for kw in mobile_keywords) else "pc"