liuchengsen 2 месяцев назад
Родитель
Сommit
99eaa49221

+ 80 - 25
docs/01-产品文档.md

@@ -64,7 +64,7 @@
 - **跳转原平台**:点击"去购买"跳转到原平台完成交易,智价云不提供交易服务
 - **会员体系**:PLUS/PRO/ULTRA三档会员,权益驱动爬虫配额
 - **支付闭环**:支持微信/支付宝扫码支付购买会员套餐
-- **裂变增长**:邀请奖励为会员时长,邀请人获赠1个月普通会员
+- **裂变增长**:邀请奖励为会员时长,邀请人获赠1个月高级会员(PRO)
 - **用户粘性**:通过多渠道触达,提高用户使用频次和留存率
 
 ---
@@ -85,20 +85,62 @@
 
 ### 2.2 会员体系
 
-系统采用**独立会员体系**,会员等级驱动爬虫配额权益:
+系统采用**三级会员体系**,会员等级驱动爬虫配额权益:
 
-| 会员等级 | 编码 | 每日配额 | 每月配额 | 每年配额 | 最大并发 | 适用场景 |
-|----------|------|----------|----------|----------|----------|----------|
-| 普通会员 | PLUS | 10次 | 300次 | 3650次 | - | 小型药店日常采购 |
-| 高级会员 | PRO | 999次 | 29970次 | 364635次 | 5 | 中型药店高频采购 |
-| 超级会员 | ULTRA | 999次 | 29970次 | 364635次 | 10 | 大型连锁药店批量采购 |
+| 会员等级 | 编码 | 权重 | 每日配额 | 每月配额 | 每年配额 | 最大并发 | 过期时间 | 适用场景 |
+|----------|------|------|----------|----------|----------|----------|----------|----------|
+| 普通会员 | PLUS | 1 | 10次 | 300次 | 3650次 | 1 | 无(永久) | 注册即得,限时免费 |
+| 高级会员 | PRO | 2 | 999次 | 29970次 | 364635次 | 5 | 有(购买/赠送) | 付费会员,核心变现 |
+| 超级会员 | ULTRA | 3 | 999次 | 29970次 | 364635次 | 10 | 有(运营发放) | 连锁专用,待开放购买 |
 
-**会员来源**:
-- `ADMIN_GRANT`:运营手动发放
-- `RECHARGE`:用户充值购买
-- `SYSTEM`:系统赠送(如新用户注册赠送、邀请奖励)
+**获取方式**:
 
-**会员有效期**:支持按天/月设置有效期,到期自动失效。
+| 等级 | 获取方式 |
+|------|---------|
+| PLUS | 用户注册即自动获得,不限时长,永久免费 |
+| PRO | ① 付费购买(月卡30天/年卡365天)② 邀请奖励(1个月)③ 营业执照审核通过(1个月)④ 运营手动发放 |
+| ULTRA | 运营手动发放(购买入口待开放) |
+
+**互斥与叠加规则**:
+
+- 普通会员、高级会员、超级会员**不可同时生效**,同一时间只有一个活跃等级
+- 高等级权限优先级更高:`ULTRA > PRO > PLUS`
+- **同等级续期**:在原到期时间基础上叠加天数(购买月卡+邀请奖励 → 60天)
+- **升级**:低等级→高等级时停用旧会员,创建新记录(PLUS→PRO 或 PRO→ULTRA)
+- **不降级保护**:已是高等级时,赠送的低等级奖励自动转为续期高等级(ULTRA用户获得邀请奖励 → 续期ULTRA 30天,不降为PRO)
+
+**到期与降级**:
+
+- 高级会员到期后,账号自动降级为普通会员(PLUS),不再享有不限量配额
+- 定时任务每5分钟扫描过期会员记录,自动执行降级
+- `t_user` 表与 `t_user_membership` 表通过 `membership_expire_at` 字段实时同步
+
+**会员数据存储**:
+
+| 表 | 关键字段 | 说明 |
+|----|---------|------|
+| `t_user` | `membership_level`, `membership_expire_at` | 用户当前等级与过期时间(冗余,实时同步) |
+| `t_user_membership` | `level`, `effective_from`, `effective_to`, `status`, `source` | 会员记录权威来源 |
+
+**会员来源(source)**:
+
+| 来源 | 说明 |
+|------|------|
+| `RECHARGE` | 用户付费购买 |
+| `INVITE` | 邀请奖励赠送 |
+| `LICENSE` | 营业执照审核通过赠送 |
+| `ADMIN_GRANT` | 运营手动发放 |
+| `SYSTEM` | 系统默认(普通会员PLUS) |
+
+**付费套餐(当前开放)**:
+
+| 方案编码 | 方案名称 | 会员等级 | 价格 | 原价 | 有效天数 |
+|----------|----------|----------|------|------|----------|
+| MONTHLY_PRO | 高级会员·月卡 | PRO | ¥29.90 | ¥59.90 | 30天 |
+| YEARLY_PRO | 高级会员·年卡 | PRO | ¥299.90 | ¥599.90 | 365天 |
+| ULTRA | 超级会员·连锁专享 | ULTRA | - | - | 敬请期待 |
+
+> **说明**:普通会员(PLUS)免费开放,无付费套餐。高级会员(PRO)提供月卡和年卡两种套餐。超级会员(ULTRA)暂未开放购买。
 
 **用户侧接口**:
 
