突然想到在写微信小程序的时候做的多设备共存、同端互斥的问题场景是微信小程序要在ipad上和手机端微信小程序和电脑端微信小程序同时在线。用的框架是Spring Security JWT。一、场景与目标场景微信小程序同一用户要在iPad 小程序、手机微信小程序、电脑端小程序同时在线。目标多端共存手机、iPad、电脑三个端互不影响可以同时登录同端互斥同一类型设备如两台手机后登录的踢掉先登录的设备识别能区分当前请求来自哪个端技术栈Spring Security JWT Redis二、核心前提把设备标识写入 JWT无论单 Token 还是双 Token多设备登录的基础都是把设备标识写入 JWT 的 payload。没有设备码服务端不知道请求来自哪个端就无法按端做互斥。2.1 设备识别wx.getDeviceInfo()微信小程序提供wx.getDeviceInfo()获取设备信息字段说明用途platform客户端平台ios、android、windows、mac、devtoolsmodel设备型号iPhone 13、iPad Pro、unknown关键局限platform为ios时同时包含 iPhone 和 iPad官方没有进一步细分要区分 iPhone 和 iPad需结合model字段判断model在新机型刚上市时可能返回unknown此时 iOS 设备会 fallback 到IPHONE对同端互斥的影响fallback 到IPHONE后新 iPad 机型可能被误判为 iPhone导致 iPad 和 iPhone 共享同一个 Redis key 而互相踢。概率低但要知道这个边界。2.2 设备类型映射javascriptconst deviceInfo wx.getDeviceInfo(); function getDeviceType(platform, model) { if (platform ios) { // iOS 平台下model 含 iPad 则视为 iPad否则为 iPhone return model model.toLowerCase().includes(ipad) ? IPAD : IPHONE; } if (platform android) return ANDROID; if (platform windows || platform mac) return PC; if (platform devtools) return DEVTOOLS; return UNKNOWN; } const deviceType getDeviceType(deviceInfo.platform, deviceInfo.model); // 可能返回IPHONE、IPAD、ANDROID、PC2.3 前端登录时携带 deviceTypejavascriptconst deviceInfo wx.getDeviceInfo(); const deviceType getDeviceType(deviceInfo.platform, deviceInfo.model); wx.login({ success(res) { wx.request({ url: https://your-server.com/api/login, method: POST, data: { code: res.code, // 微信登录凭证 deviceType: deviceType // 识别出的设备类型 }, success(response) { // 后端返回的 Token 存本地 wx.setStorageSync(accessToken, response.data.data.accessToken); wx.setStorageSync(refreshToken, response.data.data.refreshToken); } }); } });三、双 Token 方案3.1 设计思路Token有效期存哪里职责AccessToken30 分钟仅客户端日常请求短效RefreshToken7 天客户端 Redis续期长效同端互斥靠覆盖 Redis 里的 RefreshToken但是剔除有延迟最长 AccessToken 有效期。3.2 后端把 deviceType 写入 JWTjavapublic String generateAccessToken(Long userId, String deviceType) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(deviceType, deviceType) // 关键写入 JWT payload .claim(type, access) .setExpiration(new Date(System.currentTimeMillis() 30 * 60 * 1000L)) .signWith(signingKey, SignatureAlgorithm.HS512) .compact(); } public String generateRefreshToken(Long userId, String deviceType) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(deviceType, deviceType) .claim(type, refresh) .setExpiration(new Date(System.currentTimeMillis() 7 * 24 * 60 * 60 * 1000L)) .signWith(signingKey, SignatureAlgorithm.HS512) .compact(); }生成的 AccessToken payloadjson{ sub: 123, deviceType: IPHONE, type: access, exp: 1728496000 }3.3 登录接口按 deviceType 写入 RedisjavaPostMapping(/login) public ResultLoginResp login(RequestBody LoginReq req) { // 1. 微信 code2session 换取 openid // 2. 查询/创建用户 User user userService.getOrCreateUser(openid); // 3. 生成双 Token String accessToken jwtService.generateAccessToken(user.getId(), req.getDeviceType()); String refreshToken jwtService.generateRefreshToken(user.getId(), req.getDeviceType()); // 4. 按 deviceType 写入 Redis同端覆盖 互踢 String redisKey login: req.getDeviceType() : user.getId(); redisTemplate.opsForValue().set(redisKey, refreshToken, 7, TimeUnit.DAYS); return Result.ok(new LoginResp(accessToken, refreshToken)); }Redis key 结构textlogin:IPHONE:123 refreshToken_of_iphone login:IPAD:123 refreshToken_of_ipad login:PC:123 refreshToken_of_pc三个端各占一个 key互不干扰。同一端再次登录覆盖对应 key旧设备续期时被拒。3.4 校验时解析 deviceType 做比对JWT 过滤器只校验 AccessTokenjavaClaims claims jwtService.parseToken(token); String type claims.get(type, String.class); // 只有 AccessToken 能通过常规请求校验 if (!access.equals(type)) { response.setStatus(401); return; } // AccessToken 不查 Redis只校验签名和过期时间 // 过期则返回 401前端触发 refresh刷新接口用 RefreshToken 换新 AccessTokenjavaPostMapping(/refresh) public ResultLoginResp refresh(RequestBody RefreshReq req) { String refreshToken req.getRefreshToken(); Claims claims jwtService.parseToken(refreshToken); Long userId Long.valueOf(claims.getSubject()); String deviceType claims.get(deviceType, String.class); String type claims.get(type, String.class); if (!refresh.equals(type)) { return Result.error(Token 类型错误); } // 查 Redis 比对 String redisKey login: deviceType : userId; String validRefreshToken redisTemplate.opsForValue().get(redisKey); if (validRefreshToken null || !validRefreshToken.equals(refreshToken)) { // 被同端其他设备踢掉 return Result.error(您的账号已在其他设备登录); } // 签发新的 AccessToken String newAccessToken jwtService.generateAccessToken(userId, deviceType); return Result.ok(new LoginResp(newAccessToken, refreshToken)); }3.5 双 Token 的“刷新能力”与“访问能力”这是最容易混淆的点必须区分textB 登录后 A 的刷新能力 → 立即丧失Redis 里 RefreshToken 被覆盖 A 的访问能力 → 还能撑到 AccessToken 过期最长 30 分钟 A 的状态 → “假在线”能用但续不了期完整时间线textT0手机A登录 签发 accessTokenA refreshTokenA Redis: login:IPHONE:123 refreshTokenA A本地: accessTokenA refreshTokenA T1手机B登录 签发 accessTokenB refreshTokenB Redis: login:IPHONE:123 refreshTokenB ← 覆盖A的 B本地: accessTokenB refreshTokenB A本地: accessTokenA refreshTokenA没变A不知道被踢 T2手机A继续请求 带 accessTokenA 过滤器只校验JWT签名和过期时间不查Redis accessTokenA没过期 → 请求成功 A依然能用没有立刻退出 T3手机A的 accessTokenA 过期T0 30分钟 A带 accessTokenA 请求 → 401 前端自动用 refreshTokenA 请求 /refresh 服务端查 Redis: login:IPHONE:123 refreshTokenB refreshTokenA ≠ refreshTokenB → 拒绝刷新 A被踢跳转登录页核心结论双 Token 默认有剔除延迟最长等于 AccessToken 有效期。3.6 双 Token 方案里设备码的作用设备码deviceType在双 Token 方案里的作用写入 JWT payloadAccessToken 和 RefreshToken 里都带deviceType决定 Redis Key 的粒度login:IPHONE:123vslogin:IPAD:123让不同端互不干扰刷新时校验只有 RefreshToken 与 Redis 里的一致才允许换新 AccessToken同端互斥靠覆盖 Redis 里的 RefreshToken但剔除有延迟。3.7 想“瞬间剔除”怎么办双 Token 默认有延迟。如果业务要求 A 立即被踢有三种方案方案一WebSocket 主动推送体验最好textB登录成功 ↓ 服务端通过 WebSocket 向 A 推送{type:kicked,msg:您的账号在新设备登录} ↓ A收到后立即清除本地Token跳转登录页弹提示优点A 瞬间感知体验好缺点需要维护 WebSocket 长连接方案二AccessToken 也存 Redis 白名单textB登录时覆盖 access:IPHONE:123 accessTokenB A请求时过滤器查Redis发现 accessTokenA ≠ accessTokenB 立即返回401A被踢优点不需要长连接A 立刻被踢缺点每次请求都要查 RedisRedis 成为强依赖方案三缩短 AccessToken 有效期textAccessToken 从30分钟改成1分钟 A最多1分钟后就被踢优点实现最简单缺点所有用户每分钟都要刷新Redis 压力大四、单 Token 方案4.1 设计思路单 Token 方案只签发一个 TokenRedis 里存这个 Token 本身每次请求都查 Redis 比对。核心区别能瞬间踢人但每次请求多一次 Redis 查询。4.2 登录接口javaPostMapping(/login) public ResultLoginResp login(RequestBody LoginReq req) { User user userService.getOrCreateUser(openid); // 只签发一个 Token String token jwtService.generateToken(user.getId(), req.getDeviceType()); // Redis 存这个 Token 本身TTL 设 7 天 String redisKey access: req.getDeviceType() : user.getId(); redisTemplate.opsForValue().set(redisKey, token, 7, TimeUnit.DAYS); return Result.ok(new LoginResp(token)); }注意Redis TTL 不能设太短如 30 分钟否则用户频繁重登。应设较长 TTL如 7 天配合活跃续期。4.3 校验时查 Redis 比对 活跃续期java// JWT 过滤器里 Claims claims jwtService.parseToken(token); Long userId Long.valueOf(claims.getSubject()); String deviceType claims.get(deviceType, String.class); String redisKey access: deviceType : userId; String validToken redisTemplate.opsForValue().get(redisKey); if (validToken null || !validToken.equals(token)) { return 401; // 被踢 } // 活跃续期剩余 TTL 少于 30 分钟延长到 7 天 Long ttl redisTemplate.getExpire(redisKey, TimeUnit.MINUTES); if (ttl ! null ttl 30) { redisTemplate.expire(redisKey, 7, TimeUnit.DAYS); }4.4 单 Token 的踢人机制textB登录时Redis: access:IPHONE:123 tokenB ← 覆盖tokenA A请求时查Redis发现 tokenA ≠ tokenB → 立即返回401A 的下一次请求就被拒这是瞬间踢人。4.5 单 Token 的代价维度说明每次请求查 RedisQPS 高时 Redis 成为瓶颈Redis 故障影响所有请求受影响安全窗口Token 泄露后只要用户活跃就能一直续期风险窗口大五、单 Token vs 双 Token 对比维度单 Token双 Token登录签发1 个 Token2 个 TokenRedis 存什么Token 本身RefreshToken每次请求查 Redis查不查只查刷新时踢人延迟瞬间最长 AccessToken 有效期安全窗口大Token 泄露后一直能用小AccessToken 短效Redis 压力高低实现复杂度中高选型建议场景推荐方案要瞬间踢人安全要求一般单 Token Redis 白名单能接受延迟安全要求高双 Token要瞬间踢人 安全要求高双 Token WebSocket 推送六、完整链路总结双 Token 方案text小程序端 wx.getDeviceInfo() → 识别 platform/model → 映射为 IPHONE/IPAD/PC → 登录请求带上 deviceType 后端 签发双 TokendeviceType 写入 JWT payload → 按 deviceType 分 key 写入 Redis 的 refreshToken → AccessToken 不查 RedisRefreshToken 刷新时查 Redis 比对 效果 iPhone A 登录 → Redis: login:IPHONE:123 tokenA iPhone B 登录 → Redis: login:IPHONE:123 tokenB覆盖 iPhone A 的 AccessToken 过期后 → 续期被拒 → A 被踢最长延迟30分钟 iPad 不受影响 → 它查的是 login:IPAD:123单 Token 方案text小程序端 wx.getDeviceInfo() → 识别 deviceType → 登录请求带上 后端 签发单 TokendeviceType 写入 JWT payload → 按 deviceType 分 key 写入 Redis 的 Token 本身 → 每次请求查 Redis 比对活跃时续期 效果 iPhone A 登录 → Redis: access:IPHONE:123 tokenA iPhone B 登录 → Redis: access:IPHONE:123 tokenB覆盖 iPhone A 下一次请求 → tokenA ≠ tokenB → 立即被踢 iPad 不受影响 → 它查的是 access:IPAD:123七、核心结论设备码是基础无论单 Token 还是双 Token都要把deviceType写入 JWT payload并按它分 Redis key。单 Token 和双 Token 的本质区别不在 JWT 里而在 Redis 的存储策略和校验时机上。单 Token 能瞬间踢人Redis 存 Token 本身每次请求查覆盖即踢。代价是性能和安全窗口。双 Token 有剔除延迟Redis 只存 RefreshTokenAccessToken 不查最长延迟 AccessToken 有效期。换来的是性能和短效安全。要瞬间踢人 安全双 Token WebSocket 主动推送或 AccessToken 也存 Redis 白名单。设备识别的边界model返回unknown时新 iPad 可能被误判为 iPhone概率低但要知道。