Explorar el Código

feat(auth): 登录前校验账号是否正在使用,确认后才强制登录

- 新增 LoginActivityService:Redis 记录每个账号最后活跃时间(窗口默认 30 分钟,可配 zsjz.auth.in-use-window-minutes),已登录请求由 sa-token 拦截器按用户节流写入
- 登录接口在密码校验通过后判断占用,命中返回 HTTP 200 + code=40901;带 force=true 重提时先踢掉旧会话(其案件由会话监听释放)再登录,不依赖 is-concurrent 配置
- 前端登录页识别 40901 弹「账号正在使用中」确认框,确认框垂直居中;loginApi 改拿原始响应体以便读取业务码
- dev 环境 is-concurrent 改回 false(单端登录)
- 补充 LoginActivityServiceTest
cc hace 4 días
padre
commit
95602aa617

+ 30 - 2
ai-electron/frontend/src/core/api/auth/authApi.ts

@@ -7,6 +7,8 @@ const { adminPath } = useGlobSetting();
 export interface LoginParams {
   username: string;
   password: string;
+  /** 账号正在使用时,用户确认后带 true 重新提交(服务端会踢掉旧会话) */
+  force?: boolean;
 }
 
 /** 登录返回 */
@@ -19,6 +21,24 @@ export interface LoginResult {
   isAdmin: number;
 }
 
