Ver código fonte

docs: 平台终身会员(LIFETIME)设计文档(方案A: 等级扩展+实时聚合+管理员审核)

Xiaogang Liao 1 mês atrás
pai
commit
36a3339be6

+ 227 - 0
docs/superpowers/specs/2026-08-09-lifetime-membership-design.md

@@ -0,0 +1,227 @@
+# 平台终身会员设计(LIFETIME)
+
+日期:2026-08-09
+状态:已获用户批准(方案 A + 规则确认)
+
+## 1. 背景与目标
+
+平台现有会员等级体系(FREE/FAMILY/PREMIUM/PROVIDER)为订阅制,到期需续费。现新增**平台终身会员(LIFETIME)**:用户年度在平台消费满 100 万,或推广团队销售满 200 万,经管理员审核后升级为终身会员,永久享受 FAMILY 会员全部权益,无需再续费。
+
+同时修正现存价格来源矛盾:`getPricingPlans()` 目前从 `sys_config` 取价,而 `membership_levels` 表的价格字段未被真正使用。本次将**会员费用(含原价)统一迁移到 `membership_levels` 表**(membership-center 管理端的数据源)。
+
+### 已确认需求
+
+1. 方案 A:`LIFETIME` 等级 + 实时聚合统计 + 管理员审核升级
+2. 消费口径:全部订单类型(商城/测评/套餐/会员/活动),仅 `status='paid'` 的已支付订单,滚动 365 天窗口
+3. 销售口径:推广团队销售额(通过 `referralCode` 关联的下线订单总额),同样滚动 365 天
+4. 达成方式:系统计算达标候选 → 管理员在管理端审核确认 → 升级为 LIFETIME
+5. 权益:终身的 FAMILY 会员(features 配置同 FAMILY,永久有效免续费)
+6. 费用迁移:`membership_levels` 增加原价字段;`getPricingPlans()` 改读此表;sys_config 旧键保留但不再引用
+
+### 范围外(明确不做)
+
+- 不做终身会员自动判定(必须管理员审核)
+- 不做终身会员的自动续费/扣费(永久有效)
+- 不删除 sys_config 中既有 `member_fee_*` 键(仅代码停止引用,避免影响其他读取方)
+- 不新增支付方式
+
+## 2. 数据库变更(`membership_levels` 表)
+
+### 2.1 新增原价字段(迁移 203)
+
+```sql
+ALTER TABLE membership_levels
+  ADD COLUMN original_price_monthly INT COMMENT '原价-月费(分)',
+  ADD COLUMN original_price_quarterly INT COMMENT '原价-季费(分)',
+  ADD COLUMN original_price_yearly INT COMMENT '原价-年费(分)';
+```
+
+- DatabaseInitializer 迁移 203(幂等,`ensureColumn` 模式)+ schema.sql 同步
+- 同步数据:将 sys_config 现值幂等写入对应等级行
+  - FAMILY:`original_price_yearly` ← `member_fee_family_original_yearly`(兜底 `member_fee_family_original`,默认 199900)
+  - PREMIUM:`original_price_yearly` ← `member_fee_premium_original`(默认 1599900);`original_price_monthly` ← `member_fee_premium_original_monthly`(默认 159900)
+  - 现价字段 `price_monthly/quarterly/yearly` 同步用 sys_config 现值补齐(FAMILY 年费 131400、PREMIUM 年费 1316800 等,幂等 UPDATE 仅当表内为 0/NULL 时)
+
+### 2.2 新增 LIFETIME 等级行(迁移 204)
+
+```sql
+INSERT IGNORE INTO membership_levels
+  (level_code, level_name, level_desc, price_monthly, price_quarterly, price_yearly,
+   original_price_monthly, original_price_quarterly, original_price_yearly,
+   features, max_children, max_tasks_per_day, ai_review_enabled, priority_support, teacher_consultation)
+VALUES
+  ('LIFETIME', '终身会员', '终身家庭会员权益,永久有效无需续费', 0, 0, 0, 0, 0, 0,
+   '["free_activities","free_courses","full_price_purchase","referral_commission","discount_purchase","member_activities","create_family","invite_family","purchase_commission"]',
+   6, 20, 1, 1, 1);
+```
+
+- `features` 与 FAMILY 一致(终身 = FAMILY 权益)
+- 价格全 0(不可购买,仅管理员授予)
+- DatabaseInitializer 迁移 204 + schema.sql 同步
+
+## 3. 费用来源切换(`getPricingPlans()` 改造)
+
+`MembershipController.getPricingPlans()`(L317)改为**统一从 `membership_levels` 表读取价格与显示字段**,不再读 sys_config:
+
+```java
+// 每个 level 的 plan 字段:
+plan.put("levelCode", level.getLevelCode());
+plan.put("levelName", level.getLevelName());
+plan.put("levelDesc", level.getLevelDesc());
+plan.put("recommended", "FAMILY".equals(level.getLevelCode()));
+plan.put("yearly", level.getPriceYearly());          // 不再从 sys_config 读
+plan.put("monthly", level.getPriceMonthly());
+plan.put("quarterly", level.getPriceQuarterly());
+plan.put("originalYearly", level.getOriginalPriceYearly());
+plan.put("originalMonthly", level.getOriginalPriceMonthly());
+plan.put("originalQuarterly", level.getOriginalPriceQuarterly());
+```
+
+关键约束:
+
+- **过滤 LIFETIME**:终身会员不可购买,`getPricingPlans()` 跳过 `levelCode='LIFETIME'` 的行(不返回给小程序购买列表)
+- 返回结构不变(levelCode/levelName/levelDesc/recommended/yearly/monthly/quarterly/originalYearly/originalMonthly/originalQuarterly),前端 pay/upgrade 页无感知
+- `MembershipLevel` entity 增加 `originalPriceMonthly/Quarterly/Yearly` 三个字段(对应表列)
+
+### 数据兜底
+
+表内价格字段为 NULL/0 时,plan 返回 null(前端 `showOriginalPrice()` 已有 `original > current*100` 才展示划线的逻辑,NULL/0 自然不显示划线)。
+
+## 4. 终身会员判定逻辑(天然生效,零改动核心)
+
+现有逻辑已满足 LIFETIME 语义,**无需修改**:
+
+| 组件 | 现有行为 | LIFETIME 效果 |
+|------|---------|--------------|
+| `MembershipService.getMemberLevel()` (L767) | 仅 `"FAMILY"` 且 `memberExpireTime` 非空才检查过期 | LIFETIME 跳过过期检查 → 永久有效 ✅ |
+| `MembershipService.canUseFeature()` (L166) | FREE/FAMILY/PREMIUM 分支后兜底 `return true` | LIFETIME 落到兜底 → 全功能放行 ✅ |
+| `MembershipService.hasPermission()` (L228) | 查 `membership_levels` 表 features | LIFETIME 行 features 同 FAMILY → 权限一致 ✅ |
+| `getMyMembership` (MembershipController L65) | 返回顶层 `memberLevel` | 返回 `LIFETIME`,前端可识别 ✅ |
+
+### 授予写入路径(管理员审核通过时)
+
+```java
+// 升级目标用户 = 家庭创建者(与消费归属口径一致)
+User adminUser = ...;  // family.getCreatorId()
+String fromLevel = adminUser.getMemberLevel() != null ? adminUser.getMemberLevel() : "FREE";
+adminUser.setMemberLevel("LIFETIME");
+adminUser.setMemberExpireTime(null);   // NULL = 永久有效
+adminUser.setUpdatedAt(new Date());
+userMapper.updateById(adminUser);
+
+// 升级记录 upgradeType='lifetime'
+MemberUpgradeRecord record = new MemberUpgradeRecord();
+record.setUserId(adminUserId);
+record.setFromLevel(fromLevel);
+record.setToLevel("LIFETIME");
+record.setUpgradeType("lifetime");
+record.setRemark("平台终身会员:消费达标/销售达标,管理员审核授予");
+memberUpgradeRecordMapper.insert(record);
+```
+
+`MemberUpgradeRecord.upgradeType` 现有枚举 `pay/consumption/admin` 增加 `lifetime` 值(字符串字段,无 DB 约束,直接复用)。
+
+## 5. 消费/销售统计(实时聚合,滚动 365 天)
+
+### 5.1 消费统计(每用户)
+
+5 张订单表 UNION 聚合,过滤 `status='paid'` 且 `paid_at >= now-365d`,按用户归属求和:
+
+| 订单表 | 用户字段 | 金额字段 | 状态字段 |
+|--------|---------|---------|---------|
+| `product_orders` | `buyer_id` | `total_amount` | `status='paid'` |
+| `assessment_orders` | `user_id` | `amount` | `status='paid'` |
+| `package_orders` | `user_id` | `price` | `status='paid'` |
+| `member_subscription_order` | `family_id` → 家庭创建者 | `amount` | `status='paid'` |
+| `activity_orders` | `user_id` | `total_amount` | `status='paid'` |
+
+> `member_subscription_order` 无用户字段,通过 `family_id` 关联 `family.creator_id` 归属到用户(与 `getFamilyMembership` 口径一致)。
+
+阈值:**消费 ≥ 1,000,000 元**(内部 `100_000_000` 分)。
+
+### 5.2 销售统计(每用户 = 推广团队销售额)
+
+- 找出该用户 `referralCode` 关联的所有下线用户(`user.referrer_id = 该用户.id`,一级推广关系)
+- 汇总这些下线的**全部订单消费总额**(复用 5.1 的聚合逻辑,按下线 buyer 归属到该用户)
+- 阈值:**销售 ≥ 2,000,000 元**(内部 `200_000_000` 分)
+
+### 5.3 候选查询实现
+
+管理端接口 `POST /api/admin/membership/lifetime-candidates`:
+
+- 对全部用户(或指定筛选:时间/关键字),计算每个用户的消费额 + 销售额
+- 返回达标候选列表:`userId / 昵称 / 家庭 / 消费额 / 销售额 / 达标类型(consumption|sales|both)`
+- 实现方式:Mapper XML 写 SQL(UNION ALL 5 表 + LEFT JOIN family/user + 汇总),或 Service 内分表查询汇总(数据量可控,推荐 SQL 聚合)
+- 性能:滚动 365 天窗口,`paid_at` 建索引即可;低频管理操作,实时计算可接受
+
+## 6. 管理端审核(MembershipCenter.vue)
+
+### 6.1 新增后端接口(AdminController)
+
+```java
+// 查询终身会员达标候选(分页)
+@PostMapping("/membership/lifetime-candidates")
+public Result<Page<Map<String, Object>>> lifetimeCandidates(@RequestBody Map<String, Object> params)
+
+// 审核授予终身会员
+@PostMapping("/membership/lifetime-grant")
+public Result<Void> lifetimeGrant(@RequestBody Map<String, Object> params)  // { userId, remark }
+```
+
+`lifetime-grant` 校验:用户存在、未是 LIFETIME;写入 user.memberLevel='LIFETIME' + memberExpireTime=null + 升级记录(见 §4);返回成功后管理端刷新列表。
+
+### 6.2 前端页面(MembershipCenter.vue)
+
+- 新增「终身会员管理」卡片:
+  - **候选列表**:表格(用户/消费额/销售额/达标类型),每行「授予」按钮 + 确认弹窗(提示授予后永久有效)
+  - **已授予列表**:`/api/admin/membership/upgrade-records` 过滤 `toLevel='LIFETIME'` 展示(复用现有接口)
+- 概览统计卡新增「终身会员数」(overview 增加 `lifetimeCount`,按 `user.memberLevel='LIFETIME'` 计数)
+
+## 7. 小程序端展示
+
+### 7.1 upgrade.vue(套餐选择页)
+
+- `loadData()` 已用 `memberLevel !== 'FREE'` 判断非免费 → LIFETIME 自然进入"非免费"分支,显示会员状态卡 ✅
+- **新增 LIFETIME 分支处理**:
+  - 状态卡到期时间:LIFETIME 显示「永久有效」(`membership.memberExpireTime` 为 null 时)
+  - 续费卡片(v-else 分支):LIFETIME 隐藏「续费会员」卡片与「下一步」按钮,改为显示「您已是终身会员」提示文案
+- `getLevelName()` map 增加 `LIFETIME: '终身会员'`
+
+### 7.2 index.vue(会员中心)与 result.vue
+
+- index.vue:等级名称显示识别 LIFETIME(如 `getLevelName` 有对应映射则自然生效);若有「立即续费/升级」入口需对 LIFETIME 隐藏
+- result.vue:不涉及(终身会员非支付链路)
+
+### 7.3 benefits.vue(我的权益)
+
+- 终身会员权益列表:LIFETIME 行 features 同 FAMILY,权益接口走表配置自然返回,无需改动
+
+## 8. 验收标准
+
+### 后端(mvn clean compile 通过)
+
+- [ ] `MembershipLevel` entity 含 `originalPrice*` 3 字段;`membership_levels` 表含 3 原价列 + LIFETIME 行(迁移 203/204,幂等)
+- [ ] `getPricingPlans()` 从 `membership_levels` 读价(含原价),过滤 LIFETIME,返回结构不变
+- [ ] `lifetime-candidates` 返回滚动 365 天消费额 + 推广团队销售额双指标候选,阈值正确(分单位)
+- [ ] `lifetime-grant` 授予后:user.memberLevel='LIFETIME'、memberExpireTime=null、升级记录 upgradeType='lifetime'
+- [ ] `getMemberLevel()` 对 LIFETIME 返回 'LIFETIME' 且不因过期降级
+- [ ] AdminController overview 含 `lifetimeCount`
+
+### 前端(node --check 语法校验,不打包)
+
+- [ ] upgrade.vue:LIFETIME 显示「永久有效」、隐藏续费卡片、无「下一步」
+- [ ] index.vue:LIFETIME 无续费/升级入口
+- [ ] MembershipCenter.vue:终身会员管理卡片(候选 + 授予 + 已授予列表)
+- [ ] 无新增可选链 `?.`、`:key` 表达式、CSS Grid、直接 `new Date(string)`(遵守小程序限制)
+
+### 数据校验(手动核对)
+
+- [ ] 会员价格/原价迁移后,plans 接口返回值与迁移前一致(sys_config → membership_levels 幂等迁移)
+- [ ] LIFETIME 用户在小程序端显示为终身会员、到期时间「永久有效」
+
+## 9. 风险与注意
+
+- **统计口径边界**:`member_subscription_order` 按家庭创建者归属;退款订单(status='refunded')不计入(仅统计 paid)
+- **销售统计仅一级推广**:`referrer_id` 直接关联的下线(现有推广体系为两级,但销售口径按用户确认=「推广团队」定义为推荐码直接下线;如后续需含二级,扩展 SQL 即可)
+- **plans 接口过滤 LIFETIME**:防止终身会员出现在购买列表(价格全 0 会造成误解)
+- **sys_config 旧键保留**:不删除,避免影响其他潜在读取方(如管理端配置页面展示)