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

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
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
} }
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-DDKKooWp.js"></script> <script type="module" crossorigin src="/assets/index-DyfcSj-n.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D3fwIbOp.css"> <link rel="stylesheet" crossorigin href="/assets/index-D3fwIbOp.css">
</head> </head>
<body> <body>
+4
View File
@@ -216,6 +216,10 @@ export async function toggleUserStatus(userId: string, isActive: boolean): Promi
await api.put(`/admin/users/${userId}/status`, { is_active: isActive }); await api.put(`/admin/users/${userId}/status`, { is_active: isActive });
} }
export async function updateSingleDeviceLoginOverride(userId: string, override: boolean | null): Promise<void> {
await api.put(`/admin/users/${userId}/single-device-login-override`, { override });
}
export async function getModelConfigs(): Promise<ModelConfig[]> { export async function getModelConfigs(): Promise<ModelConfig[]> {
return api.get('/admin/model-configs'); return api.get('/admin/model-configs');
} }
@@ -45,6 +45,10 @@ const AdminSettings: React.FC = () => {
if (!data.some(c => c.key === 'llm_media_as_base64')) { if (!data.some(c => c.key === 'llm_media_as_base64')) {
data.push({ id: 'cfg_llm_media_as_base64', key: 'llm_media_as_base64', value: 'true', description: '文字模型请求时图片/视频使用 base64 编码' }); data.push({ id: 'cfg_llm_media_as_base64', key: 'llm_media_as_base64', value: 'true', description: '文字模型请求时图片/视频使用 base64 编码' });
} }
// 确保 single_device_login_enabled 配置存在
if (!data.some(c => c.key === 'single_device_login_enabled')) {
data.push({ id: 'cfg_single_device_login_enabled', key: 'single_device_login_enabled', value: 'false', description: '启用单设备登录(同端互斥):同一设备类型只允许一个登录会话' });
}
setConfigs(data); setConfigs(data);
const formValues: Record<string, any> = {}; const formValues: Record<string, any> = {};
data.forEach(c => { formValues[c.key] = c.value; }); data.forEach(c => { formValues[c.key] = c.value; });
@@ -154,6 +158,26 @@ const AdminSettings: React.FC = () => {
message.success('已移除登录背景视频'); message.success('已移除登录背景视频');
}; };
const handleToggleSingleDevice = async (checked: boolean) => {
try {
let config = configs.find(c => c.key === 'single_device_login_enabled');
if (config && config.id && !config.id.startsWith('cfg_')) {
await updateSystemConfig(config.id, checked ? 'true' : 'false');
} else {
const res = await createSystemConfig('single_device_login_enabled', checked ? 'true' : 'false', '启用单设备登录(同端互斥):同一设备类型只允许一个登录会话');
config = res;
}
setConfigs(prev => {
const exists = prev.some(c => c.key === 'single_device_login_enabled');
if (exists) return prev.map(c => c.key === 'single_device_login_enabled' ? { ...c, value: checked ? 'true' : 'false', id: config!.id } : c);
return [...prev, config!];
});
message.success(`${checked ? '开启' : '关闭'}单设备登录限制`);
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const handleToggleBase64 = async (checked: boolean) => { const handleToggleBase64 = async (checked: boolean) => {
try { try {
let config = configs.find(c => c.key === 'llm_media_as_base64'); let config = configs.find(c => c.key === 'llm_media_as_base64');
@@ -493,6 +517,32 @@ const AdminSettings: React.FC = () => {
</div> </div>
</div> </div>
</div> </div>
{/* 单设备登录限制 */}
<div style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
</Typography.Text>
<div style={{ padding: 16, borderRadius: 10, border: '1px solid #f0f0f5', background: '#fafbfc' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Space>
<RobotOutlined style={{ color: '#6366f1', fontSize: 18 }} />
<div>
<Typography.Text strong></Typography.Text>
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
</div>
</div>
</Space>
<Switch
checked={(configs.find(c => c.key === 'single_device_login_enabled') || {}).value === 'true'}
onChange={handleToggleSingleDevice}
checkedChildren="已启用"
unCheckedChildren="已禁用"
/>
</div>
</div>
</div>
</Form> </Form>
), ),
}, },
+78 -2
View File
@@ -1,9 +1,9 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography, Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Radio, Select, Space, Switch, Table, Tabs, Tag, Typography,
} from 'antd'; } from 'antd';
import { import {
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MinusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, SecurityScanOutlined, UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MinusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, SecurityScanOutlined, SafetyOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { import {
adminDeductCredits, adminDeductCredits,
@@ -23,6 +23,7 @@ import {
saveUserResourceCapacity, saveUserResourceCapacity,
toggleUserStatus, toggleUserStatus,
updateFrontendUserKind, updateFrontendUserKind,
updateSingleDeviceLoginOverride,
updateUserTeam, updateUserTeam,
updateSystemConfig, updateSystemConfig,
updateUserMenus, updateUserMenus,
@@ -84,6 +85,10 @@ const AdminUsers: React.FC = () => {
const [capacityModal, setCapacityModal] = useState<{ open: boolean; user: AdminUser | null; detail: AdminUserResourceCapacityOut | null }>({ open: false, user: null, detail: null }); const [capacityModal, setCapacityModal] = useState<{ open: boolean; user: AdminUser | null; detail: AdminUserResourceCapacityOut | null }>({ open: false, user: null, detail: null });
const [teamModal, setTeamModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null }); const [teamModal, setTeamModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [portraitModal, setPortraitModal] = useState<{ open: boolean; user: AdminUser | null; config: PrivatePortraitConfig | null }>({ open: false, user: null, config: null }); const [portraitModal, setPortraitModal] = useState<{ open: boolean; user: AdminUser | null; config: PrivatePortraitConfig | null }>({ open: false, user: null, config: null });
const [singleDeviceModal, setSingleDeviceModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [singleDeviceValue, setSingleDeviceValue] = useState<boolean | null>(null);
const [singleDeviceSaving, setSingleDeviceSaving] = useState(false);
const [globalSingleDeviceEnabled, setGlobalSingleDeviceEnabled] = useState(false);
const [capacityLoading, setCapacityLoading] = useState(false); const [capacityLoading, setCapacityLoading] = useState(false);
const [capacitySaving, setCapacitySaving] = useState(false); const [capacitySaving, setCapacitySaving] = useState(false);
const [teamSaving, setTeamSaving] = useState(false); const [teamSaving, setTeamSaving] = useState(false);
@@ -136,6 +141,9 @@ const AdminUsers: React.FC = () => {
const formValues: Record<string, string> = {}; const formValues: Record<string, string> = {};
credit.forEach(c => { formValues[c.key] = c.value; }); credit.forEach(c => { formValues[c.key] = c.value; });
configForm.setFieldsValue(formValues); configForm.setFieldsValue(formValues);
// 获取全局单设备登录开关状态
const singleDeviceConfig = configs.find(c => c.key === 'single_device_login_enabled');
setGlobalSingleDeviceEnabled(singleDeviceConfig?.value === 'true');
} catch { /* auth error handled by client */ } } catch { /* auth error handled by client */ }
}; };
loadCreditConfigs(); loadCreditConfigs();
@@ -431,6 +439,20 @@ const AdminUsers: React.FC = () => {
} }
}; };
const handleUpdateSingleDeviceOverride = async (user: AdminUser, value: boolean | null) => {
try {
setSingleDeviceSaving(true);
await updateSingleDeviceLoginOverride(user.id, value);
message.success('已更新单设备登录设置');
setSingleDeviceModal({ open: false, user: null });
load();
} catch (e: any) {
message.error(e?.message || '设置失败');
} finally {
setSingleDeviceSaving(false);
}
};
const isAdminTab = activeTab === 'admin'; const isAdminTab = activeTab === 'admin';
const columns = [ const columns = [
@@ -591,6 +613,15 @@ const AdminUsers: React.FC = () => {
onClick={() => { setResetPwdModal({ open: true, user: r }); resetPwdForm.resetFields(); }}> onClick={() => { setResetPwdModal({ open: true, user: r }); resetPwdForm.resetFields(); }}>
</Button> </Button>
{!isAdminTab && (
<Button type="link" size="small" icon={<SafetyOutlined />}
onClick={() => {
setSingleDeviceValue(r.singleDeviceLoginOverride ?? null);
setSingleDeviceModal({ open: true, user: r });
}}>
</Button>
)}
<Popconfirm <Popconfirm
title={r.isActive ? '确定禁用该用户?' : '确定启用该用户?'} title={r.isActive ? '确定禁用该用户?' : '确定启用该用户?'}
onConfirm={() => handleToggleStatus(r)} onConfirm={() => handleToggleStatus(r)}
@@ -1084,6 +1115,51 @@ const AdminUsers: React.FC = () => {
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
<Modal
title={<Space><SafetyOutlined /> - {singleDeviceModal.user?.username}</Space>}
open={singleDeviceModal.open}
onOk={() => {
if (singleDeviceModal.user) {
handleUpdateSingleDeviceOverride(singleDeviceModal.user, singleDeviceValue);
}
}}
onCancel={() => setSingleDeviceModal({ open: false, user: null })}
okText="保存" cancelText="取消" width={420}
confirmLoading={singleDeviceSaving}
>
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
<Tag color={globalSingleDeviceEnabled ? 'green' : 'default'}>{globalSingleDeviceEnabled ? '已开启' : '已关闭'}</Tag>
</Typography.Text>
</div>
<Radio.Group
value={singleDeviceValue}
onChange={e => setSingleDeviceValue(e.target.value)}
style={{ width: '100%' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
<Radio value={null}>
<Typography.Text></Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
{globalSingleDeviceEnabled ? '当前受单设备登录限制' : '当前不受限制'}
</Typography.Text>
</Radio>
<Radio value={true}>
<Typography.Text></Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
</Typography.Text>
</Radio>
<Radio value={false}>
<Typography.Text></Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
</Typography.Text>
</Radio>
</Space>
</Radio.Group>
</Modal>
</div> </div>
); );
}; };
+1
View File
@@ -197,6 +197,7 @@ export interface AdminUser {
allowedMenus?: string[] | null; allowedMenus?: string[] | null;
resourceCapacity?: ResourceCapacityUsage | null; resourceCapacity?: ResourceCapacityUsage | null;
privatePortraitAssetLimit: number; privatePortraitAssetLimit: number;
singleDeviceLoginOverride?: boolean | null;
} }
export interface DailyCredit { export interface DailyCredit {
@@ -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"} 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) @router.put("/users/{user_id}/frontend-kind", response_model=AdminUserOut)
async def update_user_frontend_kind( async def update_user_frontend_kind(
user_id: str, user_id: str,
+43 -17
View File
@@ -2,7 +2,7 @@ from datetime import datetime, timezone, timedelta
CST = timezone(timedelta(hours=8)) 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 import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,7 +11,9 @@ from app.dependencies import (
get_current_user, get_current_user,
get_current_user_allow_password_pending, get_current_user_allow_password_pending,
get_db, get_db,
security,
) )
from fastapi.security import HTTPAuthorizationCredentials
from app.models.system_config import SystemConfig from app.models.system_config import SystemConfig
from app.models.user import User from app.models.user import User
from app.schemas.auth import ( from app.schemas.auth import (
@@ -31,6 +33,7 @@ from app.services.auth import (
hash_password, hash_password,
verify_password, verify_password,
) )
from app.utils.device import detect_device_type
from app.services.sms import verify_sms_code from app.services.sms import verify_sms_code
from app.services.resource_capacity_service import get_user_resource_capacity_usage from app.services.resource_capacity_service import get_user_resource_capacity_usage
from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel 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) user.credits = round(user.credits, 2)
# 递增 token_version — 单设备登录(全局互斥),使旧 token 全部失效 # 按设备类型递增对应版本号 — 单设备登录(同端互斥)
user.token_version = (user.token_version or 0) + 1 if device_type == "mobile":
token = create_access_token(user.id, remember_me, user.token_version) 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 { return {
"access_token": token, "access_token": token,
"token_type": "bearer", "token_type": "bearer",
@@ -156,7 +164,7 @@ async def _handle_daily_login_credits(db: AsyncSession, user: User) -> None:
summary="客户端密码登录", summary="客户端密码登录",
description="保留原有用户名/手机号 + 密码登录。仅允许 frontend 用户登录;管理员仍使用 /auth/admin-login。", 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) _validate_captcha_if_needed(req.captcha_token)
user = await authenticate_user(db, req.username, req.password) 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) await _handle_daily_login_credits(db, user)
user.last_login_at = datetime.now(CST) user.last_login_at = datetime.now(CST)
await db.flush() 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( @router.post(
@@ -184,7 +193,7 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
summary="客户端短信验证码登录", summary="客户端短信验证码登录",
description="新增兼容登录方式:手机号 + 短信验证码登录。不覆盖 /auth/login 密码登录。仅允许 frontend 用户登录。", 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") ok = await verify_sms_code(req.phone, req.code, "login")
if not ok: if not ok:
raise HTTPException( raise HTTPException(
@@ -207,7 +216,8 @@ async def sms_login(req: SmsLoginRequest, db: AsyncSession = Depends(get_db)):
await _handle_daily_login_credits(db, user) await _handle_daily_login_credits(db, user)
user.last_login_at = datetime.now(CST) user.last_login_at = datetime.now(CST)
await db.flush() 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( @router.post(
@@ -215,7 +225,7 @@ async def sms_login(req: SmsLoginRequest, db: AsyncSession = Depends(get_db)):
summary="客户端手机号短信注册", summary="客户端手机号短信注册",
description="手机号 + 注册短信验证码注册。注册成功后 username 默认等于手机号,不生成密码;前端需根据 must_set_password 引导用户设置密码。", 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") ok = await verify_sms_code(req.phone, req.code, "register")
if not ok: if not ok:
raise HTTPException( raise HTTPException(
@@ -248,9 +258,15 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
await _assign_default_frontend_menus(db, user) await _assign_default_frontend_menus(db, user)
user.credits = round(user.credits, 2) user.credits = round(user.credits, 2)
# 递增 token_version — 单设备登录 # 按设备类型递增对应版本号
user.token_version = (user.token_version or 0) + 1 device_type = detect_device_type(request.headers.get("user-agent"))
token = create_access_token(user.id, False, 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, False, version, device_type)
return { return {
"access_token": token, "access_token": token,
"token_type": "bearer", "token_type": "bearer",
@@ -261,11 +277,20 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
@router.post("/logout") @router.post("/logout")
async def logout( async def logout(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
current_user: User = Depends(get_current_user_allow_password_pending), current_user: User = Depends(get_current_user_allow_password_pending),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
# 递增 token_version — 使当前 token 失效,实现主动退出后不可再用 # 按设备类型递增对应版本号 — 使当前 token 失效
current_user.token_version = (current_user.token_version or 0) + 1 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() await db.flush()
return {"message": "ok"} return {"message": "ok"}
@@ -392,7 +417,7 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
@router.post("/admin-login") @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.""" """Admin-only login endpoint."""
user = await authenticate_user(db, req.username, req.password) user = await authenticate_user(db, req.username, req.password)
if not user: 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) user.last_login_at = datetime.now(CST)
await db.flush() 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) user.credits = round(user.credits, 2)
return {"access_token": token, "token_type": "bearer", "user": UserOut.model_validate(user)} 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.models.user import User
from app.services.auth import decode_access_token, user_must_set_password 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.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) security = HTTPBearer(auto_error=False)
@@ -42,6 +43,7 @@ async def get_current_user_allow_password_pending(
user_id = payload.get("sub") user_id = payload.get("sub")
token_version = payload.get("ver", 0) token_version = payload.get("ver", 0)
device_type = payload.get("dev", "pc")
# Skip captcha tokens # Skip captcha tokens
if user_id and user_id.startswith("captcha:"): if user_id and user_id.startswith("captcha:"):
@@ -58,12 +60,28 @@ async def get_current_user_allow_password_pending(
detail="账号不存在或已禁用", detail="账号不存在或已禁用",
) )
# 单设备登录校验 — token 版本号不匹配说明已被踢出 # 单设备登录校验 — 根据全局开关 + 用户级覆盖决定是否启用
if token_version != user.token_version: override = getattr(user, "single_device_login_override", None)
raise HTTPException( if override is True:
status_code=status.HTTP_401_UNAUTHORIZED, enabled = True
detail="账号已在其他设备登录,请重新登录", 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)) attach_credit_snapshot(user, await get_available_credits(db, user.id))
return user return user
+10 -2
View File
@@ -38,10 +38,18 @@ class User(Base, TimestampMixin):
Integer, default=50, server_default="50", nullable=False Integer, default=50, server_default="50", nullable=False
) )
# Token 版本号 — 每次登录/退出时递增,用于实现单设备登录(全局互斥) # 按设备类型分别管理 Token 版本号 — 单设备登录(同端互斥)
token_version: Mapped[int] = mapped_column( pc_token_version: Mapped[int] = mapped_column(
Integer, default=0, server_default="0", nullable=False 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 @property
def credits(self) -> float: def credits(self) -> float:
+1
View File
@@ -66,6 +66,7 @@ class AdminUserOut(BaseModel):
allowed_menus: list | None = None allowed_menus: list | None = None
resource_capacity: ResourceCapacityUsageOut | None = None resource_capacity: ResourceCapacityUsageOut | None = None
private_portrait_asset_limit: int = 50 private_portrait_asset_limit: int = 50
single_device_login_override: bool | None = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
+7 -2
View File
@@ -22,10 +22,15 @@ def verify_password(plain: str, hashed: str | None) -> bool:
return False 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 minutes = settings.JWT_EXPIRE_REMEMBER_MINUTES if remember_me else settings.JWT_EXPIRE_MINUTES
expire = datetime.now(timezone.utc) + timedelta(minutes=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) 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"
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -27,7 +27,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>民众智创</title> <title>民众智创</title>
<script type="module" crossorigin src="/assets/index-CGl8RZCw.js"></script> <script type="module" crossorigin src="/assets/index-BcfRvOz8.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DSYnuUvx.css"> <link rel="stylesheet" crossorigin href="/assets/index-DSYnuUvx.css">
</head> </head>
<body> <body>