Kaynağa Gözat

同一个管理人员允许最多5台设备登录

liuchengsen 1 ay önce
ebeveyn
işleme
3ff31eb643

+ 241 - 0
docs/会员权益清单.md

@@ -0,0 +1,241 @@
+# 智价云(药店版)会员权益清单
+
+> **用途**:评审与验收标准参考  
+> **最后更新**:2026-07-02  
+> **源码依据**:`MembershipLevel.java`、`MembershipService.java`、`CrawlerService.java`、`WatchlistService.java`、`CouponService.java`、`InviteService.java`、`CrawlerQuotaGrantService.java`
+
+---
+
+## 一、会员等级体系
+
+系统采用 **三级会员体系**,等级驱动所有权益。
+
+| 等级 | 编码 | 权重 | 适用场景 |
+|------|------|------|----------|
+| 普通会员 | `PLUS` | 1 | 注册即得,永久免费,所有用户默认等级 |
+| 高级会员 | `PRO` | 2 | 核心付费会员,主力变现等级 |
+| 超级会员 | `ULTRA` | 3 | 连锁药店专用,运营后台发放 |
+
+**互斥规则**:
+- 同一用户同时只有一个活跃等级(权重高的优先)
+- 升级时停用旧会员,创建新记录
+- **不降级保护**:已是高等级时,低等级奖励自动转为续期高等级(如 ULTRA 用户获得邀请 PRO 奖励 → 续期 ULTRA 30 天)
+
+---
+
+## 二、核心权益:爬虫比价配额
+
+每次药品搜索/比价消耗 **1 次**配额。配额体系 = **等级基础配额 + 额外配额 + 优惠券**。
+
+### 2.1 等级基础配额
+
+| 权益项 | PLUS(普通) | PRO(高级) | ULTRA(超级) |
+|--------|:-----------:|:----------:|:------------:|
+| 每日查询配额 | **10 次** | **999 次**(不限量) | **999 次**(不限量) |
+| 每月查询配额 | **300 次** | **29,970 次** | **29,970 次** |
+| 每年查询配额 | **3,650 次** | **364,635 次** | **364,635 次** |
+| 最大并发查询 | **1** | **5** | **10** |
+
+### 2.2 配额消耗优先级
+
+```
+优惠券(有且选择优先使用)
+    ↓ 不足时
+额外配额(快过期优先扣减)
+    ↓ 不足时
+等级基础配额
+```
+
+### 2.3 额外配额(三种来源)
+
+| 来源类型 | 编码 | 说明 |
+|----------|------|------|
+| 邀请奖励 | `INVITE` | 邀请他人注册成功获得,可配置次数 |
+| 购买会员奖励 | `PURCHASE` | 购买 PRO/ULTRA 套餐时赠送 |
+| 运营手动发放 | `ADMIN` | 后台管理员手动发放,可指定次数和过期时间 |
+
+额外配额有独立过期时间,快过期的优先消耗。每小时定时清理过期配额。
+
+### 2.4 优惠券
+
+| 特性 | 说明 |
+|------|------|
+| 类型 | `CRAWLER`(爬虫优惠券) |
+| 有效期 | 相对天数(如 30 天)或固定截止日期 |
+| 等级门槛 | 可配置最低会员等级要求 |
+| 发行量限制 | 总量上限,防止超发 |
+| 状态流转 | 未使用(0) → 已使用(1) / 已过期(2) |
+
+---
+
+## 三、关注/收藏权益
+
+用户可关注药品品种,监控价格变动。**每月关注上限**按等级区分:
+
+| 等级 | 每月关注上限 |
+|------|:-----------:|
+| PLUS(普通) | **50 个** |
+| PRO(高级) | **200 个** |
+| ULTRA(超级) | **500 个** |
+
+功能细节:
+- 同一用户对同一药品+规格去重
+- 支持软删除(取消关注)
+- 可查询每月已用/上限统计
+
+---
+
+## 四、邀请裂变权益
+
+### 4.1 邀请人奖励(注册成功时发放)
+
+| 奖励项 | 内容 |
+|--------|------|
+| 会员时长 | **1 个月高级会员(PRO)**(可配置月数) |
+| 爬虫配额 | 可选额外配额(可配置次数,默认 0 = 不送) |
+
+### 4.2 邀请限制
+
+| 限制项 | 默认值 | 说明 |
+|--------|:------:|------|
+| 每日邀请上限 | 可配置 | 超限后当日不可再邀请 |
+| 总邀请奖励上限 | **30 人** | 超限后仍可邀请但不发放奖励 |
+| 不能邀请自己 | — | 返回错误 `INVITE_SELF_FORBIDDEN` |
+| 被邀请人不可重复绑定 | — | `inviter_id` 写入后不可变 |
+
+### 4.3 邀请码
+
+| 特性 | 说明 |
+|------|------|
+| 格式 | 8 位大写字母+数字(排除 I/O/0/1) |
+| 有效期 | 可配置天数,默认从配置读取 |
+| 使用次数 | 不限(`maxUses = -1`) |
+| 多渠道支持 | app / wechat / miniapp / dingtalk / feishu |
+| 点击追踪 | 每次链接打开自动计入 `click_count` |
+
+### 4.4 补填邀请码
+
+- 注册后 **30 天内**(可配置)可补填邀请码
+- 补填成功后,被邀请人额外获赠 **1 个月普通会员**
+- 邀请人获得奖励(与注册时邀请相同)
+
+---
+
+## 五、会员获取方式
+
+| 获取方式 | 编码 | 适用等级 | 说明 |
+|----------|------|----------|------|
+| 系统默认 | `SYSTEM` | PLUS | 注册即获得,永久有效 |
+| 付费购买 | `RECHARGE` | PRO | 支持微信/支付宝支付 |
+| 邀请奖励 | `INVITE` | PRO | 邀请 1 人注册 → 1 个月 PRO |
+| 营业执照审核 | `LICENSE` | PRO | 审核通过赠送 1 个月 PRO |
+| 运营手动发放 | `ADMIN_GRANT` | 任意 | 后台管理员手动指定等级和时长 |
+
+---
+
+## 六、付费套餐定价
+
+| 方案编码 | 方案名称 | 等级 | 售价 | 原价 | 天数 |
+|----------|----------|:---:|------|------|:---:|
+| `MONTHLY_PRO` | 高级会员·月卡 | PRO | **¥29.90** | ¥59.90 | 30 天 |
+| `YEARLY_PRO` | 高级会员·年卡 | PRO | **¥299.90** | ¥599.90 | 365 天 |
+| `ULTRA` | 超级会员·连锁专享 | ULTRA | — | — | 敬请期待 |
+
+> 普通会员(PLUS)免费开放,无付费套餐。
+
+---
+
+## 七、会员生命周期管理
+
+### 7.1 续期规则
+
+| 场景 | 行为 |
+|------|------|
+| 同等级续期 | 在原到期时间基础上**叠加天数** |
+| 低→高升级 | 停用所有旧会员,创建新记录 |
+| 高等级获得低等级奖励 | **不降级**,改为续期当前高等级(不降级保护) |
+
+### 7.2 过期降级
+
+| 特性 | 说明 |
+|------|------|
+| 检查频率 | **每 5 分钟**(定时任务) |
+| 降级目标 | PRO/ULTRA 到期后 → 自动降为 **PLUS(普通会员)** |
+| 降级后 | 配额恢复为 PLUS 等级配额、`membership_expire_at` 置空 |
+
+### 7.3 过期时间展示
+
+API 返回用户当前等级的 **剩余天数**:
+- PLUS(免费永久)→ `membershipDaysRemaining: -1`
+- PRO/ULTRA → 实际剩余天数(到期为 0)
+
+---
+
+## 八、会员权益汇总对照表
+
+| 权益维度 | PLUS(普通) | PRO(高级) | ULTRA(超级) |
+|----------|:-----------:|:----------:|:------------:|
+| 日查询配额 | 10 | 999(不限量) | 999(不限量) |
+| 月查询配额 | 300 | 29,970 | 29,970 |
+| 年查询配额 | 3,650 | 364,635 | 364,635 |
+| 最大并发 | 1 | 5 | 10 |
+| 月关注上限 | 50 | 200 | 500 |
+| 月卡价格 | 免费 | ¥29.90 | — |
+| 年卡价格 | 免费 | ¥299.90 | — |
+| 有效期 | 永久 | 购买/赠送天数 | 运营发放天数 |
+| 到期降级 | — | → PLUS | → PLUS |
+| 邀请奖励 | — | 获赠PRO会员+配额 | 获赠PRO会员+配额 |
+
+---
+
+## 九、验收检查清单
+
+### 9.1 等级权益正确性
+
+- [ ] 注册用户默认为 PLUS 等级
+- [ ] PLUS 每日查询上限 10 次,超出后报配额不足
+- [ ] PRO 每日查询上限 999 次(实际不限量)
+- [ ] ULTRA 每日查询上限 999 次(实际不限量)
+- [ ] 各等级并发限制正确(1 / 5 / 10)
+- [ ] 各等级关注上限正确(50 / 200 / 500)
+- [ ] 数据库 `t_membership_benefit` 表可动态覆盖等级默认权益值
+
+### 9.2 配额消耗逻辑
+
+- [ ] 搜索一次消耗 1 次配额
+- [ ] 消耗优先级:优惠券 > 额外配额(快过期优先)> 基础配额
+- [ ] 日/月/年配额分别校验,任一超限均拒绝
+- [ ] 优惠券不足时自动回退到配额扣减
+- [ ] 额外配额过期后不可用
+
+### 9.3 会员等级变更
+
+- [ ] 购买 PRO 月卡 → 有效期 30 天,等级变为 PRO
+- [ ] 购买 PRO 年卡 → 有效期 365 天
+- [ ] 同等级续期 → 到期时间叠加
+- [ ] PLUS→PRO 升级 → 旧 PLUS 记录停用,新 PRO 记录生效
+- [ ] PRO→ULTRA 升级 → 旧 PRO 记录停用
+- [ ] ULTRA 获得 PRO 奖励 → 不降级,续期 ULTRA
+- [ ] PRO/ULTRA 到期 → 自动降为 PLUS,配额恢复
+- [ ] 定时任务每 5 分钟正确扫描并处理过期会员
+
+### 9.4 邀请奖励
+
+- [ ] 邀请 1 人注册 → 邀请人获赠 1 个月 PRO
+- [ ] 邀请达到总奖励上限后,可继续邀请但不发奖励
+- [ ] 不能邀请自己
+- [ ] 被邀请人不可被重复邀请
+- [ ] 注册后 30 天内可补填邀请码
+- [ ] 补填后邀请人和被邀请人均获得对应奖励
+
+### 9.5 付费与运营
+
+- [ ] PRO 月卡售价 ¥29.90 / 30 天
+- [ ] PRO 年卡售价 ¥299.90 / 365 天
+- [ ] 运营可手动发放任意等级和时长
+- [ ] 运营可发放额外爬虫配额
+- [ ] 运营可发放优惠券
+
+---
+
+*本文档基于源码自动生成,如有代码变更请同步更新。*