@@ -118,7 +160,7 @@
 
 | 来源 | 类型标识 | 过期策略 | 说明 |
 |------|----------|----------|------|
-| 邀请奖励 | INVITE | 永不过期 | 历史遗留的爬虫次数奖励(当前邀请奖励已改为会员时长) |
+| 邀请奖励 | INVITE | 永不过期 | 运营配置的额外爬虫次数(当前默认0,仅送会员不送配额) |
 | 购买会员 | PURCHASE | 可配置 | 购买/升级会员时发放 |
 | 运营发放 | ADMIN | 可配置 | 运营手动为指定用户发放 |
 
@@ -206,16 +248,25 @@
 药店A获取邀请链接 → 分享给其他药店B 
 → B打开落地页(显示A的药店名称和奖励说明)
 → B下载客户端并注册(自动填入邀请码)
-→ A获得会员时长奖励(邀请人1个月普通会员)
-→ B获得新用户注册赠送(2个月普通会员)
+→ A获得奖励:1个月高级会员(PRO) + 可选额外爬虫配额
+→ B注册即得普通会员(PLUS),永久免费
 ```
 
 #### 奖励机制
 
 | 角色 | 奖励内容 | 说明 |
 |------|----------|------|
-| 邀请人 | 1个月普通会员 | 每成功邀请1家药店注册即发放 |
-| 被邀请人 | 2个月普通会员 | 新用户注册即送1个月,填写邀请码加送1个月 |
+| 邀请人 | 1个月高级会员(PRO) + 可选额外爬虫配额 | 每成功邀请1家药店注册即发放,奖励立即到账 |
+| 被邀请人 | 普通会员(PLUS) | 注册即获得,不限时长,永久免费 |
+
+**邀请奖励规则**:
+- 老用户邀请新用户,完成注册后邀请人立即获得1个月高级会员时长
+- 奖励会员等级固定为PRO(高级会员),与邀请人当前等级无关
+- **不降级保护**:若邀请人已是ULTRA,自动续期ULTRA 30天,不降为PRO
+- 额外爬虫配额由运营后台配置(`rewardCrawlerCount`),默认0=不额外发放
+- 单个账号获得会员时长奖励上限30人(超限可继续邀请但不再发放奖励)
+- **被邀请人限制**:注册30天内可输入邀请码,每人仅能填写一次,二次无效
+- **补填邀请码**:注册时未填,30天内可在个人中心补填,邀请人同样获得奖励
 
 #### 支持渠道
 
@@ -277,6 +328,8 @@
 
 **营业执照信息**:药店名称、统一社会信用代码、法人姓名、注册地址、经营范围、有效期等。
 
+**审核通过奖励**:首次营业执照审核通过,自动赠送30天高级会员(PRO),来源标记为 `LICENSE`。
+
 **用户侧接口**:
 
 | 接口 | 说明 |
@@ -327,15 +380,15 @@
 
 > **⚠️ 当前状态**:生产环境微信/支付宝回调接口为占位实现(含TODO),当前仅模拟支付模式可用。上线前需接入微信支付SDK和支付宝SDK完成验签与解密。
 
-**支付方案(初始套餐)**:
+**支付方案(当前开放)**:
 
 | 方案编码 | 方案名称 | 会员等级 | 价格 | 原价 | 有效天数 |
 |----------|----------|----------|------|------|----------|
-| MONTHLY_PLUS | 普通会员·月卡 | PLUS | ¥9.90 | ¥19.90 | 30天 |
-| YEARLY_PLUS | 普通会员·年卡 | PLUS | ¥99.00 | ¥199.90 | 365天 |
 | MONTHLY_PRO | 高级会员·月卡 | PRO | ¥29.90 | ¥59.90 | 30天 |
 | YEARLY_PRO | 高级会员·年卡 | PRO | ¥299.90 | ¥599.90 | 365天 |
-| ULTRA | 超级会员·连锁专享 | ULTRA | - | - | 连锁专用(敬请期待) |
+| ULTRA | 超级会员·连锁专享 | ULTRA | - | - | 敬请期待 |
+
+> **说明**:普通会员(PLUS)免费开放,注册即得,不提供付费套餐。月卡自付款日起算30天,年卡自付款日起算365天。
 
 **运营管理**:
 
@@ -465,9 +518,10 @@
 药店A获取邀请链接 → 复制链接分享到微信群/朋友圈
 → 药店B浏览器打开 → 展示落地页(A的药店名称 + 奖励说明 + 下载链接)
 → B下载注册 → 携带邀请码 → 建立邀请关系 → 发放奖励
-  ├── 邀请人A:获赠1个月普通会员
-  └── 被邀请人B:获赠2个月普通会员(注册1个月 + 邀请码1个月)
-→ A查看统计:"已邀请X家药店,Y家注册,累计获得Y个月会员"
+  ├── 邀请人A:获赠1个月高级会员(PRO) + 可选额外爬虫配额(立即到账)
+  └── 被邀请人B:注册即得普通会员(PLUS),永久免费
+→ A查看统计:"已邀请X家药店,Y家注册,累计获得Y个月高级会员"
+→ A同时可在邀请页查看邀请贡献的爬虫配额明细
 ```
 
 ### 3.5 会员购买流程
