“单设备登录(同端互斥)”功能和后台全局用户配置
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>
|
||||||
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user