“单设备登录(同端互斥)”功能和后台全局用户配置
This commit is contained in:
Vendored
+134
-134
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
||||
}
|
||||
})();
|
||||
</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">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -216,6 +216,10 @@ export async function toggleUserStatus(userId: string, isActive: boolean): Promi
|
||||
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[]> {
|
||||
return api.get('/admin/model-configs');
|
||||
}
|
||||
|
||||
@@ -45,6 +45,10 @@ const AdminSettings: React.FC = () => {
|
||||
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 编码' });
|
||||
}
|
||||
// 确保 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);
|
||||
const formValues: Record<string, any> = {};
|
||||
data.forEach(c => { formValues[c.key] = c.value; });
|
||||
@@ -154,6 +158,26 @@ const AdminSettings: React.FC = () => {
|
||||
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) => {
|
||||
try {
|
||||
let config = configs.find(c => c.key === 'llm_media_as_base64');
|
||||
@@ -493,6 +517,32 @@ const AdminSettings: React.FC = () => {
|
||||
</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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
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';
|
||||
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';
|
||||
import {
|
||||
adminDeductCredits,
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
saveUserResourceCapacity,
|
||||
toggleUserStatus,
|
||||
updateFrontendUserKind,
|
||||
updateSingleDeviceLoginOverride,
|
||||
updateUserTeam,
|
||||
updateSystemConfig,
|
||||
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 [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 [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 [capacitySaving, setCapacitySaving] = useState(false);
|
||||
const [teamSaving, setTeamSaving] = useState(false);
|
||||
@@ -136,6 +141,9 @@ const AdminUsers: React.FC = () => {
|
||||
const formValues: Record<string, string> = {};
|
||||
credit.forEach(c => { formValues[c.key] = c.value; });
|
||||
configForm.setFieldsValue(formValues);
|
||||
// 获取全局单设备登录开关状态
|
||||
const singleDeviceConfig = configs.find(c => c.key === 'single_device_login_enabled');
|
||||
setGlobalSingleDeviceEnabled(singleDeviceConfig?.value === 'true');
|
||||
} catch { /* auth error handled by client */ }
|
||||
};
|
||||
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 columns = [
|
||||
@@ -591,6 +613,15 @@ const AdminUsers: React.FC = () => {
|
||||
onClick={() => { setResetPwdModal({ open: true, user: r }); resetPwdForm.resetFields(); }}>
|
||||
重置密码
|
||||
</Button>
|
||||
{!isAdminTab && (
|
||||
<Button type="link" size="small" icon={<SafetyOutlined />}
|
||||
onClick={() => {
|
||||
setSingleDeviceValue(r.singleDeviceLoginOverride ?? null);
|
||||
setSingleDeviceModal({ open: true, user: r });
|
||||
}}>
|
||||
单设备登录
|
||||
</Button>
|
||||
)}
|
||||
<Popconfirm
|
||||
title={r.isActive ? '确定禁用该用户?' : '确定启用该用户?'}
|
||||
onConfirm={() => handleToggleStatus(r)}
|
||||
@@ -1084,6 +1115,51 @@ const AdminUsers: React.FC = () => {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -197,6 +197,7 @@ export interface AdminUser {
|
||||
allowedMenus?: string[] | null;
|
||||
resourceCapacity?: ResourceCapacityUsage | null;
|
||||
privatePortraitAssetLimit: number;
|
||||
singleDeviceLoginOverride?: boolean | null;
|
||||
}
|
||||
|
||||
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')
|
||||
@@ -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(
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
+2
-2
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -27,7 +27,7 @@
|
||||
<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" />
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user