+ 17 - 4
zhijiayun-user/src/main/java/com/xuekairui/user/security/JwtAuthenticationFilter.java

@@ -104,7 +104,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
                         request.setAttribute("userId", userId);
                         request.setAttribute("userId", userId);
 
 
                         // 单设备登录校验:验证 token 中的版本号与当前最新版本一致
                         // 单设备登录校验:验证 token 中的版本号与当前最新版本一致
-                        if (!isTokenVersionValid(userId, tokenVer)) {
+                        if (!isTokenVersionValid(userId, tokenVer, role)) {
                             log.info("用户 {} 的Token版本号不匹配(已在其他设备登录),拒绝访问", userId);
                             log.info("用户 {} 的Token版本号不匹配(已在其他设备登录),拒绝访问", userId);
                             writeKickedResponse(response);
                             writeKickedResponse(response);
                             return;
                             return;
@@ -149,11 +149,15 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
     }
     }
 
 
     /**
     /**
-     * 校验 Token 版本号是否有效(单设备登录控制)
+     * 校验 Token 版本号是否有效(单设备/多设备登录控制)
+     * <p>
+     * 管理员(ADMIN / SUPER_ADMIN)允许最多 5 台设备同时登录:
+     * 接受 [currentVersion-4, currentVersion] 范围内的版本号(最近 5 次登录)。
+     * 普通用户(USER)保持单设备登录,精确匹配。
      * <p>
      * <p>
      * 优先从内存缓存读取,缓存未命中时查 DB 并回填缓存。
      * 优先从内存缓存读取,缓存未命中时查 DB 并回填缓存。
      */
      */