@@ -476,7 +530,8 @@
 用户查看会员套餐 → 选择方案(月卡/年卡)
 → 选择支付渠道(微信/支付宝)→ 创建订单 → 生成二维码
 → 用户扫码支付 → 第三方回调通知 → 订单状态更新为已支付
-→ 自动发放对应会员等级 → 配额立即生效
+→ 自动激活高级会员(PRO) → t_user 会员等级与过期时间实时同步 → 配额立即生效
+→ 同等级续期叠加天数,低等级升级切换
 ```
 
 ### 3.6 重复支付与自动退款流程

+ 27 - 23
zhijiayun-invite/src/main/java/com/xuekairui/invite/service/InviteService.java

@@ -533,18 +533,12 @@ public class InviteService {
         inviteCode.setUsedCount(inviteCode.getUsedCount() + 1);
         inviteCodeMapper.updateById(inviteCode);
 
-        // 8. 给邀请人发放奖励(在奖励上限内赠送30天会员时长+额外爬虫配额
+        // 8. 给邀请人发放奖励(在奖励上限内)
         if (rewardAllowed) {
-            grantInviteReward(inviterId, inviteeId);
-            // 发放额外爬虫配额(由运营后台配置每次邀请赠送次数,默认0=不送)
-            int crawlerReward = config.getRewardCrawlerCount() != null ? config.getRewardCrawlerCount() : 0;
-            if (crawlerReward > 0) {
-                crawlerQuotaGrantService.grantInviteReward(inviterId, inviteeId, crawlerReward);
-            }
-            log.info("邀请成功: 用户 {} 邀请了用户 {},邀请人获赠30天会员时长 + {}次额外配额",
-                    inviterId, inviteeId, crawlerReward);
+            grantInviteReward(inviterId, inviteeId, config);
+            log.info("邀请成功: inviter={}, invitee={}", inviterId, inviteeId);
         } else {
-            log.info("邀请成功: 用户 {} 邀请了用户 {},已达奖励上限,不发放会员时长", inviterId, inviteeId);
+            log.info("邀请成功(已达奖励上限): inviter={}, invitee={}", inviterId, inviteeId);
         }
     }
 
@@ -643,29 +637,39 @@ public class InviteService {
         inviteCode.setUsedCount(inviteCode.getUsedCount() + 1);
         inviteCodeMapper.updateById(inviteCode);
 
-        // 10. 发放奖励:邀请人30天会员时长 + 额外爬虫配额(在奖励上限内)
+        // 10. 发放奖励(在奖励上限内)
         if (rewardAllowed) {
-            membershipService.grantInviteRewardMembership(inviterId, userId);
-            // 发放额外爬虫配额
-            int crawlerReward = config.getRewardCrawlerCount() != null ? config.getRewardCrawlerCount() : 0;
-            if (crawlerReward > 0) {
-                crawlerQuotaGrantService.grantInviteReward(inviterId, userId, crawlerReward);
-            }
-            log.info("补填邀请码成功: 用户{}绑定邀请码{},邀请人{},邀请人获赠30天会员时长 + {}次额外配额",
-                    userId, inviteCodeStr, inviterId, crawlerReward);
+            grantInviteReward(inviterId, userId, config);
+            log.info("补填邀请码成功: invitee={}, inviter={}", userId, inviterId);
             return "补填邀请码成功!邀请人获赠1个月会员时长";
         } else {
-            log.info("补填邀请码成功: 用户{}绑定邀请码{},邀请人{},已达奖励上限,不发放会员时长",
-                    userId, inviteCodeStr, inviterId);
+            log.info("补填邀请码成功(已达奖励上限): invitee={}, inviter={}", userId, inviterId);
             return "补填邀请码成功!邀请人已达奖励上限,本次不发放会员时长";
         }
     }
 
+    // ==========================================
+    // 邀请奖励统一入口
+    // ==========================================
+
     /**
-     * 发放邀请奖励:邀请人30天高级会员
+     * 发放邀请奖励(统一入口)
+     * <p>
+     * 包含:1个月高级会员(PRO) + 可选额外爬虫配额。
+     * 由 processInviteRegistration 和 bindInviteCode 统一调用。
      */
-    private void grantInviteReward(Long inviterId, Long inviteeId) {
+    private void grantInviteReward(Long inviterId, Long inviteeId, InviteConfig config) {
+        // 1. 会员奖励:1个月高级会员(PRO)
         membershipService.grantInviteRewardMembership(inviterId, inviteeId);
+
+        // 2. 额外爬虫配额(由运营后台配置,默认0=不送)
+        int crawlerReward = config.getRewardCrawlerCount() != null ? config.getRewardCrawlerCount() : 0;
+        if (crawlerReward > 0) {
+            crawlerQuotaGrantService.grantInviteReward(inviterId, inviteeId, crawlerReward);
+        }
+
+        log.info("邀请奖励已发放: inviter={}, invitee={}, 会员=PRO/30天, 配额={}次",
+                inviterId, inviteeId, crawlerReward);
     }
 
     // ==========================================

+ 66 - 26
zhijiayun-user/src/main/java/com/xuekairui/user/service/MembershipService.java

@@ -154,28 +154,48 @@ public class MembershipService {
         }
 
         LocalDateTime newExpireAt;
+        MembershipLevel effectiveLevel = level;
 
         UserMembership existing = userMembershipMapper.selectActiveByUserId(userId);
-        if (existing != null && existing.getLevel() == level) {
-            // 同等级续期:在原到期时间基础上追加
-            LocalDateTime base = existing.getEffectiveTo() != null
-                    && existing.getEffectiveTo().isAfter(LocalDateTime.now())
-                    ? existing.getEffectiveTo()
-                    : LocalDateTime.now();
-            existing.setEffectiveTo(base.plusDays(days));
-            userMembershipMapper.updateById(existing);
-            newExpireAt = existing.getEffectiveTo();
-        } else {
-            // 互斥:停用所有旧会员
-            List<UserMembership> activeList = userMembershipMapper.selectList(
-                    new LambdaQueryWrapper<UserMembership>()
-                            .eq(UserMembership::getUserId, userId)
-                            .eq(UserMembership::getStatus, 1));
-            for (UserMembership active : activeList) {
-                active.setStatus(0);
-                userMembershipMapper.updateById(active);
+        if (existing != null) {
+            if (existing.getLevel() == level) {
+                // 同等级续期:在原到期时间基础上追加
+                LocalDateTime base = existing.getEffectiveTo() != null
+                        && existing.getEffectiveTo().isAfter(LocalDateTime.now())
+                        ? existing.getEffectiveTo()
+                        : LocalDateTime.now();
+                existing.setEffectiveTo(base.plusDays(days));
+                userMembershipMapper.updateById(existing);
+                newExpireAt = existing.getEffectiveTo();
+            } else if (existing.getLevel().getWeight() > level.getWeight()) {
+                // 已有更高等级:不降级,直接续期更高等级(如ULTRA用户邀请奖励仍保持ULTRA)
+                LocalDateTime base = existing.getEffectiveTo() != null
+                        && existing.getEffectiveTo().isAfter(LocalDateTime.now())
+                        ? existing.getEffectiveTo()
+                        : LocalDateTime.now();
+                existing.setEffectiveTo(base.plusDays(days));
+                userMembershipMapper.updateById(existing);
+                newExpireAt = existing.getEffectiveTo();
+                effectiveLevel = existing.getLevel();
+            } else {
+                // 升级:停用所有低等级旧会员,创建新记录
+                deactivateAllActive(userId);
+                LocalDateTime now = LocalDateTime.now();
+                UserMembership membership = UserMembership.builder()
+                        .userId(userId)
+                        .level(level)
+                        .source(source)
+                        .effectiveFrom(now)
+                        .effectiveTo(now.plusDays(days))
+                        .status(1)
+                        .remark(remark)
+                        .build();
+                userMembershipMapper.insert(membership);
+                newExpireAt = membership.getEffectiveTo();
             }
-
+        } else {
+            // 无活跃会员:创建新记录
+            deactivateAllActive(userId);
             LocalDateTime now = LocalDateTime.now();
             UserMembership membership = UserMembership.builder()
                     .userId(userId)
@@ -190,15 +210,21 @@ public class MembershipService {
             newExpireAt = membership.getEffectiveTo();
         }
 
-        // 同步用户表:等级 + 过期时间
-        // 仅当新等级更高时升级
+        // 同步用户表:等级 + 过期时间(仅当等级变化或过期时间变化时更新)
+        boolean needUpdate = false;
         if (user.getMembershipLevel() == null
-                || user.getMembershipLevel().getWeight() < level.getWeight()) {
-            user.setMembershipLevel(level);
+                || user.getMembershipLevel().getWeight() < effectiveLevel.getWeight()) {
+            user.setMembershipLevel(effectiveLevel);
+            needUpdate = true;
+        }
+        if (user.getMembershipExpireAt() == null
+                || !user.getMembershipExpireAt().equals(newExpireAt)) {
+            user.setMembershipExpireAt(newExpireAt);
+            needUpdate = true;
+        }
+        if (needUpdate) {
+            userMapper.updateById(user);
         }
-        // 每次授予/续期都同步过期时间到用户表
-        user.setMembershipExpireAt(newExpireAt);
-        userMapper.updateById(user);
     }
 
     /**
@@ -286,6 +312,20 @@ public class MembershipService {
         return toResponse(membership, user);
     }
 
+    /**
+     * 停用用户所有活跃会员记录
+     */
+    private void deactivateAllActive(Long userId) {
+        List<UserMembership> activeList = userMembershipMapper.selectList(
+                new LambdaQueryWrapper<UserMembership>()
+                        .eq(UserMembership::getUserId, userId)
+                        .eq(UserMembership::getStatus, 1));
+        for (UserMembership active : activeList) {
+            active.setStatus(0);
+            userMembershipMapper.updateById(active);
+        }
+    }
+
     private MembershipResponse toResponse(UserMembership membership, User user) {
         if (membership == null) {
             return null;

+ 24 - 12
zhijiayun-user/src/main/java/com/xuekairui/user/service/UserLevelService.java

@@ -253,26 +253,38 @@ public class UserLevelService {
     /**
      * 填充会员过期信息到 UserLevelResponse
      * <p>
-     * 高级会员(PRO/ULTRA):从用户表读取 membershipExpireAt 计算剩余天数;
-     * 普通会员(PLUS):无过期时间,membershipDaysRemaining=-1。
+     * 根据已解析的等级结果(response.levelCode)来决定过期信息:
+     * - PLUS(普通会员):无过期时间概念,membershipExpireAt=null, daysRemaining=-1
+     * - PRO/ULTRA:从用户表 membershipExpireAt 读取,已过期则视为普通会员
      */
     private void fillMembershipExpiration(UserLevelResponse response, User user) {
-        if (user == null) {
+        if (user == null || response == null) {
             return;
         }
-        if (user.getMembershipExpireAt() != null) {
-            response.setMembershipExpireAt(user.getMembershipExpireAt());
-            long daysRemaining = ChronoUnit.DAYS.between(LocalDateTime.now(), user.getMembershipExpireAt());
-            response.setMembershipDaysRemaining(daysRemaining > 0 ? (int) daysRemaining : 0);
-        } else if (user.getMembershipLevel() != null
-                && user.getMembershipLevel() != MembershipLevel.PLUS) {
-            // 高级会员但无过期时间(永久有效),如运营发放的永久会员
+        // 根据已解析的等级判断:resolveUserLevel 已做过期检查
+        boolean isPremium = response.getLevelCode() != null
+                && !"PLUS".equals(response.getLevelCode());
+        if (!isPremium) {
+            // 普通会员或已过期降级:无过期时间概念
             response.setMembershipExpireAt(null);
             response.setMembershipDaysRemaining(-1);
+            return;
+        }
+        // 高级会员(PRO/ULTRA):必须有到期时间,不存在"永久高级会员"
+        LocalDateTime expireAt = user.getMembershipExpireAt();
+        if (expireAt != null && expireAt.isAfter(LocalDateTime.now())) {
+            response.setMembershipExpireAt(expireAt);
+            long daysRemaining = ChronoUnit.DAYS.between(LocalDateTime.now(), expireAt);
+            response.setMembershipDaysRemaining((int) daysRemaining);
+        } else if (expireAt != null) {
+            // 已过期:resolveUserLevel 应该已降级为 PLUS,兜底处理
+            response.setMembershipExpireAt(expireAt);
+            response.setMembershipDaysRemaining(0);
         } else {
-            // 普通会员,无过期时间概念
+            // expireAt = null 但 level 是 PRO/ULTRA:数据异常(DDL未执行或回填遗漏)
+            log.warn("高级会员缺少过期时间: userId={}, level={}", user.getId(), response.getLevelCode());
             response.setMembershipExpireAt(null);
-            response.setMembershipDaysRemaining(-1);
+            response.setMembershipDaysRemaining(0);
         }
     }