+/**
+ * 后端业务码:账号正在其他设备使用。
+ *
+ * <p>后端刻意返回 HTTP 200 + 该业务码(见 {@code AccountInUseException}),
+ * 页面据此弹「是否继续登录」,确认后带 {@code force=true} 重新提交。
+ */
+export const LOGIN_ACCOUNT_IN_USE = 40901;
+
+/** 登录接口的原始响应体(非 200 也是正常分支,所以不走 defHttp 的 transform) */
+export interface LoginRawResult {
+  code: number;
+  message?: string;
+  data?: LoginResult;
+}
+
+/** 登录结果:账号占用是正常分支,用返回值表达而不是抛异常,页面要据此弹确认框 */
+export type LoginOutcome = { ok: true; result: LoginResult } | { ok: false; code: number; message: string };
+
 /** 用户信息 */
 export interface UserInfo {
   id: string;
@@ -53,8 +73,16 @@ export interface PageResult<T> {
 // 认证 /auth
 // ---------------------------------------------------------------------------
 
-/** 登录;token 通过响应体返回,前端写入缓存后由请求拦截器注入 x-token 头 */
-export const loginApi = (data: LoginParams) => defHttp.postJson<LoginResult>({ url: adminPath + '/auth/login', data });
+/**
+ * 登录;token 通过响应体返回,前端写入缓存后由请求拦截器注入 x-token 头。
+ *
+ * <p>{@code isTransformResponse:false}:账号正在使用时后端返回的是
+ * HTTP 200 + {@code code=40901} 的正常分支,不能被 transform 当成错误吞成一次提示,
+ * 页面需要拿到业务码去弹确认框。真正的错误(密码错、账号停用)是 4xx,
+ * 仍走 axios 的 catch 分支由 defHttp 统一提示。
+ */
+export const loginApi = (data: LoginParams) =>
+  defHttp.postJson<LoginRawResult>({ url: adminPath + '/auth/login', data }, { isTransformResponse: false });
 
 /** 登出:后端会同时释放该用户打开的案件数据源 */
 export const logoutApi = () => defHttp.postJson<void>({ url: adminPath + '/auth/logout' });

+ 20 - 4
ai-electron/frontend/src/core/store/modules/account.ts

@@ -7,6 +7,7 @@ import {
   logoutApi,
   getUserInfoApi,
   changePwdApi,
+  type LoginOutcome,
   type LoginParams,
   type UserInfo,
 } from '@/core/api/auth/authApi';
@@ -53,9 +54,24 @@ export const useAccountStore = defineStore('app-account', {
       this.lastUpdateTime = new Date().getTime();
       setAuthCache(USER_INFO_KEY, info);
     },
-    /** 登录:成功后写入 token 与用户信息 */
-    async login(params: LoginParams) {
-      const result = await loginApi(params);
+    /**
+     * 登录:成功后写入 token 与用户信息。
+     *
+     * <p>返回结果而不是抛异常:账号正在使用(code=40901)是正常分支,
+     * 页面要据此弹「是否确定登录」,确认后带 {@code force=true} 再调一次。
+     * 密码错误等 4xx 仍走 defHttp 的统一提示(这里会抛出去)。
+     */
+    async login(params: LoginParams): Promise<LoginOutcome> {
+      const res = await loginApi(params);
+      const code = Number(res?.code ?? 0);
+      if (code !== 200 || !res?.data?.token) {
+        return {
+          ok: false,
+          code,
+          message: res?.message || '登录失败',
+        };
+      }
+      const result = res.data;
       setAuthCache(TOKEN_KEY, result.token);
       this.setUserInfo({
         id: result.userId,
@@ -63,7 +79,7 @@ export const useAccountStore = defineStore('app-account', {
         displayName: result.displayName,
         isAdmin: result.isAdmin,
       });
-      return result;
+      return { ok: true, result };
     },
     /** 刷新页面后拉取当前用户(token 失效会抛 401,由拦截器跳登录页) */
     async fetchUserInfo() {

+ 46 - 2
ai-electron/frontend/src/plat/views/authorization/index.vue

@@ -275,7 +275,7 @@
 <script lang="ts" setup>
   import { computed, onMounted, reactive, ref } from 'vue';
   import { useRoute, useRouter } from 'vue-router';
-  import { Button, Form, FormItem, Input } from 'ant-design-vue';
+  import { Button, Form, FormItem, Input, Modal } from 'ant-design-vue';
   import {
     ApartmentOutlined,
     CopyOutlined,
@@ -294,6 +294,7 @@
   import { AppHeader } from '@/core/components/Application';
   import ServerAddressPanel from '@/core/components/ServerAddress/ServerAddressPanel.vue';
   import { authorizationInfoApi, checkAuthApi, machineCodeApi, offlineAuthorizationApi } from '@/core/api/sys/login';
+  import { LOGIN_ACCOUNT_IN_USE } from '@/core/api/auth/authApi';
   import { useCopyToClipboard } from '@/core/hooks/web/useCopyToClipboard';
   import { useDesign } from '@/core/hooks/web/useDesign';
   import { useMessage } from '@/core/hooks/web/useMessage';
@@ -646,6 +647,40 @@
     viewMode.value = 'login';
   }
 
+  /**
+   * 账号正在使用:确认后才带 force 重新登录。
+   *
+   * <p>「正在使用」= 该账号有有效会话且最近半小时内活跃过(后端判定)。
+   * 确认登录会踢掉旧会话,旧会话打开的案件由后端会话监听释放。
+   */
+  function confirmForceLogin(username: string) {
+    Modal.confirm({
+      title: '账号正在使用中',
+      content: '该账号最近半小时内在其他设备上有操作。继续登录会使对方下线,是否继续?',
+      okText: '继续登录',
+      cancelText: '取消',
+      // Ant Design 的弹框默认从顶部 100px 处展开;登录页是独立的全屏入口,
+      // 跟随页面居中显示更稳(窗口矮的时候也不会被顶到边缘)
+      centered: true,
+      onOk: async () => {
+        try {
+          const outcome = await accountStore.login({
+            username,
+            password: loginForm.password,
+            force: true,
+          });
+          if (!outcome.ok) {
+            showMessage(outcome.message || '登录失败');
+            return;
+          }
+          await router.replace(getPostLoginTarget());
+        } catch {
+          // HTTP 层错误(网络/密码校验失败)已由 defHttp 统一提示
+        }
+      },
+    });
+  }
+
   async function handleLoginSubmit() {
     // 防重入:重复登录会因 is-concurrent=false 把前一个 token 顶掉,导致刚拿到的凭据立即失效
     if (loginLoading.value) {
@@ -657,7 +692,16 @@
     }
     loginLoading.value = true;
     try {
-      await accountStore.login({ username, password: loginForm.password });
+      const outcome = await accountStore.login({ username, password: loginForm.password });
+      if (!outcome.ok) {
+        // 账号占用是正常分支:弹确认框,确认后带 force 重新登录
+        if (outcome.code === LOGIN_ACCOUNT_IN_USE) {
+          confirmForceLogin(username);
+          return;
+        }
+        showMessage(outcome.message || '登录失败');
+        return;
+      }
       await router.replace(getPostLoginTarget());
     } catch {
       // 错误提示已由 defHttp 统一弹出,这里只负责不跳转

+ 106 - 0
ai-server/src/main/java/com/zsjz/ai/common/auth/LoginActivityService.java

@@ -0,0 +1,106 @@
+package com.zsjz.ai.common.auth;
+
+import cn.dev33.satoken.stp.StpUtil;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Service;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 登录活跃度登记:回答「这个账号是不是正在被人使用」。
+ *
+ * <p><b>为什么需要</b>:单端登录({@code is-concurrent=false})下,后登录会直接顶掉前一个会话。
+ * 用户 A 正在研判、用户 B 拿同一账号登录时,A 会被无声踢下线(未保存的工作丢失)。
+ * 登录接口据此判断是否要先让用户确认「账号正在使用,是否继续登录」。
+ *
+ * <p><b>为什么不用 sa-token 的活跃时间</b>:{@code sa-token.active-timeout} 当前配的是 -1
+ * (长时间研判不能被活跃超时踢掉),sa-token 的 last-active 只在启用活跃超时后才有意义;
+ * 这里自己用 Redis 维护,与配置解耦。
+ *
+ * <p><b>存储</b>:{@code zsjz:auth:last-active:{userId}},value 为最后活跃时间,
+ * TTL 就是判定窗口 —— key 存在即代表「窗口内活跃过」,过期自动清理。<br>
+ * 写操作按用户节流(默认 60 秒一次):窗口是分钟级,这点误差不影响判定,
+ * 但能把「每个请求一次 Redis 写」压成「每人每分钟最多一次」。
+ *
+ * <p>Redis 异常一律不阻断主流程:记日志,按「不在使用中」处理 ——
+ * 登录本身是安全的(顶号会走既有的会话监听释放旧案件),宁可少弹一次确认,也不要登录不了。
+ */
+@Slf4j
+@Service
+public class LoginActivityService {
+
+    /** 活跃标记 key 前缀(带冒号分层,便于 Redis 上排查) */
+    private static final String KEY_PREFIX = "zsjz:auth:last-active:";
+
+    /** 同一用户两次写 Redis 的最小间隔 */
+    private static final long TOUCH_INTERVAL_MS = 60_000L;
+
+    private final StringRedisTemplate redis;
+
+    /** 本节点的写节流表:userId → 上次写 Redis 的毫秒时间戳 */
+    private final Map<Long, Long> lastWriteAt = new ConcurrentHashMap<>();
+
+    /** 「正在使用」的判定窗口(分钟),对应需求里的「半小时内是否在使用」 */
+    private final long windowMinutes;
+
+    public LoginActivityService(StringRedisTemplate redis,
+                                @Value("${zsjz.auth.in-use-window-minutes:30}") long windowMinutes) {
+        this.redis = redis;
+        this.windowMinutes = windowMinutes;
+    }
+
+    /**
+     * 记录一次活跃:登录成功后调用一次,之后每个已登录请求都由拦截器调用。
+     *
+     * <p>节流是「本节点」维度的:多节点部署时每个节点各自最多每分钟写一次,
+     * 结果仍是「同一用户每分钟最多 N 次写」,判定语义不受影响。
+     */
+    public void touch(Long userId) {
+        if (userId == null || windowMinutes <= 0) {
+            return;
+        }
+        long now = System.currentTimeMillis();
+        Long last = lastWriteAt.get(userId);
+        if (last != null && now - last < TOUCH_INTERVAL_MS) {
+            return;
+        }
+        try {
+            redis.opsForValue().set(key(userId), String.valueOf(now), Duration.ofMinutes(windowMinutes));
+            lastWriteAt.put(userId, now);
+        } catch (Exception e) {
+            log.error("记录登录活跃时间失败: userId={}", userId, e);
+        }
+    }
+
+    /**
+     * 账号当前是否「正在使用」:既要有有效 token(已登录且没被顶号/踢下线/登出),
+     * 又要在窗口内活跃过(避免把「登录后一直没人动」的死会话当成正在使用)。
+     *
+     * <p>判断失败按「不在使用」处理(见类注释)。
+     */
+    public boolean isInUse(Long userId) {
+        if (userId == null) {
+            return false;
+        }
+        try {
+            List<String> tokens = StpUtil.getTokenValueListByLoginId(userId);
+            if (tokens == null || tokens.isEmpty()) {
+                return false;
+            }
+            String value = redis.opsForValue().get(key(userId));
+            return value != null && !value.isBlank();
+        } catch (Exception e) {
+            log.error("判断账号是否在使用失败,按未在使用处理: userId={}", userId, e);
+            return false;
+        }
+    }
+
+    private static String key(Long userId) {
+        return KEY_PREFIX + userId;
+    }
+}

+ 13 - 1
ai-server/src/main/java/com/zsjz/ai/common/config/SaTokenWebConfig.java

@@ -2,6 +2,7 @@ package com.zsjz.ai.common.config;
 
 import cn.dev33.satoken.interceptor.SaInterceptor;
 import cn.dev33.satoken.stp.StpUtil;
+import com.zsjz.ai.common.auth.LoginActivityService;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
 import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@@ -44,9 +45,20 @@ public class SaTokenWebConfig implements WebMvcConfigurer {
             "/error",
     };
 
+    private final LoginActivityService loginActivityService;
+
+    public SaTokenWebConfig(LoginActivityService loginActivityService) {
+        this.loginActivityService = loginActivityService;
+    }
+
     @Override
     public void addInterceptors(InterceptorRegistry registry) {
-        registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin()).isAnnotation(false))
+        registry.addInterceptor(new SaInterceptor(handle -> {
+                    StpUtil.checkLogin();
+                    // 每个已登录请求记一次活跃:登录接口按「半小时内是否活跃」判断账号是否正在使用。
+                    // touch 内部按用户节流(默认 60 秒),失败只记日志,不影响请求。
+                    loginActivityService.touch(StpUtil.getLoginIdAsLong());
+                }).isAnnotation(false))
                 .addPathPatterns("/**")
                 .excludePathPatterns(WHITELIST);
     }

+ 19 - 0
ai-server/src/main/java/com/zsjz/ai/common/exception/AccountInUseException.java

@@ -0,0 +1,19 @@
+package com.zsjz.ai.common.exception;
+
+/**
+ * 登录被拦截:账号正在其他设备使用,需要用户确认后再以 {@code force=true} 重新提交。
+ *
+ * <p>与普通业务异常不同,它由 {@code GlobalExceptionHandler} 转成
+ * <b>HTTP 200 + body.code=40901</b>:前端 {@code defHttp} 对非 2xx 会走 axios 的
+ * catch 分支、且错误码会在拦截器里被吞掉;这里要让页面拿到稳定的业务码去弹确认框,
+ * 与 401(未登录)走同一种「HTTP 200 + 业务码」约定。
+ */
+public class AccountInUseException extends RuntimeException {
+
+    /** 登录被拦截的业务码(前端 {@code LOGIN_ACCOUNT_IN_USE} 与此保持一致) */
+    public static final int CODE = 40901;
+
+    public AccountInUseException(String message) {
+        super(message);
+    }
+}

+ 13 - 0
ai-server/src/main/java/com/zsjz/ai/common/exception/GlobalExceptionHandler.java

@@ -38,6 +38,19 @@ public class GlobalExceptionHandler {
                 .body(Result.failure(e.getCode(), e.getMessage()));
     }
 
+    /**
+     * 登录被拦截:账号正在其他设备使用。
+     *
+     * <p><b>刻意返回 HTTP 200 + body.code=40901</b>:前端要拿这个码弹「是否继续登录」的确认框。
+     * 若返回 4xx,会走 axios 的 catch 分支,业务码被拦截器当成普通错误吞成一次提示,
+     * 页面就没机会弹确认框了 —— 与 401 的处理同理。
+     */
+    @ExceptionHandler(AccountInUseException.class)
+    public ResponseEntity<Result<Void>> handleAccountInUse(AccountInUseException e) {
+        log.info("登录被拦截(账号正在使用): {}", e.getMessage());
+        return ResponseEntity.ok(Result.failure(AccountInUseException.CODE, e.getMessage()));
+    }
+
     /**
      * 未登录 / token 失效 / 被顶下线(sa-token 抛出)。
      *

+ 9 - 0
ai-server/src/main/java/com/zsjz/ai/common/model/plat/dto/LoginDTO.java

@@ -13,4 +13,13 @@ public class LoginDTO {
 
     /** 明文密码(服务端用 BCrypt 比对,不落库) */
     private String password;
+
+    /**
+     * 是否确认强制登录。
+     *
+     * <p>账号正在其他设备使用(有效 token 且窗口内活跃过)时,首次登录返回
+     * {@code code=40901} 让前端确认;用户确认后带 {@code force=true} 重新提交,
+     * 服务端踢掉旧会话再登录。其他情况忽略该字段。
+     */
+    private Boolean force;
 }

+ 26 - 2
ai-server/src/main/java/com/zsjz/ai/module/plat/service/SysUserService.java

@@ -5,8 +5,10 @@ import cn.hutool.core.util.StrUtil;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.spring.service.impl.ServiceImpl;
+import com.zsjz.ai.common.auth.LoginActivityService;
 import com.zsjz.ai.common.datasource.CaseDataSourceRegistry;
 import com.zsjz.ai.common.cache.CaseDataCache;
+import com.zsjz.ai.common.exception.AccountInUseException;
 import com.zsjz.ai.common.exception.ServerException;
 import com.zsjz.ai.common.model.plat.dto.ChangePwdDTO;
 import com.zsjz.ai.common.model.plat.dto.LoginDTO;
@@ -49,12 +51,16 @@ public class SysUserService extends ServiceImpl<SysUserMapper, SysUser> {
 
     private final CaseDataSourceRegistry caseDataSourceRegistry;
 
+    private final LoginActivityService loginActivityService;
+
     public SysUserService(PasswordEncoder passwordEncoder,
                           CaseInfoMapper caseInfoMapper,
-                          CaseDataSourceRegistry caseDataSourceRegistry) {
+                          CaseDataSourceRegistry caseDataSourceRegistry,
+                          LoginActivityService loginActivityService) {
         this.passwordEncoder = passwordEncoder;
         this.caseInfoMapper = caseInfoMapper;
         this.caseDataSourceRegistry = caseDataSourceRegistry;
+        this.loginActivityService = loginActivityService;
     }
 
     // ------------------------------------------------------------------
@@ -64,6 +70,12 @@ public class SysUserService extends ServiceImpl<SysUserMapper, SysUser> {
     /**
      * 登录。
      *
+     * <p><b>账号占用确认</b>:登录前先判断该账号是否正在使用(存在有效会话且半小时内活跃过,
+     * 见 {@link LoginActivityService})。命中时抛 {@link AccountInUseException},
+     * 前端拿到 {@code code=40901} 弹「是否确定登录」;用户确认后带 {@code force=true}
+     * 重新提交,这里先踢掉旧会话(会话监听会同步释放旧会话打开的案件)再登录。
+     * 不在使用则直接登录,不打扰用户。
+     *
      * <p>生产默认 {@code sa-token.is-concurrent=false}:本次登录会把该账号此前的 token 顶下线
      * (旧 token 下次请求抛 {@code NotLoginException(BE_REPLACED)})。
      * 联调时可用 {@code SA_TOKEN_CONCURRENT=true} 临时开启多端。
@@ -82,14 +94,26 @@ public class SysUserService extends ServiceImpl<SysUserMapper, SysUser> {
         if (!FLAG_ON.equals(user.getStatus())) {
             throw badRequest("账号已被停用,请联系管理员");
         }
+        boolean force = Boolean.TRUE.equals(dto.getForce());
+        // 密码校验通过后才判断占用:避免用登录接口探测「某个账号是否在线」
+        if (!force && loginActivityService.isInUse(user.getId())) {
+            throw new AccountInUseException("该账号正在其他设备使用");
+        }
+        if (force) {
+            // 确认接管:显式踢掉该账号所有在线会话。不依赖 is-concurrent 配置,
+            // 保证「确定登录」在任何配置下都能让旧会话下线(会话监听会释放旧案件)。
+            StpUtil.kickout(user.getId());
+        }
         StpUtil.login(user.getId());
+        // 登录本身算一次活跃:半小时内重复登录会触发下一次确认
+        loginActivityService.touch(user.getId());
 
         SysUser touch = new SysUser();
         touch.setId(user.getId());
         touch.setLastLoginTime(LocalDateTime.now());
         updateById(touch);
 
-        log.info("用户登录成功: id={}, username={}", user.getId(), user.getUsername());
+        log.info("用户登录成功: id={}, username={}, force={}", user.getId(), user.getUsername(), force);
         return toLoginVO(user, StpUtil.getTokenValue());
     }
 

+ 1 - 1
ai-server/src/main/resources/application-dev.yaml

@@ -61,7 +61,7 @@ sa-token:
   active-timeout: -1
   # 生产默认单端登录:后登录顶掉前登录(被顶方下次请求收到 code=401)。
   # 联调需要同账号多端时,启动参数加 -DSA_TOKEN_CONCURRENT=true(或环境变量 SA_TOKEN_CONCURRENT=true)。
-  is-concurrent: true
+  is-concurrent: false
   token-style: uuid
   is-print: false
   # 每次请求自动续期

+ 99 - 0
ai-server/src/test/java/com/zsjz/ai/common/auth/LoginActivityServiceTest.java

@@ -0,0 +1,99 @@
+package com.zsjz.ai.common.auth;
+
+import cn.dev33.satoken.stp.StpUtil;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+
+import java.time.Duration;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 登录活跃度判定的契约。
+ *
+ * <p>「正在使用」= 有有效 token <b>且</b> 窗口内活跃过。两个条件缺一不可:
+ * 只看 token 会把「登录后一直没人动」的死会话当成在用;只看活跃时间则可能在会话已被顶掉后误报。
+ */
+class LoginActivityServiceTest {
+
+    private static final Long USER_ID = 1L;
+
+    private StringRedisTemplate redis;
+    private ValueOperations<String, String> valueOps;
+    private LoginActivityService service;
+
+    @BeforeEach
+    @SuppressWarnings("unchecked")
+    void setUp() {
+        redis = mock(StringRedisTemplate.class);
+        valueOps = mock(ValueOperations.class);
+        when(redis.opsForValue()).thenReturn(valueOps);
+        service = new LoginActivityService(redis, 30);
+    }
+
+    @Test
+    @DisplayName("没有任何有效 token:不算在使用(即使活跃标记还在)")
+    void notInUseWithoutLiveToken() {
+        try (MockedStatic<StpUtil> stp = mockStatic(StpUtil.class)) {
+            stp.when(() -> StpUtil.getTokenValueListByLoginId(USER_ID)).thenReturn(List.of());
+
+            assertFalse(service.isInUse(USER_ID));
+        }
+    }
+
+    @Test
+    @DisplayName("有有效 token 但活跃标记已过期:不算在使用,登录直接放行")
+    void notInUseWhenActivityExpired() {
+        try (MockedStatic<StpUtil> stp = mockStatic(StpUtil.class)) {
+            stp.when(() -> StpUtil.getTokenValueListByLoginId(USER_ID)).thenReturn(List.of("token-a"));
+            when(valueOps.get(anyString())).thenReturn(null);
+
+            assertFalse(service.isInUse(USER_ID));
+        }
+    }
+
+    @Test
+    @DisplayName("有有效 token 且窗口内活跃过:判定为正在使用,登录需要确认")
+    void inUseWhenTokenAliveAndRecentlyActive() {
+        try (MockedStatic<StpUtil> stp = mockStatic(StpUtil.class)) {
+            stp.when(() -> StpUtil.getTokenValueListByLoginId(USER_ID)).thenReturn(List.of("token-a"));
+            when(valueOps.get(anyString())).thenReturn(String.valueOf(System.currentTimeMillis()));
+
+            assertTrue(service.isInUse(USER_ID));
+        }
+    }
+
+    @Test
+    @DisplayName("活跃标记按窗口设置 TTL,同一用户一分钟内只写一次 Redis")
+    void touchWritesAtMostOncePerMinute() {
+        service.touch(USER_ID);
+        service.touch(USER_ID);
+        service.touch(USER_ID);
+
+        verify(valueOps, times(1)).set(anyString(), anyString(), any(Duration.class));
+    }
+
+    @Test
+    @DisplayName("Redis 异常不能阻断请求:touch 吞掉异常")
+    void touchSwallowsRedisError() {
+        doThrow(new RuntimeException("redis down"))
+                .when(valueOps).set(anyString(), anyString(), any(Duration.class));
+
+        assertDoesNotThrow(() -> service.touch(USER_ID));
+    }
+}