-    private boolean isTokenVersionValid(Long userId, Long tokenVersion) {
+    private boolean isTokenVersionValid(Long userId, Long tokenVersion, String role) {
         if (userId == null) {
         if (userId == null) {
             return false;
             return false;
         }
         }
@@ -168,7 +172,16 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
             currentVersion = user.getTokenVersion() != null ? user.getTokenVersion() : 0L;
             currentVersion = user.getTokenVersion() != null ? user.getTokenVersion() : 0L;
             tokenVersionCache.putVersion(userId, currentVersion);
             tokenVersionCache.putVersion(userId, currentVersion);
         }
         }
-        return currentVersion.equals(tokenVersion != null ? tokenVersion : 0L);
+
+        long tokenVer = tokenVersion != null ? tokenVersion : 0L;
+
+        // 管理员允许最多 5 台设备:接受最近 5 个版本号
+        if ("ADMIN".equals(role) || "SUPER_ADMIN".equals(role)) {
+            return tokenVer <= currentVersion && (currentVersion - tokenVer) < 5;
+        }
+
+        // 普通用户保持单设备:精确匹配
+        return currentVersion.equals(tokenVer);
     }
     }
 
 
     /**
     /**

+ 7 - 1
zhijiayun-user/src/main/java/com/xuekairui/user/service/AuthService.java

@@ -356,7 +356,13 @@ public class AuthService {
 
 
         // 检测是否存在旧会话
         // 检测是否存在旧会话
         long oldVersion = user.getTokenVersion() != null ? user.getTokenVersion() : 0L;
         long oldVersion = user.getTokenVersion() != null ? user.getTokenVersion() : 0L;
-        boolean hadPreviousSession = oldVersion > 0;
+        boolean hadPreviousSession;
+        if ("ADMIN".equals(roleCode) || "SUPER_ADMIN".equals(roleCode)) {
+            // 管理员允许多设备:仅当旧版本号 ≥ 5 时说明有其他设备被挤出
+            hadPreviousSession = oldVersion >= 5;
+        } else {
+            hadPreviousSession = oldVersion > 0;
+        }
 
 
         // 递增 tokenVersion,使所有旧 Token 立即失效
         // 递增 tokenVersion,使所有旧 Token 立即失效
         long newVersion = oldVersion + 1;
         long newVersion = oldVersion + 1;