From 58e061c9e78c43a91fab79c45ed5ac663313b14a Mon Sep 17 00:00:00 2001 From: wwwwwwwww <526125649@qq.com> Date: Thu, 13 Aug 2026 09:37:39 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=8D=E5=8F=B0=E7=99=BB=E9=99=86=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E4=BF=AE=E6=94=B9=EF=BC=8C=E8=AF=B7=E5=B8=AE=E6=88=91?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E2=80=9C=E5=8D=95=E8=AE=BE=E5=A4=87=E7=99=BB?= =?UTF-8?q?=E5=BD=95=EF=BC=88=E5=90=8C=E7=AB=AF=E4=BA=92=E6=96=A5=EF=BC=89?= =?UTF-8?q?=E2=80=9D=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260813_增加用户token_version字段.py | 22 ++++++++++ video-gen-api/app/api/v1/auth.py | 16 +++++-- video-gen-api/app/dependencies.py | 17 ++++++-- video-gen-api/app/models/user.py | 5 +++ video-gen-api/app/services/auth.py | 10 ++--- video-gen-app/src/api/client.ts | 4 ++ .../src/components/KickedOutModal.tsx | 43 +++++++++++++++++++ .../src/components/Layout/AppLayout.tsx | 10 +++++ 8 files changed, 116 insertions(+), 11 deletions(-) create mode 100644 video-gen-api/alembic/versions/20260813_增加用户token_version字段.py create mode 100644 video-gen-app/src/components/KickedOutModal.tsx diff --git a/video-gen-api/alembic/versions/20260813_增加用户token_version字段.py b/video-gen-api/alembic/versions/20260813_增加用户token_version字段.py new file mode 100644 index 00000000..3c1c9f63 --- /dev/null +++ b/video-gen-api/alembic/versions/20260813_增加用户token_version字段.py @@ -0,0 +1,22 @@ +"""增加用户token_version字段 — 单设备登录 + +Revision ID: 20260813_token_version +Revises:b7e2c4d91a63 +Create Date: 2026-08-13 10:00:00.000000 +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '20260813_token_version' +down_revision = 'b7e2c4d91a63' # 设置为上一个迁移的 revision +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('users', sa.Column('token_version', sa.Integer(), nullable=False, server_default='0')) + + +def downgrade(): + op.drop_column('users', 'token_version') diff --git a/video-gen-api/app/api/v1/auth.py b/video-gen-api/app/api/v1/auth.py index c3052fa5..dfb7064e 100644 --- a/video-gen-api/app/api/v1/auth.py +++ b/video-gen-api/app/api/v1/auth.py @@ -58,7 +58,9 @@ def _validate_captcha_if_needed(captcha_token: str | None) -> None: def _token_response(user: User, remember_me: bool = False) -> dict: user.credits = round(user.credits, 2) - token = create_access_token(user.id, remember_me) + # 递增 token_version — 单设备登录(全局互斥),使旧 token 全部失效 + user.token_version = (user.token_version or 0) + 1 + token = create_access_token(user.id, remember_me, user.token_version) return { "access_token": token, "token_type": "bearer", @@ -237,7 +239,9 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): await _assign_default_frontend_menus(db, user) user.credits = round(user.credits, 2) - token = create_access_token(user.id) + # 递增 token_version — 单设备登录 + user.token_version = (user.token_version or 0) + 1 + token = create_access_token(user.id, False, user.token_version) return { "access_token": token, "token_type": "bearer", @@ -247,7 +251,13 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): @router.post("/logout") -async def logout(current_user: User = Depends(get_current_user_allow_password_pending)): +async def logout( + 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 + await db.flush() return {"message": "ok"} diff --git a/video-gen-api/app/dependencies.py b/video-gen-api/app/dependencies.py index 7299a4a5..bca20b80 100644 --- a/video-gen-api/app/dependencies.py +++ b/video-gen-api/app/dependencies.py @@ -32,15 +32,18 @@ async def get_current_user_allow_password_pending( detail="未登录", ) - user_id = decode_access_token(credentials.credentials) - if not user_id: + payload = decode_access_token(credentials.credentials) + if not payload: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期", ) + user_id = payload.get("sub") + token_version = payload.get("ver", 0) + # Skip captcha tokens - if user_id.startswith("captcha:"): + if user_id and user_id.startswith("captcha:"): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的凭证", @@ -53,6 +56,14 @@ async def get_current_user_allow_password_pending( status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在或已禁用", ) + + # 单设备登录校验 — token 版本号不匹配说明已被踢出 + if token_version != user.token_version: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="账号已在其他设备登录,请重新登录", + ) + return user diff --git a/video-gen-api/app/models/user.py b/video-gen-api/app/models/user.py index 57801686..2e774c7f 100644 --- a/video-gen-api/app/models/user.py +++ b/video-gen-api/app/models/user.py @@ -44,6 +44,11 @@ class User(Base, TimestampMixin): Integer, default=50, server_default="50", nullable=False ) + # Token 版本号 — 每次登录/退出时递增,用于实现单设备登录(全局互斥) + token_version: Mapped[int] = mapped_column( + Integer, default=0, server_default="0", nullable=False + ) + @property def must_set_password(self) -> bool: return self.user_type == "frontend" and not self.hashed_password diff --git a/video-gen-api/app/services/auth.py b/video-gen-api/app/services/auth.py index 89e26056..381348d7 100644 --- a/video-gen-api/app/services/auth.py +++ b/video-gen-api/app/services/auth.py @@ -22,20 +22,20 @@ def verify_password(plain: str, hashed: str | None) -> bool: return False -def create_access_token(user_id: str, remember_me: bool = False) -> str: +def create_access_token(user_id: str, remember_me: bool = False, token_version: int = 0) -> 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} + payload = {"sub": user_id, "exp": expire, "ver": token_version} return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM) -def decode_access_token(token: str) -> str | None: - """Decode JWT and return user_id, or None if invalid.""" +def decode_access_token(token: str) -> dict | None: + """Decode JWT and return full payload, or None if invalid.""" try: payload = jwt.decode( token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM] ) - return payload.get("sub") + return payload except jwt.PyJWTError: return None diff --git a/video-gen-app/src/api/client.ts b/video-gen-app/src/api/client.ts index 8c623cbd..cfb173e5 100644 --- a/video-gen-app/src/api/client.ts +++ b/video-gen-app/src/api/client.ts @@ -114,6 +114,10 @@ if (!res.ok) { if (typeof parsed?.detail === 'string') { msg = parsed.detail; } + // 检测"被踢出"错误 — 单设备登录互斥 + if (res.status === 401 && typeof parsed?.detail === 'string' && parsed.detail.includes('其他设备登录')) { + window.dispatchEvent(new CustomEvent('kicked-out', { detail: { message: parsed.detail } })); + } if (res.status === 401 && !options.skipAuthRedirect) { clearToken(); window.location.href = '/login'; diff --git a/video-gen-app/src/components/KickedOutModal.tsx b/video-gen-app/src/components/KickedOutModal.tsx new file mode 100644 index 00000000..0ae22303 --- /dev/null +++ b/video-gen-app/src/components/KickedOutModal.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Modal, Typography, Button } from 'antd'; +import { ExclamationCircleOutlined } from '@ant-design/icons'; +import { clearToken } from '../api/client'; + +interface KickedOutModalProps { + visible: boolean; +} + +const KickedOutModal: React.FC = ({ visible }) => { + const handleOk = () => { + clearToken(); + window.location.href = '/login'; + }; + + return ( + +
+ + + 登录提示 + + + 您的账号已在其他设备登录,请重新登录 + + +
+
+ ); +}; + +export default KickedOutModal; diff --git a/video-gen-app/src/components/Layout/AppLayout.tsx b/video-gen-app/src/components/Layout/AppLayout.tsx index 948ba8a1..fd58c43d 100644 --- a/video-gen-app/src/components/Layout/AppLayout.tsx +++ b/video-gen-app/src/components/Layout/AppLayout.tsx @@ -69,6 +69,7 @@ import { useAuthStore } from '../../store/useAuthStore'; import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount, createContactRequest, getUser, changePassword, changeUsername } from '../../api'; import NotificationPopup from '../NotificationPopup'; import ActivityBanner from './ActivityBanner'; +import KickedOutModal from '../KickedOutModal'; import './AppLayout.css'; import bg1 from '../../assets/bg1.png'; @@ -440,6 +441,7 @@ const AppLayout: React.FC = () => { const currentOrderNoRef = useRef(null); const [enabledMethods, setEnabledMethods] = useState<{ alipay: boolean; wechat: boolean }>({ alipay: false, wechat: false }); const [bannerVisible, setBannerVisible] = useState(false); + const [kickedOutVisible, setKickedOutVisible] = useState(false); // 资源存储容量(从 getUser().resource_capacity 获取) const [resourceCapacity, setResourceCapacity] = useState<{ @@ -457,6 +459,13 @@ const AppLayout: React.FC = () => { const PENDING_ORDER_KEY = 'pending_payment_order'; + // 监听"被踢出"事件 — 单设备登录互斥 + useEffect(() => { + const handler = () => setKickedOutVisible(true); + window.addEventListener('kicked-out', handler); + return () => window.removeEventListener('kicked-out', handler); + }, []); + useEffect(() => { getSiteInfo().then(info => { const name = info.siteName || '智创'; @@ -923,6 +932,7 @@ const AppLayout: React.FC = () => { overflow: 'hidden', }}> setBannerVisible(v)} /> +