diff --git a/docs/design/.DS_Store b/docs/design/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/docs/design/.DS_Store differ diff --git a/docs/design/EVAL-技术架构评估总结.md b/docs/design/EVAL-技术架构评估总结.md index ddf3fdd..6430336 100644 --- a/docs/design/EVAL-技术架构评估总结.md +++ b/docs/design/EVAL-技术架构评估总结.md @@ -4,7 +4,7 @@ > 版本: v1.0 > 日期: 2026-03-04 > 作者: 张翔 -> 状态: 初稿 +> 状态: 正式发布 --- diff --git a/docs/design/HLD-付费订阅版系统概要设计.md b/docs/design/HLD-付费订阅版系统概要设计.md deleted file mode 100644 index 82d1ba5..0000000 --- a/docs/design/HLD-付费订阅版系统概要设计.md +++ /dev/null @@ -1,543 +0,0 @@ -# 健身房管理系统付费订阅版业务概要设计文档(HLD) - -> 文档编号: GYM-HLD-SUBSCRIPTION-001 -> 版本: v1.0 -> 日期: 2026-03-04 -> 作者: 张翔 -> 状态: 初稿 - ---- - -## 文档修订历史 - -| 版本 | 日期 | 作者 | 修订内容 | -| ---- | ---------- | ---- | ------------------ | -| v1.0 | 2026-03-04 | 张翔 | 创建付费订阅版业务概要设计 | - ---- - -## 一、引言 - -### 1.1 编写目的 - -本文档为健身房管理系统付费订阅版的业务概要设计文档(High-Level Design),旨在: - -1. 从业务层面描述付费订阅版的业务范围、业务流程、业务规则 -2. 为付费订阅版详细设计提供业务指导和约束 -3. 作为产品经理、业务分析师、开发人员的业务参考 - -### 1.2 项目背景 - -健身房管理系统付费订阅版在基础版基础上,提供丰富的增值功能,满足中大型健身房、连锁品牌等复杂场景需求。 - -### 1.3 术语定义 - -| 术语 | 定义 | -| ----------------------------- | ------------------------------------------------ | -| 租户(Tenant) | 系统的多租户架构中的独立业务实体,如一个连锁品牌 | -| 门店(Store) | 租户下的具体经营场所 | -| 会员(Member) | 在门店注册的用户 | -| 权益(Benefit) | 会员卡包含的时长、次数、储值、等级等权益 | -| 可预约资源(Bookable Resource) | 团课、私教、场地、线上课程等可被预约的对象 | -| 时段(Slot) | 资源的可预约时间窗口 | -| 订阅模块(Subscription Module) | 按需订阅的增值功能模块 | -| 配置继承(Configuration Inheritance) | 门店配置继承租户配置的机制 | - -### 1.4 参考文档 - -- 《健身房管理系统付费订阅版产品设计文档》 GYM-PRD-SUBSCRIPTION-001 -- 《健身房管理系统业务概要设计文档》 GYM-HLD-001 -- 《订阅与配置模块详细设计文档》 GYM-LLD-004 - ---- - -## 二、业务概述 - -### 2.1 业务目标 - -| 目标维度 | 目标描述 | 成功指标 | -| -------- | ---------------------- | -------------------------------- | -| 用户体验 | 提升会员预约和签到体验 | 预约成功率 ≥ 95%,签到耗时 ≤ 3秒 | -| 运营效率 | 降低人工操作成本 | 人工处理时间减少 50% | -| 数据价值 | 提供数据驱动决策支持 | 数据报表使用率 ≥ 80% | -| 业务增长 | 提升会员留存和增长 | 会员留存率提升 20% | - -### 2.2 用户角色 - -| 角色 | 描述 | 主要功能 | -| ---------- | -------------- | ---------------------------- | -| 会员 | 健身房注册用户 | 预约课程、签到、查看个人信息、参与社区 | -| 教练 | 健身房教练 | 排课、私教预约确认、学员签到、发布线上课程 | -| 前台 | 门店前台人员 | 会员接待、签到辅助、会员管理 | -| 店长 | 门店管理者 | 单店全功能管理、数据查看、营销活动管理 | -| 运营管理员 | 平台运营人员 | 营销活动配置、数据分析、AI运营建议查看 | -| 财务专员 | 财务人员 | 账单管理、财务报表 | -| 超级管理员 | 平台最高权限 | 全平台管理、系统配置 | - -### 2.3 业务范围 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 付费订阅版业务范围 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 基础功能(包含基础版所有功能) │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 会员管理 • 预约管理 • 签到管理 • 数据统计 • 系统管理 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 订阅与配置管理 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 订阅管理 • 配置管理 • 套餐管理 • 计费管理 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 业务扩展类模块 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 私教管理 • 场地预约 • 线上课程 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 体验升级类模块 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 人脸识别签到 • NFC签到 • 智能储物柜 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 营销增长类模块 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 营销活动 • 会员推荐奖励 • 会员互动社区 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 数据智能类模块 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 高级数据分析 • 智能报表 • AI运营建议 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 营销分析与预测模块 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 营销精算模型 • 促销策略预测 • 促销活动效果预测 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 三、核心业务流程 - -### 3.1 订阅流程 - -#### 3.1.1 业务场景 - -租户管理员通过管理后台订阅增值模块。 - -#### 3.1.2 业务流程 - -``` -┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ -│ 租户管理 │ → │ 查看订阅 │ → │ 选择订阅 │ → │ 确认订阅 │ → │ 模块立即 │ -│ 员登录 │ │ 套餐 │ │ 模块 │ │ │ │ 启用 │ -└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ -``` - -#### 3.1.3 业务规则 - -- 订阅成功后模块立即启用 -- 年付享受最大折扣 -- 支持多种支付方式 -- 订阅成功后发送通知 - -#### 3.1.4 异常处理 - -| 异常场景 | 处理方式 | -|---------|---------| -| 支付失败 | 提示用户重新支付 | -| 支付超时 | 提示用户重新发起支付 | - ---- - -### 3.2 配置继承流程 - -#### 3.2.1 业务场景 - -门店管理员配置门店级参数,可以选择继承租户级配置。 - -#### 3.2.2 业务流程 - -``` -┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ -│ 门店管理 │ → │ 查看租户 │ → │ 选择继承 │ → │ 配置门店 │ → │ 配置立即 │ -│ 员登录 │ │ 级配置 │ │ 模式 │ │ 级参数 │ │ 生效 │ -└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ -``` - -#### 3.2.3 业务规则 - -- 查询优先级:门店配置 → 租户配置 → 默认配置 -- 支持三种继承模式(继承/继承+覆盖/自定义) -- 配置变更后立即生效 -- 配置变更记录版本,支持回滚 - -#### 3.2.4 异常处理 - -| 异常场景 | 处理方式 | -|---------|---------| -| 配置冲突 | 提示用户选择覆盖或合并 | -| 配置无效 | 提示用户重新配置 | - ---- - -### 3.3 私教预约流程 - -#### 3.3.1 业务场景 - -会员通过小程序预约私教课程。 - -#### 3.3.2 业务流程 - -``` -┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ -│ 会员打开 │ → │ 查看私教 │ → │ 选择私教 │ → │ 确认预约 │ → │ 预约成功 │ -│ 小程序 │ │ 课程列表 │ │ 课程 │ │ │ │ │ -└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ -``` - -#### 3.3.3 业务规则 - -- 私教预约需提前至少24小时 -- 私教取消需提前至少12小时 -- 私教签到后记录考勤 - -#### 3.3.4 异常处理 - -| 异常场景 | 处理方式 | -|---------|---------| -| 教练时间冲突 | 提示用户选择其他时间 | -| 会员卡权益不足 | 提示用户购买会员卡 | - ---- - -### 3.4 营销活动创建流程 - -#### 3.4.1 业务场景 - -运营管理员通过管理后台创建营销活动。 - -#### 3.4.2 业务流程 - -``` -┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ -│ 运营管理 │ → │ 创建营销 │ → │ 配置活动 │ → │ 发布活动 │ → │ 活动生效 │ -│ 员登录 │ │ 活动 │ │ 规则 │ │ │ │ │ -└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ -``` - -#### 3.4.3 业务规则 - -- 营销活动需指定时间、规则、奖励 -- 营销活动发布后不可修改规则 -- 营销活动统计按活动、时间维度 - -#### 3.4.4 异常处理 - -| 异常场景 | 处理方式 | -|---------|---------| -| 活动时间冲突 | 提示用户调整活动时间 | -| 活动规则无效 | 提示用户重新配置 | - ---- - -### 3.5 营销分析与预测流程 - -#### 3.5.1 业务场景 - -运营管理员使用营销精算模型预测促销策略。 - -#### 3.5.2 业务流程 - -``` -┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ -│ 运营管理 │ → │ 选择营销 │ → │ 配置促销 │ → │ 预测效果 │ → │ 查看预测 │ -│ 员登录 │ │ 精算模型 │ │ 参数 │ │ │ │ 结果 │ -└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ -``` - -#### 3.5.3 业务规则 - -- 营销精算模型基于历史数据 -- 促销策略预测提供多种方案 -- 促销活动效果预测基于历史数据 - -#### 3.5.4 异常处理 - -| 异常场景 | 处理方式 | -|---------|---------| -| 历史数据不足 | 提示用户积累更多数据 | -| 预测失败 | 提示用户调整参数 | - ---- - -## 四、核心业务规则 - -### 4.1 订阅管理规则 - -| 规则 | 描述 | -|------|------| -| 订阅生效 | 订阅成功后模块立即启用 | -| 计费周期 | 支持月付、季付、半年付、年付 | -| 试用政策 | 不同模块类型提供不同试用时长 | -| 组合套餐 | 支持组合套餐,享受更多优惠 | - -### 4.2 配置管理规则 - -| 规则 | 描述 | -|------|------| -| 配置继承 | 支持门店配置继承租户配置 | -| 继承模式 | 支持继承、继承+覆盖、自定义三种模式 | -| 配置优先级 | 门店配置 → 租户配置 → 默认配置 | -| 配置版本 | 配置变更记录版本,支持回滚 | - -### 4.3 私教管理规则 - -| 规则 | 描述 | -|------|------| -| 私教预约时间 | 私教预约需提前至少24小时 | -| 私教取消时间 | 私教取消需提前至少12小时 | -| 私教考勤 | 私教签到后记录考勤 | - -### 4.4 营销活动规则 - -| 规则 | 描述 | -|------|------| -| 活动规则 | 营销活动需指定时间、规则、奖励 | -| 活动修改 | 营销活动发布后不可修改规则 | -| 活动统计 | 营销活动统计按活动、时间维度 | - -### 4.5 营销分析与预测规则 - -| 规则 | 描述 | -|------|------| -| 模型基础 | 营销精算模型基于历史数据 | -| 预测方案 | 促销策略预测提供多种方案 | -| 效果预测 | 促销活动效果预测基于历史数据 | - ---- - -## 五、业务场景 - -### 5.1 租户订阅场景 - -**场景描述**: -租户A是一家连锁健身房品牌,想启用私教管理和营销活动模块,租户管理员登录管理后台,查看订阅套餐,选择私教管理模块和营销活动模块,选择年付方式,查看优惠信息,确认订阅,支付成功,模块立即启用,租户开始使用新功能。 - -**业务流程**: - -1. 租户管理员登录管理后台 -2. 查看订阅套餐 -3. 选择订阅模块 -4. 选择计费方式 -5. 查看优惠信息 -6. 确认订阅 -7. 支付成功 -8. 模块立即启用 -9. 开始使用新功能 - -**涉及的业务规则**: - -- 订阅成功后模块立即启用,无需重启 -- 年付享受最大折扣 -- 支持多种支付方式 -- 订阅成功后发送通知 - ---- - -### 5.2 门店配置继承场景 - -**场景描述**: -租户A配置了团课、私教、营销模块,门店1想完全继承租户配置,门店2想在租户配置基础上覆盖签到方式(增加人脸识别),门店3想完全自定义配置。各门店管理员登录管理后台,选择继承模式,配置门店级参数,保存配置,配置立即生效。 - -**业务流程**: - -1. 门店管理员登录管理后台 -2. 查看租户级配置 -3. 选择继承模式(继承/继承+覆盖/自定义) -4. 配置门店级参数 -5. 保存配置 -6. 配置立即生效 -7. 验证配置生效 - -**涉及的业务规则**: - -- 查询优先级:门店配置 → 租户配置 → 默认配置 -- 支持三种继承模式 -- 配置变更后立即生效 -- 配置变更记录版本,支持回滚 - ---- - -### 5.3 私教预约场景 - -**场景描述**: -会员张三想预约私教课程,通过小程序查看私教课程列表,选择教练李四,选择时间,确认预约,预约成功,接收提醒。 - -**业务流程**: - -1. 张三打开小程序 -2. 查看私教课程列表 -3. 选择教练李四 -4. 选择时间 -5. 确认预约 -6. 预约成功 -7. 接收提醒 - -**涉及的业务规则**: - -- 私教预约需提前至少24小时 -- 私教取消需提前至少12小时 -- 私教签到后记录考勤 - ---- - -### 5.4 营销活动创建场景 - -**场景描述**: -运营管理员王五想创建一个新会员注册送月卡的活动,登录管理后台,创建营销活动,配置活动规则(新会员注册送月卡),配置活动奖励(月卡一张),发布活动,活动生效,开始监控活动效果。 - -**业务流程**: - -1. 王五登录管理后台 -2. 创建营销活动 -3. 配置活动规则(新会员注册送月卡) -4. 配置活动奖励(月卡一张) -5. 发布活动 -6. 活动生效 -7. 开始监控活动效果 - -**涉及的业务规则**: - -- 营销活动需指定时间、规则、奖励 -- 营销活动发布后不可修改规则 -- 营销活动统计按活动、时间维度 - ---- - -### 5.5 营销分析与预测场景 - -**场景描述**: -运营管理员赵六想预测一个新会员注册送月卡活动的效果,登录管理后台,选择营销精算模型,配置促销参数(活动时间、目标人群、奖励金额),预测活动效果,查看预测结果(预计新增会员数、预计成本、预计收益)。 - -**业务流程**: - -1. 赵六登录管理后台 -2. 选择营销精算模型 -3. 配置促销参数(活动时间、目标人群、奖励金额) -4. 预测活动效果 -5. 查看预测结果(预计新增会员数、预计成本、预计收益) - -**涉及的业务规则**: - -- 营销精算模型基于历史数据 -- 促销策略预测提供多种方案 -- 促销活动效果预测基于历史数据 - ---- - -## 六、数据模型 - -### 6.1 核心实体 - -| 实体 | 描述 | -|------|------| -| 租户(Tenant) | 系统的多租户架构中的独立业务实体 | -| 门店(Store) | 租户下的具体经营场所 | -| 会员(Member) | 在门店注册的用户 | -| 会员卡(MemberCard) | 会员购买的权益卡 | -| 权益(Benefit) | 会员卡包含的权益 | -| 团课(GroupClass) | 集体课程 | -| 私教课程(PrivateClass) | 私教课程 | -| 预约(Booking) | 会员预约记录 | -| 签到(CheckIn) | 会员签到记录 | -| 订阅(Subscription) | 租户订阅记录 | -| 配置(Config) | 租户或门店配置 | -| 营销活动(MarketingActivity) | 营销活动 | -| 营销预测(MarketingPrediction) | 营销预测结果 | - -### 6.2 实体关系 - -``` -租户(Tenant) ──1:N── 门店(Store) -租户(Tenant) ──1:N── 订阅(Subscription) -租户(Tenant) ──1:N── 配置(Config) -门店(Store) ──1:N── 会员(Member) -门店(Store) ──1:N── 配置(Config) -会员(Member) ──1:N── 会员卡(MemberCard) -会员(Member) ──1:N── 预约(Booking) -会员(Member) ──1:N── 签到(CheckIn) -会员卡(MemberCard) ──1:N── 权益(Benefit) -团课(GroupClass) ──1:N── 预约(Booking) -私教课程(PrivateClass) ──1:N── 预约(Booking) -营销活动(MarketingActivity) ──1:N── 营销预测(MarketingPrediction) -``` - ---- - -## 七、技术约束 - -### 7.1 性能约束 - -| 指标 | 要求 | -|------|------| -| API响应时间 | ≤ 500ms | -| 并发用户 | 支持500并发用户 | -| 数据库查询 | 查询响应时间 ≤ 1s | - -### 7.2 可用性约束 - -| 指标 | 要求 | -|------|------| -| 系统可用性 | SLA ≥ 99.9% | -| 故障恢复时间 | MTTR ≤ 30分钟 | - -### 7.3 安全性约束 - -| 指标 | 要求 | -|------|------| -| 数据加密 | 敏感数据加密存储 | -| 访问控制 | 基于角色的访问控制 | -| 操作审计 | 关键操作记录审计日志 | -| 支付安全 | 支持安全支付通道 | - -### 7.4 可扩展性约束 - -| 指标 | 要求 | -|------|------| -| 会员数量 | 不限制 | -| 门店数量 | 支持多门店 | -| 团课容量 | 不限制 | -| 数据保留 | 永久保存 | - ---- - -## 八、附录 - -### 8.1 术语定义 - -| 术语 | 定义 | -|------|------| -| 订阅模块 | 按需订阅的增值功能模块 | -| 私教管理 | 私教课程管理、私教预约、私教签到等功能 | -| 营销活动 | 吸引新会员和提升会员活跃度的活动 | -| 营销精算模型 | 基于历史数据预测促销策略的模型 | -| 促销活动效果预测 | 基于历史数据预测促销活动效果 | -| 配置继承 | 门店配置继承租户配置的机制 | - -### 8.2 参考文档 - -- 《健身房管理系统付费订阅版产品设计文档》 GYM-PRD-SUBSCRIPTION-001 -- 《健身房管理系统业务概要设计文档》 GYM-HLD-001 -- 《订阅与配置模块详细设计文档》 GYM-LLD-004 diff --git a/docs/design/HLD-基础版系统概要设计.md b/docs/design/HLD-基础版系统概要设计.md deleted file mode 100644 index d730538..0000000 --- a/docs/design/HLD-基础版系统概要设计.md +++ /dev/null @@ -1,524 +0,0 @@ -# 健身房管理系统基础版业务概要设计文档(HLD) - -> 文档编号: GYM-HLD-BASIC-001 -> 版本: v1.0 -> 日期: 2026-03-04 -> 作者: 张翔 -> 状态: 初稿 - ---- - -## 文档修订历史 - -| 版本 | 日期 | 作者 | 修订内容 | -| ---- | ---------- | ---- | ---------------------- | -| v1.0 | 2026-03-04 | 张翔 | 创建基础版业务概要设计 | - ---- - -## 一、引言 - -### 1.1 编写目的 - -本文档为健身房管理系统基础版的业务概要设计文档(High-Level Design),旨在: - -1. 从业务层面描述基础版的业务范围、业务流程、业务规则 -2. 为基础版详细设计提供业务指导和约束 -3. 作为产品经理、业务分析师、开发人员的业务参考 - -### 1.2 项目背景 - -健身房管理系统基础版是面向小型工作室、个人教练等场景的核心版本,保证业务闭环,提供完整的会员管理、预约、签到等核心功能。 - -### 1.3 术语定义 - -| 术语 | 定义 | -| ----------------------------- | ------------------------------------------------ | -| 租户(Tenant) | 系统的多租户架构中的独立业务实体,如一个连锁品牌 | -| 门店(Store) | 租户下的具体经营场所 | -| 会员(Member) | 在门店注册的用户 | -| 权益(Benefit) | 会员卡包含的时长、次数、储值、等级等权益 | -| 可预约资源(Bookable Resource) | 团课等可被预约的对象 | -| 时段(Slot) | 资源的可预约时间窗口 | - -### 1.4 参考文档 - -- 《健身房管理系统基础版产品设计文档》 GYM-PRD-BASIC-001 - ---- - -## 二、业务概述 - -### 2.1 业务目标 - -| 目标维度 | 目标描述 | 成功指标 | -| -------- | ---------------------- | -------------------------------- | -| 用户体验 | 提升会员预约和签到体验 | 预约成功率 ≥ 95%,签到耗时 ≤ 3秒 | -| 运营效率 | 降低人工操作成本 | 人工处理时间减少 50% | -| 数据价值 | 提供基础数据支持 | 数据报表使用率 ≥ 80% | - -### 2.2 用户角色 - -| 角色 | 描述 | 主要功能 | -| ---------- | -------------- | ---------------------------- | -| 会员 | 健身房注册用户 | 预约课程、签到、查看个人信息 | -| 教练 | 健身房教练 | 排课、团课签到管理 | -| 前台 | 门店前台人员 | 会员接待、签到辅助、会员管理 | -| 店长 | 门店管理者 | 单店全功能管理、数据查看 | -| 超级管理员 | 平台最高权限 | 全平台管理、系统配置 | - -### 2.3 业务范围 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 基础版业务范围 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 会员管理 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 会员注册 • 会员卡管理 • 权益管理 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 预约管理 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 团课预约 • 团课管理 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 签到管理 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 扫码签到 • 签到记录管理 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 数据统计 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 基础数据统计 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 系统管理 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 用户管理 • 角色权限管理 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ UI模版定制 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 品牌定制 • 布局调整 • 预设模板 • 配置历史 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 三、核心业务流程 - -### 3.1 会员注册流程 - -#### 3.1.1 业务场景 - -新用户通过小程序或前台进行注册,成为健身房会员。 - -#### 3.1.2 业务流程 - -``` -┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ -│ 用户打开 │ → │ 填写手机 │ → │ 验证手机 │ → │ 填写基本 │ → │ 注册成功 │ -│ 小程序 │ │ 号 │ │ 号 │ │ 信息 │ │ │ -└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ -``` - -#### 3.1.3 业务规则 - -- 手机号需验证唯一性 -- 手机号需通过短信验证码验证 -- 支持微信授权快速注册 -- 注册成功后自动创建会员档案 - -#### 3.1.4 异常处理 - -| 异常场景 | 处理方式 | -| ------------ | ---------------- | -| 手机号已存在 | 提示用户直接登录 | -| 验证码错误 | 提示用户重新输入 | -| 验证码过期 | 提示用户重新获取 | - ---- - -### 3.2 团课预约流程 - -#### 3.2.1 业务场景 - -会员通过小程序预约团课,教练通过管理后台创建团课。 - -#### 3.2.2 业务流程 - -**会员预约团课**: - -``` -┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ -│ 会员打开 │ → │ 查看团课 │ → │ 选择团课 │ → │ 确认预约 │ → │ 预约成功 │ -│ 小程序 │ │ 列表 │ │ │ │ │ │ │ -└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ -``` - -**教练创建团课**: - -``` -┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ -│ 教练打开 │ → │ 点击创建 │ → │ 填写团课 │ → │ 发布团课 │ → │ 发布成功 │ -│ 管理后台 │ │ 团课 │ │ 信息 │ │ │ │ │ -└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ -``` - -#### 3.2.3 业务规则 - -- 预约需在课程开始前至少30分钟 -- 取消预约需在课程开始前至少2小时 -- 每节课最多20人 -- 预约成功后发送提醒 -- 预约成功后扣减权益 -- 团课需指定教练、时间、地点 -- 团课取消需提前24小时通知 -- 团课取消后自动退款 - -#### 3.2.4 异常处理 - -| 异常场景 | 处理方式 | -| -------------- | -------------------- | -| 课程已满 | 提示用户选择其他课程 | -| 会员卡权益不足 | 提示用户购买会员卡 | -| 预约时间过短 | 提示用户提前预约 | - ---- - -### 3.3 签到流程 - -#### 3.3.1 业务场景 - -会员到店后通过扫码进行签到,记录到店信息。 - -#### 3.3.2 业务流程 - -``` -┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ -│ 会员到店 │ → │ 扫描签到 │ → │ 验证会员 │ → │ 签到成功 │ → │ 记录到店 │ -│ │ │ 码 │ │ 卡 │ │ │ │ 时间 │ -└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ -``` - -#### 3.3.3 业务规则 - -- 签到需验证会员卡有效性 -- 签到需验证预约信息(如有) -- 签到成功后记录到店时间 -- 签到失败后提示原因 - -#### 3.3.4 异常处理 - -| 异常场景 | 处理方式 | -| ---------- | ------------------ | -| 会员卡无效 | 提示用户购买会员卡 | -| 会员卡过期 | 提示用户续费 | -| 签到码无效 | 提示用户重新扫描 | - ---- - -### 3.4 会员卡购买流程 - -#### 3.4.1 业务场景 - -会员通过小程序购买会员卡,获得相应权益。 - -#### 3.4.2 业务流程 - -``` -┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ -│ 会员打开 │ → │ 查看会员 │ → │ 选择会员 │ → │ 确认购买 │ → │ 购买成功 │ -│ 小程序 │ │ 卡列表 │ │ 卡 │ │ │ │ │ -└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ -``` - -#### 3.4.3 业务规则 - -- 支持时长卡、次卡、储值卡 -- 会员卡到期前7天提醒 -- 会员卡续费后权益立即生效 -- 会员卡使用记录永久保存 - -#### 3.4.4 异常处理 - -| 异常场景 | 处理方式 | -| -------- | -------------------- | -| 支付失败 | 提示用户重新支付 | -| 支付超时 | 提示用户重新发起支付 | - ---- - -### 3.5 UI模版定制流程 - -#### 3.5.1 业务场景 - -租户通过管理后台的可视化配置器定制自己的UI,包括品牌元素、布局结构和预设模板。 - -#### 3.5.2 业务流程 - -``` -┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ -│ 租户登录 │ → │ 打开UI │ → │ 品牌定制 │ → │ 布局调整 │ → │ 配置保存 │ -│ 管理后台 │ │ 定制器 │ │ │ │ │ │ │ -└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ -``` - -#### 3.5.3 业务规则 - -- 品牌元素应用范围包括小程序和管理后台 -- 布局调整支持拖拽排序和模块隐藏 -- 预设模板应用后保留品牌配置 -- 配置变更实时生效,无需重新部署 -- 配置变更自动记录到历史 - -#### 3.5.4 异常处理 - -| 异常场景 | 处理方式 | -| ------------ | -------------------- | -| Logo上传失败 | 提示用户重新上传 | -| 配置保存失败 | 提示用户检查配置格式 | -| 模板应用失败 | 提示用户调整品牌配置 | -| 配置回滚失败 | 提示用户选择其他版本 | - ---- - -## 四、核心业务规则 - -### 4.1 会员管理规则 - -| 规则 | 描述 | -| ---------------- | ------------------------------------------------------ | -| 会员唯一性 | 手机号作为会员唯一标识 | -| 会员信息完整性 | 必填字段:手机号、姓名、性别 | -| 会员信息修改权限 | 会员只能编辑自己的基本信息,前台和店长可以编辑所有信息 | - -### 4.2 会员卡管理规则 - -| 规则 | 描述 | -| -------------- | ------------------------------------ | -| 会员卡类型 | 支持时长卡、次卡、储值卡 | -| 会员卡有效期 | 时长卡有有效期,次卡和储值卡无有效期 | -| 会员卡到期提醒 | 到期前7天提醒 | -| 会员卡续费 | 续费后权益立即生效 | - -### 4.3 预约管理规则 - -| 规则 | 描述 | -| ---------------- | ------------------------------- | -| 预约时间限制 | 预约需在课程开始前至少30分钟 | -| 取消预约时间限制 | 取消预约需在课程开始前至少2小时 | -| 团课容量限制 | 每节课最多20人 | -| 预约权益扣减 | 预约成功后扣减权益 | - -### 4.4 签到管理规则 - -| 规则 | 描述 | -| ------------ | -------------------------- | -| 签到验证 | 签到需验证会员卡有效性 | -| 签到预约验证 | 签到需验证预约信息(如有) | -| 签到记录 | 签到成功后记录到店时间 | - -### 4.5 数据统计规则 - -| 规则 | 描述 | -| ------------ | -------------------- | -| 数据保留期限 | 数据保留30天 | -| 统计维度 | 支持按日、周、月统计 | -| 数据导出 | 支持数据导出 | - -### 4.6 UI模版定制规则 - -| 规则 | 描述 | -| ------------ | ------------------------------------------ | -| 品牌元素应用 | 品牌元素应用范围包括小程序和管理后台 | -| Logo格式限制 | Logo支持PNG/JPG格式,限制2MB以内 | -| 颜色格式限制 | 颜色支持RGB和HEX格式 | -| 布局调整权限 | 布局调整支持按角色区分(店长、前台、会员) | -| 模板应用规则 | 模板应用后保留租户已有的品牌配置 | -| 配置版本管理 | 每次配置变更自动生成新版本号 | -| 配置历史保留 | 配置历史保留90天 | -| 配置实时生效 | 配置变更实时生效,无需重新部署 | - ---- - -## 五、业务场景 - -### 5.1 会员注册场景 - -**场景描述**: -新用户张三通过小程序注册成为健身房会员。 - -**业务流程**: - -1. 张三打开小程序 -2. 点击注册 -3. 填写手机号 -4. 验证手机号 -5. 填写基本信息(姓名、性别、生日、身高体重、健身目标) -6. 注册成功 -7. 自动创建会员档案 - -**涉及的业务规则**: - -- 手机号需验证唯一性 -- 手机号需通过短信验证码验证 -- 注册成功后自动创建会员档案 - ---- - -### 5.2 团课预约场景 - -**场景描述**: -会员李四通过小程序预约团课。 - -**业务流程**: - -1. 李四打开小程序 -2. 查看团课列表 -3. 选择团课 -4. 查看详情 -5. 确认预约 -6. 预约成功 -7. 接收提醒 - -**涉及的业务规则**: - -- 预约需在课程开始前至少30分钟 -- 取消预约需在课程开始前至少2小时 -- 每节课最多20人 -- 预约成功后发送提醒 -- 预约成功后扣减权益 - ---- - -### 5.3 签到场景 - -**场景描述**: -会员王五到店后通过扫码进行签到。 - -**业务流程**: - -1. 王五到店 -2. 扫描签到码 -3. 验证会员卡 -4. 签到成功 -5. 记录到店时间 - -**涉及的业务规则**: - -- 签到需验证会员卡有效性 -- 签到需验证预约信息(如有) -- 签到成功后记录到店时间 -- 签到失败后提示原因 - ---- - -### 5.4 会员卡购买场景 - -**场景描述**: -会员赵六通过小程序购买会员卡。 - -**业务流程**: - -1. 赵六打开小程序 -2. 查看会员卡列表 -3. 选择会员卡 -4. 确认购买 -5. 购买成功 - -**涉及的业务规则**: - -- 支持时长卡、次卡、储值卡 -- 会员卡到期前7天提醒 -- 会员卡续费后权益立即生效 - ---- - -## 六、数据模型 - -### 6.1 核心实体 - -| 实体 | 描述 | -| ------------------ | ---------------- | -| 会员(Member) | 健身房注册用户 | -| 会员卡(MemberCard) | 会员购买的权益卡 | -| 权益(Benefit) | 会员卡包含的权益 | -| 团课(GroupClass) | 集体课程 | -| 预约(Booking) | 会员预约记录 | -| 签到(CheckIn) | 会员签到记录 | - -### 6.2 实体关系 - -``` -会员(Member) ──1:N── 会员卡(MemberCard) -会员(Member) ──1:N── 预约(Booking) -会员(Member) ──1:N── 签到(CheckIn) -会员卡(MemberCard) ──1:N── 权益(Benefit) -团课(GroupClass) ──1:N── 预约(Booking) -``` - ---- - -## 七、技术约束 - -### 7.1 性能约束 - -| 指标 | 要求 | -| ----------------- | ----------------- | -| API响应时间 (P99) | ≤ 500ms | -| 并发用户 | 支持100并发用户 | -| 数据库查询 | 查询响应时间 ≤ 1s | - -### 7.2 可用性约束 - -| 指标 | 要求 | -| ------------ | ------------- | -| 系统可用性 | SLA ≥ 99.9% | -| 故障恢复时间 | MTTR ≤ 30分钟 | - -### 7.3 安全性约束 - -| 指标 | 要求 | -| -------- | -------------------- | -| 数据加密 | 敏感数据加密存储 | -| 访问控制 | 基于角色的访问控制 | -| 操作审计 | 关键操作记录审计日志 | - -### 7.4 可扩展性约束 - -| 指标 | 要求 | -| -------- | -------------- | -| 会员数量 | 最多500人 | -| 门店数量 | 单门店 | -| 团课容量 | 每节课最多20人 | -| 数据保留 | 保留30天 | - ---- - -## 八、附录 - -### 8.1 术语定义 - -| 术语 | 定义 | -| ------ | ------------------------------------------ | -| 会员 | 在健身房注册的用户 | -| 会员卡 | 会员购买的权益卡,包括时长卡、次卡、储值卡 | -| 权益 | 会员卡包含的时长、次数、储值、等级等权益 | -| 团课 | 集体课程,由教练带领多个会员一起上课 | -| 预约 | 会员预约团课 | -| 签到 | 会员到店记录 | - -### 8.2 参考文档 - -- 《健身房管理系统基础版产品设计文档》 GYM-PRD-BASIC-001 diff --git a/docs/design/LLD-基础版系统详细设计.md b/docs/design/LLD-基础版系统详细设计.md deleted file mode 100644 index 152d437..0000000 --- a/docs/design/LLD-基础版系统详细设计.md +++ /dev/null @@ -1,2079 +0,0 @@ -# 健身房管理系统基础版详细设计文档(LLD) - -> 文档编号: GYM-LLD-BASIC-001 -> 版本: v1.0 -> 日期: 2026-03-04 -> 作者: 张翔 -> 状态: 初稿 - ---- - -## 文档修订历史 - -| 版本 | 日期 | 作者 | 修订内容 | -| ---- | ---------- | ---- | -------- | -| v1.0 | 2026-03-04 | 张翔 | 创建基础版详细设计 | - ---- - -## 参考文档 - -- 《健身房管理系统基础版产品设计文档》 GYM-PRD-BASIC-001 -- 《健身房管理系统基础版业务概要设计文档》 GYM-HLD-BASIC-001 -- Spring Boot 3 官方文档 -- R2DBC 规范文档 -- PostgreSQL 官方文档 - ---- - -## 一、系统架构设计 - -### 1.1 总体架构 - -采用分层架构 + 模块化设计: - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 基础版总体架构 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 客户端层 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 会员小程序 (uniapp+Vue3) │ │ -│ │ • 教练端App (uniapp+Vue3) │ │ -│ │ • 管理后台PC (Vue3+Vite) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ API Gateway 统一网关 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 路由转发 • 认证鉴权 • 限流熔断 • 日志追踪 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 业务层 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 会员服务 (Member Service) │ │ -│ │ • 预约服务 (Booking Service) │ │ -│ │ • 签到服务 (CheckIn Service) │ │ -│ │ • 数据服务 (Data Service) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 公共服务层 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 认证服务 • 消息服务 • 文件服务 • 缓存服务 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 基础设施层 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • PostgreSQL • R2DBC • Caffeine • Redis(可选) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 外部服务层 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 微信开放平台 • 短信服务 • 支付服务 • OSS存储 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 1.2 技术架构 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 技术架构 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 前端技术栈 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • uniapp (跨平台小程序) • Vue3 (前端框架) │ │ -│ │ • Vite (构建工具) • TypeScript (类型安全) │ │ -│ │ • Pinia (状态管理) • Element Plus (UI组件库) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 后端技术栈 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • Spring Boot 3.2 (应用框架) • Spring Security (安全框架) │ │ -│ │ • R2DBC (响应式数据库) • Spring WebFlux (响应式Web) │ │ -│ │ • Caffeine (本地缓存) • Redis (分布式缓存) │ │ -│ │ • PostgreSQL (关系型数据库) • MyBatis (ORM框架) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 部署架构 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • Docker (容器化) • Kubernetes (容器编排) │ │ -│ │ • Nginx (反向代理) • ELK (日志收集) │ │ -│ │ • Prometheus (监控) • Grafana (可视化) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 二、模块设计 - -### 2.1 会员模块 - -#### 2.1.1 模块概述 - -会员模块是基础版的核心基础模块,负责管理会员全生命周期,包括: - -- 会员注册与信息管理 -- 会员卡购买与管理 -- 会员权益(时长/次数/储值/等级)管理 - -#### 2.1.2 数据模型设计 - -**会员表 (member)** - -```sql -CREATE TABLE member ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - store_id BIGINT NOT NULL, - member_no VARCHAR(32) NOT NULL, - name VARCHAR(64), - phone VARCHAR(64) NOT NULL, - phone_mask VARCHAR(20), - avatar VARCHAR(512), - gender SMALLINT, - birthday DATE, - height INT, - weight DECIMAL(5,2), - fitness_goal VARCHAR(64), - status SMALLINT DEFAULT 1, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - created_by BIGINT, - updated_by BIGINT, - deleted_at TIMESTAMP DEFAULT NULL, - - CONSTRAINT uk_member_tenant_no UNIQUE (tenant_id, member_no), - CONSTRAINT uk_member_phone UNIQUE (phone), - CONSTRAINT fk_member_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id), - CONSTRAINT fk_member_store FOREIGN KEY (store_id) REFERENCES store(id) -); -``` - -**会员卡表 (member_card)** - -```sql -CREATE TABLE member_card ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - store_id BIGINT NOT NULL, - member_id BIGINT NOT NULL, - card_no VARCHAR(32) NOT NULL, - card_type SMALLINT NOT NULL, - card_name VARCHAR(64) NOT NULL, - total_amount DECIMAL(10,2), - balance DECIMAL(10,2), - total_count INT, - balance_count INT, - valid_days INT, - valid_from DATE, - valid_to DATE, - status SMALLINT DEFAULT 1, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - created_by BIGINT, - updated_by BIGINT, - deleted_at TIMESTAMP DEFAULT NULL, - - CONSTRAINT uk_member_card_no UNIQUE (card_no), - CONSTRAINT fk_member_card_member FOREIGN KEY (member_id) REFERENCES member(id) -); -``` - -#### 2.1.3 核心服务设计 - -**会员注册服务** - -```java -@Service -public class MemberRegistrationService { - - @Autowired - private MemberRepository memberRepository; - - @Autowired - private SmsService smsService; - - @Autowired - private MemberCardService memberCardService; - - /** - * 会员注册 - */ - @Transactional - public Member register(MemberRegistrationRequest request) { - validatePhone(request.getPhone()); - validateSmsCode(request.getPhone(), request.getSmsCode()); - - Member member = new Member(); - member.setTenantId(request.getTenantId()); - member.setStoreId(request.getStoreId()); - member.setMemberNo(generateMemberNo(request.getTenantId())); - member.setName(request.getName()); - member.setPhone(encryptPhone(request.getPhone())); - member.setPhoneMask(maskPhone(request.getPhone())); - member.setGender(request.getGender()); - member.setBirthday(request.getBirthday()); - member.setHeight(request.getHeight()); - member.setWeight(request.getWeight()); - member.setFitnessGoal(request.getFitnessGoal()); - member.setStatus(1); - - return memberRepository.save(member); - } - - /** - * 验证手机号 - */ - private void validatePhone(String phone) { - if (!memberRepository.findByPhone(phone).isEmpty()) { - throw new BusinessException("手机号已存在"); - } - } - - /** - * 验证短信验证码 - */ - private void validateSmsCode(String phone, String smsCode) { - if (!smsService.verifySmsCode(phone, smsCode)) { - throw new BusinessException("验证码错误"); - } - } - - /** - * 生成会员号 - */ - private String generateMemberNo(Long tenantId) { - String prefix = "M" + tenantId; - String timestamp = String.valueOf(System.currentTimeMillis()); - String random = String.valueOf(new Random().nextInt(1000)); - return prefix + timestamp.substring(timestamp.length() - 8) + random; - } - - /** - * 加密手机号 - */ - private String encryptPhone(String phone) { - return AESUtil.encrypt(phone); - } - - /** - * 脱敏手机号 - */ - private String maskPhone(String phone) { - return phone.substring(0, 3) + "****" + phone.substring(7); - } -} -``` - -**会员卡购买服务** - -```java -@Service -public class MemberCardPurchaseService { - - @Autowired - private MemberCardRepository memberCardRepository; - - @Autowired - private PaymentService paymentService; - - @Autowired - private BenefitService benefitService; - - /** - * 购买会员卡 - */ - @Transactional - public MemberCard purchase(MemberCardPurchaseRequest request) { - Member member = memberRepository.findById(request.getMemberId()) - .orElseThrow(() -> new BusinessException("会员不存在")); - - Payment payment = paymentService.createPayment(request); - - MemberCard memberCard = new MemberCard(); - memberCard.setTenantId(member.getTenantId()); - memberCard.setStoreId(member.getStoreId()); - memberCard.setMemberId(member.getId()); - memberCard.setCardNo(generateCardNo(member.getTenantId())); - memberCard.setCardType(request.getCardType()); - memberCard.setCardName(request.getCardName()); - memberCard.setTotalAmount(request.getAmount()); - memberCard.setBalance(request.getAmount()); - memberCard.setTotalCount(request.getCount()); - memberCard.setBalanceCount(request.getCount()); - memberCard.setValidDays(request.getValidDays()); - memberCard.setValidFrom(LocalDate.now()); - memberCard.setValidTo(LocalDate.now().plusDays(request.getValidDays())); - memberCard.setStatus(1); - - memberCard = memberCardRepository.save(memberCard); - - benefitService.createBenefits(memberCard); - - return memberCard; - } - - /** - * 生成卡号 - */ - private String generateCardNo(Long tenantId) { - String prefix = "C" + tenantId; - String timestamp = String.valueOf(System.currentTimeMillis()); - String random = String.valueOf(new Random().nextInt(1000)); - return prefix + timestamp.substring(timestamp.length() - 8) + random; - } -} -``` - ---- - -### 2.2 预约模块 - -#### 2.2.1 模块概述 - -预约模块是基础版的核心业务模块,负责管理团课预约,包括: - -- 团课管理 -- 团课预约 -- 预约取消 - -#### 2.2.2 数据模型设计 - -**课程表 (course)** - -```sql -CREATE TABLE course ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - name VARCHAR(128) NOT NULL, - code VARCHAR(32), - type SMALLINT NOT NULL, - category VARCHAR(64), - description TEXT, - cover_image VARCHAR(512), - duration INT NOT NULL, - capacity INT DEFAULT 20, - min_capacity INT DEFAULT 1, - difficulty SMALLINT DEFAULT 1, - calories INT, - equipment VARCHAR(256), - benefits JSONB, - price DECIMAL(10,2), - price_type SMALLINT DEFAULT 1, - price_value DECIMAL(10,2), - advance_days INT DEFAULT 7, - cancel_hours INT DEFAULT 2, - cancel_penalty DECIMAL(3,2) DEFAULT 0.00, - status SMALLINT DEFAULT 1, - sort_order INT DEFAULT 0, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - created_by BIGINT, - updated_by BIGINT, - deleted_at TIMESTAMP DEFAULT NULL, - - CONSTRAINT fk_course_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id) -); -``` - -**课程时段表 (course_slot)** - -```sql -CREATE TABLE course_slot ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - store_id BIGINT NOT NULL, - course_id BIGINT NOT NULL, - coach_id BIGINT, - slot_date DATE NOT NULL, - start_time TIME NOT NULL, - end_time TIME NOT NULL, - capacity INT DEFAULT 20, - booked_count INT DEFAULT 0, - status SMALLINT DEFAULT 1, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - created_by BIGINT, - updated_by BIGINT, - deleted_at TIMESTAMP DEFAULT NULL, - - CONSTRAINT fk_course_slot_course FOREIGN KEY (course_id) REFERENCES course(id), - CONSTRAINT fk_course_slot_coach FOREIGN KEY (coach_id) REFERENCES coach(id) -); -``` - -**预约记录表 (booking_record)** - -```sql -CREATE TABLE booking_record ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - store_id BIGINT NOT NULL, - member_id BIGINT NOT NULL, - course_id BIGINT NOT NULL, - slot_id BIGINT NOT NULL, - booking_no VARCHAR(32) NOT NULL, - status SMALLINT DEFAULT 1, - booked_at TIMESTAMP DEFAULT NOW(), - cancelled_at TIMESTAMP, - cancel_reason VARCHAR(256), - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - created_by BIGINT, - updated_by BIGINT, - deleted_at TIMESTAMP DEFAULT NULL, - - CONSTRAINT uk_booking_no UNIQUE (booking_no), - CONSTRAINT fk_booking_member FOREIGN KEY (member_id) REFERENCES member(id), - CONSTRAINT fk_booking_slot FOREIGN KEY (slot_id) REFERENCES course_slot(id) -); -``` - -#### 2.2.3 核心服务设计 - -**团课预约服务** - -```java -@Service -public class CourseBookingService { - - @Autowired - private BookingRecordRepository bookingRecordRepository; - - @Autowired - private CourseSlotRepository courseSlotRepository; - - @Autowired - private BenefitService benefitService; - - @Autowired - private MessageService messageService; - - /** - * 预约团课 - */ - @Transactional - public BookingRecord book(CourseBookingRequest request) { - validateBookingTime(request.getSlotId()); - validateCapacity(request.getSlotId()); - validateMemberBenefit(request.getMemberId(), request.getSlotId()); - - CourseSlot slot = courseSlotRepository.findById(request.getSlotId()) - .orElseThrow(() -> new BusinessException("课程时段不存在")); - - BookingRecord record = new BookingRecord(); - record.setTenantId(slot.getTenantId()); - record.setStoreId(slot.getStoreId()); - record.setMemberId(request.getMemberId()); - record.setCourseId(slot.getCourseId()); - record.setSlotId(slot.getId()); - record.setBookingNo(generateBookingNo(slot.getTenantId())); - record.setStatus(1); - - record = bookingRecordRepository.save(record); - - slot.setBookedCount(slot.getBookedCount() + 1); - courseSlotRepository.save(slot); - - benefitService.deductBenefit(request.getMemberId(), slot.getCourseId()); - - messageService.sendBookingNotification(record); - - return record; - } - - /** - * 验证预约时间 - */ - private void validateBookingTime(Long slotId) { - CourseSlot slot = courseSlotRepository.findById(slotId) - .orElseThrow(() -> new BusinessException("课程时段不存在")); - - LocalDateTime slotDateTime = LocalDateTime.of(slot.getSlotDate(), slot.getStartTime()); - if (slotDateTime.isBefore(LocalDateTime.now().plusMinutes(30))) { - throw new BusinessException("预约时间过短"); - } - } - - /** - * 验证容量 - */ - private void validateCapacity(Long slotId) { - CourseSlot slot = courseSlotRepository.findById(slotId) - .orElseThrow(() -> new BusinessException("课程时段不存在")); - - if (slot.getBookedCount() >= slot.getCapacity()) { - throw new BusinessException("课程已满"); - } - } - - /** - * 验证会员权益 - */ - private void validateMemberBenefit(Long memberId, Long slotId) { - if (!benefitService.hasBenefit(memberId, slotId)) { - throw new BusinessException("会员权益不足"); - } - } - - /** - * 生成预约号 - */ - private String generateBookingNo(Long tenantId) { - String prefix = "B" + tenantId; - String timestamp = String.valueOf(System.currentTimeMillis()); - String random = String.valueOf(new Random().nextInt(1000)); - return prefix + timestamp.substring(timestamp.length() - 8) + random; - } -} -``` - ---- - -### 2.3 签到模块 - -#### 2.3.1 模块概述 - -签到模块是基础版的核心业务模块,负责管理会员的入场签到,支持: - -- 二维码签到 -- 签到记录管理 - -#### 2.3.2 数据模型设计 - -**签到记录表 (checkin_record)** - -```sql -CREATE TABLE checkin_record ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - store_id BIGINT NOT NULL, - member_id BIGINT NOT NULL, - booking_id BIGINT, - type SMALLINT NOT NULL, - method SMALLINT NOT NULL, - status SMALLINT DEFAULT 1, - checkin_at TIMESTAMP NOT NULL, - checkin_date DATE NOT NULL, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - created_by BIGINT, - updated_by BIGINT, - deleted_at TIMESTAMP DEFAULT NULL, - - CONSTRAINT fk_checkin_member FOREIGN KEY (member_id) REFERENCES member(id), - CONSTRAINT fk_checkin_booking FOREIGN KEY (booking_id) REFERENCES booking_record(id) -); -``` - -#### 2.3.3 核心服务设计 - -**扫码签到服务** - -```java -@Service -public class QRCodeCheckInService { - - @Autowired - private CheckInRecordRepository checkInRecordRepository; - - @Autowired - private MemberRepository memberRepository; - - @Autowired - private MemberCardRepository memberCardRepository; - - /** - * 扫码签到 - */ - @Transactional - public CheckInRecord checkIn(QRCodeCheckInRequest request) { - Member member = memberRepository.findByQrCode(request.getQrCode()) - .orElseThrow(() -> new BusinessException("会员不存在")); - - validateMemberCard(member.getId()); - - CheckInRecord record = new CheckInRecord(); - record.setTenantId(member.getTenantId()); - record.setStoreId(member.getStoreId()); - record.setMemberId(member.getId()); - record.setType(1); - record.setMethod(1); - record.setStatus(1); - record.setCheckInAt(LocalDateTime.now()); - record.setCheckInDate(LocalDate.now()); - - return checkInRecordRepository.save(record); - } - - /** - * 验证会员卡 - */ - private void validateMemberCard(Long memberId) { - List cards = memberCardRepository.findByMemberId(memberId); - if (cards.isEmpty()) { - throw new BusinessException("会员卡不存在"); - } - - boolean hasValidCard = cards.stream() - .anyMatch(card -> card.getStatus() == 1 && - (card.getValidTo() == null || card.getValidTo().isAfter(LocalDate.now()))); - - if (!hasValidCard) { - throw new BusinessException("会员卡无效"); - } - } -} -``` - ---- - -### 2.4 数据统计模块 - -#### 2.4.1 模块概述 - -数据统计模块是基础版的核心业务模块,负责提供基础数据统计,包括: - -- 会员数据统计 -- 预约数据统计 -- 签到数据统计 - -#### 2.4.2 核心服务设计 - -**数据统计服务** - -```java -@Service -public class DataStatisticsService { - - @Autowired - private MemberRepository memberRepository; - - @Autowired - private BookingRecordRepository bookingRecordRepository; - - @Autowired - private CheckInRecordRepository checkInRecordRepository; - - /** - * 获取会员数据统计 - */ - public MemberStatistics getMemberStatistics(Long tenantId, Long storeId, LocalDate startDate, LocalDate endDate) { - long totalMembers = memberRepository.countByTenantIdAndStoreIdAndCreatedAtBetween( - tenantId, storeId, startDate.atStartOfDay(), endDate.atTime(23, 59, 59)); - - long activeMembers = memberRepository.countActiveMembers(tenantId, storeId, startDate, endDate); - - MemberStatistics statistics = new MemberStatistics(); - statistics.setTotalMembers(totalMembers); - statistics.setActiveMembers(activeMembers); - statistics.setNewMembers(totalMembers); - - return statistics; - } - - /** - * 获取预约数据统计 - */ - public BookingStatistics getBookingStatistics(Long tenantId, Long storeId, LocalDate startDate, LocalDate endDate) { - long totalBookings = bookingRecordRepository.countByTenantIdAndStoreIdAndBookedAtBetween( - tenantId, storeId, startDate.atStartOfDay(), endDate.atTime(23, 59, 59)); - - long cancelledBookings = bookingRecordRepository.countCancelledBookings(tenantId, storeId, startDate, endDate); - - BookingStatistics statistics = new BookingStatistics(); - statistics.setTotalBookings(totalBookings); - statistics.setCancelledBookings(cancelledBookings); - statistics.setSuccessBookings(totalBookings - cancelledBookings); - - return statistics; - } - - /** - * 获取签到数据统计 - */ - public CheckInStatistics getCheckInStatistics(Long tenantId, Long storeId, LocalDate startDate, LocalDate endDate) { - long totalCheckIns = checkInRecordRepository.countByTenantIdAndStoreIdAndCheckInAtBetween( - tenantId, storeId, startDate.atStartOfDay(), endDate.atTime(23, 59, 59)); - - CheckInStatistics statistics = new CheckInStatistics(); - statistics.setTotalCheckIns(totalCheckIns); - - return statistics; - } -} -``` - ---- - -### 2.5 系统管理模块 - -#### 2.5.1 模块概述 - -系统管理模块是基础版的核心基础模块,负责管理系统用户和权限,包括: - -- 用户管理 -- 角色权限管理 - -#### 2.5.2 数据模型设计 - -**用户表 (user)** - -```sql -CREATE TABLE user ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - store_id BIGINT, - username VARCHAR(64) NOT NULL, - password VARCHAR(256) NOT NULL, - name VARCHAR(64) NOT NULL, - phone VARCHAR(64), - email VARCHAR(128), - avatar VARCHAR(512), - status SMALLINT DEFAULT 1, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - created_by BIGINT, - updated_by BIGINT, - deleted_at TIMESTAMP DEFAULT NULL, - - CONSTRAINT uk_user_username UNIQUE (username), - CONSTRAINT fk_user_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id), - CONSTRAINT fk_user_store FOREIGN KEY (store_id) REFERENCES store(id) -); -``` - -**角色表 (role)** - -```sql -CREATE TABLE role ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - name VARCHAR(64) NOT NULL, - code VARCHAR(32) NOT NULL, - description VARCHAR(256), - status SMALLINT DEFAULT 1, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - created_by BIGINT, - updated_by BIGINT, - deleted_at TIMESTAMP DEFAULT NULL, - - CONSTRAINT uk_role_code UNIQUE (code), - CONSTRAINT fk_role_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id) -); -``` - -**用户角色关联表 (user_role)** - -```sql -CREATE TABLE user_role ( - id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL, - role_id BIGINT NOT NULL, - created_at TIMESTAMP DEFAULT NOW(), - created_by BIGINT, - - CONSTRAINT uk_user_role UNIQUE (user_id, role_id), - CONSTRAINT fk_user_role_user FOREIGN KEY (user_id) REFERENCES user(id), - CONSTRAINT fk_user_role_role FOREIGN KEY (role_id) REFERENCES role(id) -); -``` - -#### 2.5.3 核心服务设计 - -**用户管理服务** - -```java -@Service -public class UserService { - - @Autowired - private UserRepository userRepository; - - @Autowired - private UserRoleRepository userRoleRepository; - - @Autowired - private PasswordEncoder passwordEncoder; - - /** - * 创建用户 - */ - @Transactional - public User createUser(UserCreateRequest request) { - validateUsername(request.getUsername()); - - User user = new User(); - user.setTenantId(request.getTenantId()); - user.setStoreId(request.getStoreId()); - user.setUsername(request.getUsername()); - user.setPassword(passwordEncoder.encode(request.getPassword())); - user.setName(request.getName()); - user.setPhone(request.getPhone()); - user.setEmail(request.getEmail()); - user.setStatus(1); - - user = userRepository.save(user); - - assignRoles(user.getId(), request.getRoleIds()); - - return user; - } - - /** - * 验证用户名 - */ - private void validateUsername(String username) { - if (userRepository.findByUsername(username).isPresent()) { - throw new BusinessException("用户名已存在"); - } - } - - /** - * 分配角色 - */ - private void assignRoles(Long userId, List roleIds) { - if (roleIds == null || roleIds.isEmpty()) { - return; - } - - List userRoles = roleIds.stream() - .map(roleId -> { - UserRole userRole = new UserRole(); - userRole.setUserId(userId); - userRole.setRoleId(roleId); - return userRole; - }) - .collect(Collectors.toList()); - - userRoleRepository.saveAll(userRoles); - } -} -``` - ---- - -### 2.6 UI模版定制模块 - -#### 2.6.1 模块概述 - -UI模版定制模块是基础版的核心基础模块,负责管理租户的UI定制配置,包括: - -- 品牌定制(Logo、颜色、背景图等) -- 布局调整(模块顺序、模块隐藏等) -- 预设模板(模板选择、模板应用等) -- 配置历史(配置回滚、配置对比等) - -#### 2.6.2 数据模型设计 - -**租户UI配置表 (tenant_ui_config)** - -```sql -CREATE TABLE tenant_ui_config ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - version INT NOT NULL DEFAULT 1, - brand_config JSONB NOT NULL DEFAULT '{}', - layout_config JSONB NOT NULL DEFAULT '{}', - template_id BIGINT, - is_active BOOLEAN DEFAULT TRUE, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - created_by BIGINT, - updated_by BIGINT, - deleted_at TIMESTAMP DEFAULT NULL, - - CONSTRAINT uk_tenant_ui_config UNIQUE (tenant_id, version), - CONSTRAINT fk_tenant_ui_config_template FOREIGN KEY (template_id) REFERENCES ui_template(id) -); - -CREATE INDEX idx_tenant_ui_config_tenant ON tenant_ui_config(tenant_id); -CREATE INDEX idx_tenant_ui_config_is_active ON tenant_ui_config(is_active); -``` - -**预设模板表 (ui_template)** - -```sql -CREATE TABLE ui_template ( - id BIGSERIAL PRIMARY KEY, - name VARCHAR(128) NOT NULL, - code VARCHAR(64) NOT NULL, - type VARCHAR(32) NOT NULL, - description VARCHAR(512), - thumbnail VARCHAR(512), - preview_image VARCHAR(512), - config JSONB NOT NULL DEFAULT '{}', - status SMALLINT DEFAULT 1, - sort_order INT DEFAULT 0, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - created_by BIGINT, - updated_by BIGINT, - deleted_at TIMESTAMP DEFAULT NULL, - - CONSTRAINT uk_ui_template_code UNIQUE (code) -); - -CREATE INDEX idx_ui_template_type ON ui_template(type); -CREATE INDEX idx_ui_template_status ON ui_template(status); -``` - -**配置历史表 (ui_config_history)** - -```sql -CREATE TABLE ui_config_history ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - config_id BIGINT NOT NULL, - version INT NOT NULL, - brand_config JSONB NOT NULL DEFAULT '{}', - layout_config JSONB NOT NULL DEFAULT '{}', - template_id BIGINT, - change_reason VARCHAR(512), - created_at TIMESTAMP DEFAULT NOW(), - created_by BIGINT, - - CONSTRAINT fk_ui_config_history_config FOREIGN KEY (config_id) REFERENCES tenant_ui_config(id), - CONSTRAINT fk_ui_config_history_template FOREIGN KEY (template_id) REFERENCES ui_template(id) -); - -CREATE INDEX idx_ui_config_history_tenant ON ui_config_history(tenant_id); -CREATE INDEX idx_ui_config_history_config ON ui_config_history(config_id); -``` - -**资源文件表 (ui_resource)** - -```sql -CREATE TABLE ui_resource ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - resource_type VARCHAR(32) NOT NULL, - resource_name VARCHAR(128) NOT NULL, - file_path VARCHAR(512) NOT NULL, - file_size BIGINT, - file_type VARCHAR(32), - width INT, - height INT, - created_at TIMESTAMP DEFAULT NOW(), - created_by BIGINT, - deleted_at TIMESTAMP DEFAULT NULL, - - CONSTRAINT uk_ui_resource UNIQUE (tenant_id, resource_type, resource_name) -); - -CREATE INDEX idx_ui_resource_tenant ON ui_resource(tenant_id); -CREATE INDEX idx_ui_resource_type ON ui_resource(resource_type); -``` - -#### 2.6.3 核心业务逻辑 - -**品牌配置Service** - -```java -@Service -public class BrandConfigService { - - @Autowired - private TenantUiConfigRepository tenantUiConfigRepository; - - @Autowired - private UiResourceRepository uiResourceRepository; - - @Autowired - private FileStorageService fileStorageService; - - /** - * 上传Logo - */ - @Transactional - public UiResource uploadLogo(Long tenantId, MultipartFile file, Long userId) { - validateLogoFile(file); - - String filePath = fileStorageService.upload(file); - - UiResource resource = new UiResource(); - resource.setTenantId(tenantId); - resource.setResourceType("logo"); - resource.setResourceName(file.getOriginalFilename()); - resource.setFilePath(filePath); - resource.setFileSize(file.getSize()); - resource.setFileType(file.getContentType()); - - BufferedImage image = ImageIO.read(file.getInputStream()); - resource.setWidth(image.getWidth()); - resource.setHeight(image.getHeight()); - - resource = uiResourceRepository.save(resource); - - updateBrandConfig(tenantId, "logo", filePath, userId); - - return resource; - } - - /** - * 验证Logo文件 - */ - private void validateLogoFile(MultipartFile file) { - if (file.getSize() > 2 * 1024 * 1024) { - throw new BusinessException("Logo文件大小不能超过2MB"); - } - - String contentType = file.getContentType(); - if (!"image/png".equals(contentType) && !"image/jpeg".equals(contentType)) { - throw new BusinessException("Logo文件格式只支持PNG或JPG"); - } - } - - /** - * 更新品牌配置 - */ - @Transactional - public void updateBrandConfig(Long tenantId, String key, String value, Long userId) { - TenantUiConfig config = tenantUiConfigRepository.findActiveByTenantId(tenantId) - .orElseThrow(() -> new BusinessException("租户配置不存在")); - - JsonObject brandConfig = config.getBrandConfig(); - brandConfig.addProperty(key, value); - - config.setBrandConfig(brandConfig); - config.setUpdatedBy(userId); - - tenantUiConfigRepository.save(config); - } - - /** - * 设置品牌颜色 - */ - @Transactional - public void setBrandColor(Long tenantId, String colorType, String color, Long userId) { - validateColorFormat(color); - - TenantUiConfig config = tenantUiConfigRepository.findActiveByTenantId(tenantId) - .orElseThrow(() -> new BusinessException("租户配置不存在")); - - JsonObject brandConfig = config.getBrandConfig(); - brandConfig.addProperty(colorType, color); - - config.setBrandConfig(brandConfig); - config.setUpdatedBy(userId); - - tenantUiConfigRepository.save(config); - } - - /** - * 验证颜色格式 - */ - private void validateColorFormat(String color) { - if (color.matches("^#[0-9A-Fa-f]{6}$")) { - return; - } - - if (color.matches("^rgb\\(\\d{1,3},\\s*\\d{1,3},\\s*\\d{1,3}\\)$")) { - return; - } - - throw new BusinessException("颜色格式不正确,请使用HEX或RGB格式"); - } -} -``` - -**布局配置Service** - -```java -@Service -public class LayoutConfigService { - - @Autowired - private TenantUiConfigRepository tenantUiConfigRepository; - - /** - * 更新模块顺序 - */ - @Transactional - public void updateModuleOrder(Long tenantId, List moduleOrder, Long userId) { - TenantUiConfig config = tenantUiConfigRepository.findActiveByTenantId(tenantId) - .orElseThrow(() -> new BusinessException("租户配置不存在")); - - JsonObject layoutConfig = config.getLayoutConfig(); - JsonArray moduleOrderArray = new JsonArray(); - moduleOrder.forEach(moduleOrderArray::add); - - layoutConfig.add("moduleOrder", moduleOrderArray); - - config.setLayoutConfig(layoutConfig); - config.setUpdatedBy(userId); - - tenantUiConfigRepository.save(config); - } - - /** - * 隐藏/显示模块 - */ - @Transactional - public void toggleModuleVisibility(Long tenantId, String moduleCode, boolean visible, Long userId) { - TenantUiConfig config = tenantUiConfigRepository.findActiveByTenantId(tenantId) - .orElseThrow(() -> new BusinessException("租户配置不存在")); - - JsonObject layoutConfig = config.getLayoutConfig(); - JsonArray hiddenModules = layoutConfig.has("hiddenModules") - ? layoutConfig.getAsJsonArray("hiddenModules") - : new JsonArray(); - - if (visible) { - hiddenModules.removeIf(element -> element.getAsString().equals(moduleCode)); - } else { - if (!hiddenModules.contains(new JsonPrimitive(moduleCode))) { - hiddenModules.add(moduleCode); - } - } - - layoutConfig.add("hiddenModules", hiddenModules); - - config.setLayoutConfig(layoutConfig); - config.setUpdatedBy(userId); - - tenantUiConfigRepository.save(config); - } - - /** - * 设置首页布局类型 - */ - @Transactional - public void setHomeLayoutType(Long tenantId, String layoutType, Long userId) { - TenantUiConfig config = tenantUiConfigRepository.findActiveByTenantId(tenantId) - .orElseThrow(() -> new BusinessException("租户配置不存在")); - - JsonObject layoutConfig = config.getLayoutConfig(); - layoutConfig.addProperty("homeLayoutType", layoutType); - - config.setLayoutConfig(layoutConfig); - config.setUpdatedBy(userId); - - tenantUiConfigRepository.save(config); - } -} -``` - -**模板管理Service** - -```java -@Service -public class TemplateService { - - @Autowired - private UiTemplateRepository uiTemplateRepository; - - @Autowired - private TenantUiConfigRepository tenantUiConfigRepository; - - @Autowired - private UiConfigHistoryRepository uiConfigHistoryRepository; - - /** - * 获取所有可用模板 - */ - public List getAvailableTemplates() { - return uiTemplateRepository.findByStatus(1); - } - - /** - * 应用模板 - */ - @Transactional - public void applyTemplate(Long tenantId, Long templateId, Long userId) { - UiTemplate template = uiTemplateRepository.findById(templateId) - .orElseThrow(() -> new BusinessException("模板不存在")); - - if (template.getStatus() != 1) { - throw new BusinessException("模板不可用"); - } - - TenantUiConfig config = tenantUiConfigRepository.findActiveByTenantId(tenantId) - .orElseThrow(() -> new BusinessException("租户配置不存在")); - - saveConfigToHistory(config); - - JsonObject templateConfig = template.getConfig(); - JsonObject layoutConfig = templateConfig.getAsJsonObject("layoutConfig"); - - config.setLayoutConfig(layoutConfig); - config.setTemplateId(templateId); - config.setUpdatedBy(userId); - - tenantUiConfigRepository.save(config); - } - - /** - * 保存配置到历史 - */ - private void saveConfigToHistory(TenantUiConfig config) { - UiConfigHistory history = new UiConfigHistory(); - history.setTenantId(config.getTenantId()); - history.setConfigId(config.getId()); - history.setVersion(config.getVersion()); - history.setBrandConfig(config.getBrandConfig()); - history.setLayoutConfig(config.getLayoutConfig()); - history.setTemplateId(config.getTemplateId()); - - uiConfigHistoryRepository.save(history); - } - - /** - * 获取模板详情 - */ - public UiTemplate getTemplateDetail(Long templateId) { - return uiTemplateRepository.findById(templateId) - .orElseThrow(() -> new BusinessException("模板不存在")); - } -} -``` - -**配置历史Service** - -```java -@Service -public class ConfigHistoryService { - - @Autowired - private UiConfigHistoryRepository uiConfigHistoryRepository; - - @Autowired - private TenantUiConfigRepository tenantUiConfigRepository; - - /** - * 获取配置历史列表 - */ - public List getConfigHistory(Long tenantId) { - return uiConfigHistoryRepository.findByTenantIdOrderByCreatedAtDesc(tenantId); - } - - /** - * 回滚到历史版本 - */ - @Transactional - public void rollbackToVersion(Long tenantId, Long historyId, Long userId) { - UiConfigHistory history = uiConfigHistoryRepository.findById(historyId) - .orElseThrow(() -> new BusinessException("历史版本不存在")); - - if (!history.getTenantId().equals(tenantId)) { - throw new BusinessException("无权访问该历史版本"); - } - - TenantUiConfig config = tenantUiConfigRepository.findActiveByTenantId(tenantId) - .orElseThrow(() -> new BusinessException("租户配置不存在")); - - config.setBrandConfig(history.getBrandConfig()); - config.setLayoutConfig(history.getLayoutConfig()); - config.setTemplateId(history.getTemplateId()); - config.setVersion(config.getVersion() + 1); - config.setUpdatedBy(userId); - - tenantUiConfigRepository.save(config); - } - - /** - * 对比配置 - */ - public Map compareConfigs(Long tenantId, Long historyId) { - TenantUiConfig currentConfig = tenantUiConfigRepository.findActiveByTenantId(tenantId) - .orElseThrow(() -> new BusinessException("租户配置不存在")); - - UiConfigHistory historyConfig = uiConfigHistoryRepository.findById(historyId) - .orElseThrow(() -> new BusinessException("历史版本不存在")); - - Map diff = new HashMap<>(); - diff.put("brandConfigDiff", compareJsonObjects( - currentConfig.getBrandConfig(), - historyConfig.getBrandConfig() - )); - diff.put("layoutConfigDiff", compareJsonObjects( - currentConfig.getLayoutConfig(), - historyConfig.getLayoutConfig() - )); - - return diff; - } - - /** - * 比较JSON对象 - */ - private Map compareJsonObjects(JsonObject obj1, JsonObject obj2) { - Map diff = new HashMap<>(); - - Set allKeys = new HashSet<>(); - obj1.keySet().forEach(allKeys::add); - obj2.keySet().forEach(allKeys::add); - - for (String key : allKeys) { - if (!obj1.has(key)) { - diff.put(key, "新增: " + obj2.get(key)); - } else if (!obj2.has(key)) { - diff.put(key, "删除: " + obj1.get(key)); - } else if (!obj1.get(key).equals(obj2.get(key))) { - diff.put(key, "修改: " + obj1.get(key) + " -> " + obj2.get(key)); - } - } - - return diff; - } -} -``` - ---- - -## 三、API设计 - -### 3.1 会员模块API - -#### 3.1.1 会员注册 - -``` -POST /api/v1/members/register - -Request: -{ - "tenantId": 1, - "storeId": 1, - "phone": "13800138000", - "smsCode": "123456", - "name": "张三", - "gender": 1, - "birthday": "1990-01-01", - "height": 175, - "weight": 70.5, - "fitnessGoal": "减脂" -} - -Response: -{ - "code": 200, - "message": "注册成功", - "data": { - "id": 1, - "memberNo": "M10000000000000001", - "name": "张三", - "phoneMask": "138****8000", - "status": 1 - } -} -``` - -#### 3.1.2 购买会员卡 - -``` -POST /api/v1/member-cards/purchase - -Request: -{ - "memberId": 1, - "cardType": 1, - "cardName": "月卡", - "amount": 299.00, - "count": 30, - "validDays": 30 -} - -Response: -{ - "code": 200, - "message": "购买成功", - "data": { - "id": 1, - "cardNo": "C10000000000000001", - "cardName": "月卡", - "balance": 299.00, - "balanceCount": 30, - "validFrom": "2026-03-04", - "validTo": "2026-04-03", - "status": 1 - } -} -``` - -### 3.2 预约模块API - -#### 3.2.1 预约团课 - -``` -POST /api/v1/bookings/course - -Request: -{ - "memberId": 1, - "slotId": 1 -} - -Response: -{ - "code": 200, - "message": "预约成功", - "data": { - "id": 1, - "bookingNo": "B10000000000000001", - "courseName": "瑜伽", - "slotDate": "2026-03-05", - "startTime": "10:00:00", - "endTime": "11:00:00", - "status": 1 - } -} -``` - -### 3.3 签到模块API - -#### 3.3.1 扫码签到 - -``` -POST /api/v1/checkins/qrcode - -Request: -{ - "qrCode": "M10000000000000001" -} - -Response: -{ - "code": 200, - "message": "签到成功", - "data": { - "id": 1, - "memberName": "张三", - "checkInAt": "2026-03-04T10:00:00", - "status": 1 - } -} -``` - -### 3.4 数据统计模块API - -#### 3.4.1 获取数据统计 - -``` -GET /api/v1/statistics/overview?tenantId=1&storeId=1&startDate=2026-03-01&endDate=2026-03-31 - -Response: -{ - "code": 200, - "message": "查询成功", - "data": { - "memberStatistics": { - "totalMembers": 100, - "activeMembers": 80, - "newMembers": 20 - }, - "bookingStatistics": { - "totalBookings": 500, - "cancelledBookings": 50, - "successBookings": 450 - }, - "checkInStatistics": { - "totalCheckIns": 800 - } - } -} -``` - ---- - -### 3.5 UI模版定制模块API - -#### 3.5.1 上传Logo - -``` -POST /api/v1/ui-config/logo -Content-Type: multipart/form-data - -Request: -{ - "file": , - "tenantId": 1 -} - -Response: -{ - "code": 200, - "message": "上传成功", - "data": { - "id": 1, - "resourceType": "logo", - "resourceName": "logo.png", - "filePath": "/uploads/logo/1/logo.png", - "fileSize": 102400, - "fileType": "image/png", - "width": 200, - "height": 200, - "createdAt": "2026-03-07T10:00:00Z" - } -} -``` - -#### 3.5.2 设置品牌颜色 - -``` -POST /api/v1/ui-config/brand/color - -Request: -{ - "tenantId": 1, - "colorType": "primaryColor", - "color": "#FF5733" -} - -Response: -{ - "code": 200, - "message": "设置成功", - "data": { - "primaryColor": "#FF5733", - "secondaryColor": "#FFC300", - "updatedAt": "2026-03-07T10:00:00Z" - } -} -``` - -#### 3.5.3 更新模块顺序 - -``` -POST /api/v1/ui-config/layout/module-order - -Request: -{ - "tenantId": 1, - "moduleOrder": ["dashboard", "member", "booking", "checkin", "statistics"] -} - -Response: -{ - "code": 200, - "message": "更新成功", - "data": { - "moduleOrder": ["dashboard", "member", "booking", "checkin", "statistics"], - "updatedAt": "2026-03-07T10:00:00Z" - } -} -``` - -#### 3.5.4 隐藏/显示模块 - -``` -POST /api/v1/ui-config/layout/module-visibility - -Request: -{ - "tenantId": 1, - "moduleCode": "statistics", - "visible": false -} - -Response: -{ - "code": 200, - "message": "更新成功", - "data": { - "hiddenModules": ["statistics"], - "updatedAt": "2026-03-07T10:00:00Z" - } -} -``` - -#### 3.5.5 获取可用模板列表 - -``` -GET /api/v1/ui-config/templates - -Response: -{ - "code": 200, - "message": "查询成功", - "data": [ - { - "id": 1, - "name": "简约风格", - "code": "simple", - "type": "简约", - "description": "简洁清爽的设计风格", - "thumbnail": "/templates/simple/thumbnail.png", - "previewImage": "/templates/simple/preview.png", - "sortOrder": 1 - }, - { - "id": 2, - "name": "运动风格", - "code": "sport", - "type": "运动", - "description": "活力四射的运动风格", - "thumbnail": "/templates/sport/thumbnail.png", - "previewImage": "/templates/sport/preview.png", - "sortOrder": 2 - } - ] -} -``` - -#### 3.5.6 应用模板 - -``` -POST /api/v1/ui-config/template/apply - -Request: -{ - "tenantId": 1, - "templateId": 1 -} - -Response: -{ - "code": 200, - "message": "应用成功", - "data": { - "templateId": 1, - "templateName": "简约风格", - "appliedAt": "2026-03-07T10:00:00Z" - } -} -``` - -#### 3.5.7 获取配置历史 - -``` -GET /api/v1/ui-config/history?tenantId=1 - -Response: -{ - "code": 200, - "message": "查询成功", - "data": [ - { - "id": 1, - "version": 1, - "templateId": 1, - "changeReason": "应用简约风格模板", - "createdAt": "2026-03-07T10:00:00Z", - "createdBy": 1 - }, - { - "id": 2, - "version": 2, - "templateId": 2, - "changeReason": "切换到运动风格模板", - "createdAt": "2026-03-07T11:00:00Z", - "createdBy": 1 - } - ] -} -``` - -#### 3.5.8 回滚到历史版本 - -``` -POST /api/v1/ui-config/history/rollback - -Request: -{ - "tenantId": 1, - "historyId": 1 -} - -Response: -{ - "code": 200, - "message": "回滚成功", - "data": { - "version": 3, - "rolledBackFrom": 1, - "rolledBackAt": "2026-03-07T12:00:00Z" - } -} -``` - -#### 3.5.9 对比配置 - -``` -GET /api/v1/ui-config/history/compare?tenantId=1&historyId=1 - -Response: -{ - "code": 200, - "message": "查询成功", - "data": { - "brandConfigDiff": { - "primaryColor": "修改: #FF5733 -> #FFC300", - "logo": "删除: /uploads/logo/1/logo.png" - }, - "layoutConfigDiff": { - "moduleOrder": "修改: [\"dashboard\", \"member\"] -> [\"member\", \"dashboard\"]", - "homeLayoutType": "新增: card" - } - } -} -``` - -#### 3.5.10 获取当前配置 - -``` -GET /api/v1/ui-config/current?tenantId=1 - -Response: -{ - "code": 200, - "message": "查询成功", - "data": { - "id": 1, - "tenantId": 1, - "version": 3, - "brandConfig": { - "logo": "/uploads/logo/1/logo.png", - "primaryColor": "#FF5733", - "secondaryColor": "#FFC300", - "brandName": "我的健身房", - "slogan": "健康生活,从现在开始" - }, - "layoutConfig": { - "moduleOrder": ["dashboard", "member", "booking", "checkin"], - "hiddenModules": ["statistics"], - "homeLayoutType": "card" - }, - "templateId": 1, - "isActive": true, - "updatedAt": "2026-03-07T10:00:00Z" - } -} -``` - ---- - -## 四、缓存策略 - -### 4.1 缓存设计 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 缓存策略 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 本地缓存 (Caffeine) │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 会员信息缓存 (TTL: 30分钟) │ │ -│ │ • 会员卡缓存 (TTL: 30分钟) │ │ -│ │ • 课程信息缓存 (TTL: 1小时) │ │ -│ │ • 课程时段缓存 (TTL: 30分钟) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 分布式缓存 (Redis) │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 验证码缓存 (TTL: 5分钟) │ │ -│ │ • 令牌缓存 (TTL: 24小时) │ │ -│ │ • 限流计数器 (TTL: 1分钟) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 4.2 缓存实现 - -**会员缓存服务** - -```java -@Service -public class MemberCacheService { - - @Autowired - private CacheManager cacheManager; - - private static final String MEMBER_CACHE = "member"; - private static final String MEMBER_CARD_CACHE = "memberCard"; - - /** - * 获取会员缓存 - */ - public Optional getMember(Long memberId) { - Cache cache = cacheManager.getCache(MEMBER_CACHE); - if (cache != null) { - return Optional.ofNullable(cache.get(memberId, Member.class)); - } - return Optional.empty(); - } - - /** - * 设置会员缓存 - */ - public void setMember(Member member) { - Cache cache = cacheManager.getCache(MEMBER_CACHE); - if (cache != null) { - cache.put(member.getId(), member); - } - } - - /** - * 删除会员缓存 - */ - public void evictMember(Long memberId) { - Cache cache = cacheManager.getCache(MEMBER_CACHE); - if (cache != null) { - cache.evict(memberId); - } - } - - /** - * 获取会员卡缓存 - */ - public Optional getMemberCard(Long cardId) { - Cache cache = cacheManager.getCache(MEMBER_CARD_CACHE); - if (cache != null) { - return Optional.ofNullable(cache.get(cardId, MemberCard.class)); - } - return Optional.empty(); - } - - /** - * 设置会员卡缓存 - */ - public void setMemberCard(MemberCard card) { - Cache cache = cacheManager.getCache(MEMBER_CARD_CACHE); - if (cache != null) { - cache.put(card.getId(), card); - } - } - - /** - * 删除会员卡缓存 - */ - public void evictMemberCard(Long cardId) { - Cache cache = cacheManager.getCache(MEMBER_CARD_CACHE); - if (cache != null) { - cache.evict(cardId); - } - } -} -``` - ---- - -## 五、异常处理 - -### 5.1 异常分类 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 异常分类 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 业务异常 (BusinessException) │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 会员不存在 • 会员卡无效 • 预约失败 • 签到失败 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 参数异常 (ValidationException) │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 参数为空 • 参数格式错误 • 参数超出范围 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 权限异常 (PermissionException) │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 无权限 • 权限不足 • 令牌过期 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 系统异常 (SystemException) │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 数据库异常 • 网络异常 • 服务异常 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 5.2 异常处理实现 - -**全局异常处理器** - -```java -@RestControllerAdvice -public class GlobalExceptionHandler { - - /** - * 业务异常 - */ - @ExceptionHandler(BusinessException.class) - public ResponseEntity handleBusinessException(BusinessException e) { - ErrorResponse response = new ErrorResponse(); - response.setCode(e.getCode()); - response.setMessage(e.getMessage()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); - } - - /** - * 参数异常 - */ - @ExceptionHandler(ValidationException.class) - public ResponseEntity handleValidationException(ValidationException e) { - ErrorResponse response = new ErrorResponse(); - response.setCode(400); - response.setMessage(e.getMessage()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); - } - - /** - * 权限异常 - */ - @ExceptionHandler(PermissionException.class) - public ResponseEntity handlePermissionException(PermissionException e) { - ErrorResponse response = new ErrorResponse(); - response.setCode(403); - response.setMessage(e.getMessage()); - return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response); - } - - /** - * 系统异常 - */ - @ExceptionHandler(SystemException.class) - public ResponseEntity handleSystemException(SystemException e) { - ErrorResponse response = new ErrorResponse(); - response.setCode(500); - response.setMessage("系统异常"); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); - } -} -``` - ---- - -## 六、测试用例 - -### 6.1 会员模块测试用例 - -#### 6.1.1 会员注册测试 - -| 测试用例 | 输入 | 预期输出 | -|---------|------|---------| -| 正常注册 | 手机号、验证码、姓名 | 注册成功 | -| 手机号已存在 | 已存在的手机号 | 提示手机号已存在 | -| 验证码错误 | 错误的验证码 | 提示验证码错误 | -| 验证码过期 | 过期的验证码 | 提示验证码过期 | - -#### 6.1.2 购买会员卡测试 - -| 测试用例 | 输入 | 预期输出 | -|---------|------|---------| -| 正常购买 | 会员ID、卡类型、金额 | 购买成功 | -| 会员不存在 | 不存在的会员ID | 提示会员不存在 | -| 支付失败 | 支付失败 | 提示支付失败 | - -### 6.2 预约模块测试用例 - -#### 6.2.1 预约团课测试 - -| 测试用例 | 输入 | 预期输出 | -|---------|------|---------| -| 正常预约 | 会员ID、课程时段ID | 预约成功 | -| 预约时间过短 | 课程开始前30分钟内 | 提示预约时间过短 | -| 课程已满 | 已满的课程时段 | 提示课程已满 | -| 权益不足 | 权益不足的会员 | 提示权益不足 | - -### 6.3 签到模块测试用例 - -#### 6.3.1 扫码签到测试 - -| 测试用例 | 输入 | 预期输出 | -|---------|------|---------| -| 正常签到 | 有效的二维码 | 签到成功 | -| 会员不存在 | 无效的二维码 | 提示会员不存在 | -| 会员卡无效 | 会员卡无效 | 提示会员卡无效 | - ---- - -## 七、部署与运维 - -### 7.1 部署架构 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 部署架构 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 负载均衡 (Nginx) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 应用服务器 (Kubernetes) │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • Pod 1 • Pod 2 • Pod 3 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 数据库 (PostgreSQL) │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 主库 • 从库 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 缓存 (Redis) │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 主节点 • 从节点 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 7.2 监控指标 - -| 指标类型 | 指标名称 | 阈值 | -|---------|---------|------| -| 系统指标 | CPU使用率 | ≤ 80% | -| 系统指标 | 内存使用率 | ≤ 80% | -| 系统指标 | 磁盘使用率 | ≤ 80% | -| 应用指标 | API响应时间 | ≤ 500ms | -| 应用指标 | 错误率 | ≤ 1% | -| 应用指标 | 并发用户数 | ≤ 100 | - ---- - -## 八、附录 - -### 8.1 术语定义 - -| 术语 | 定义 | -|------|------| -| 会员 | 在健身房注册的用户 | -| 会员卡 | 会员购买的权益卡,包括时长卡、次卡、储值卡 | -| 权益 | 会员卡包含的时长、次数、储值、等级等权益 | -| 团课 | 集体课程,由教练带领多个会员一起上课 | -| 预约 | 会员预约团课 | -| 签到 | 会员到店记录 | - -### 8.2 参考文档 - -- 《健身房管理系统基础版产品设计文档》 GYM-PRD-BASIC-001 -- 《健身房管理系统基础版业务概要设计文档》 GYM-HLD-BASIC-001 -- Spring Boot 3 官方文档 -- R2DBC 规范文档 -- PostgreSQL 官方文档 diff --git a/docs/design/OPS-部署运维文档.md b/docs/design/OPS-部署运维文档.md index 9cf27e4..97b666a 100644 --- a/docs/design/OPS-部署运维文档.md +++ b/docs/design/OPS-部署运维文档.md @@ -4,7 +4,7 @@ > 版本: v1.0 > 日期: 2026-03-04 > 作者: 张翔 -> 状态: 初稿 +> 状态: 正式发布 --- @@ -29,42 +29,18 @@ ### 1.1 部署拓扑 -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 部署架构拓扑 │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ 用户层 │ │ -│ ├─────────────────────────────────────────────────────────┤ │ -│ │ • 会员小程序 • 教练端App • 管理后台PC │ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ 负载均衡层 (Nginx) │ │ -│ ├─────────────────────────────────────────────────────────┤ │ -│ │ • 负载均衡 • SSL 终止 • 静态资源 • 限流 │ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ 应用层 (Docker Compose) │ │ -│ ├─────────────────────────────────────────────────────────┤ │ -│ │ • gym-manage (应用) • postgres (数据库) │ │ -│ │ • redis (缓存) • rabbitmq (消息队列) │ │ -│ │ • elasticsearch (搜索引擎) • prometheus (监控) │ │ -│ │ • grafana (可视化) • kibana (日志可视化) │ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ 监控层 (Prometheus + Grafana) │ │ -│ ├─────────────────────────────────────────────────────────┤ │ -│ │ • 指标采集 • 告警规则 • 可视化仪表板 │ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────┘ +```mermaid +flowchart TB + subgraph 部署架构拓扑 + A[用户层
• 会员小程序
• 教练端App
• 管理后台PC] + B[负载均衡层 Nginx
• 负载均衡
• SSL 终止
• 静态资源
• 限流] + C[应用层 Docker Compose
• gym-manage 应用
• postgres 数据库
• redis 缓存
• rabbitmq 消息队列
• elasticsearch 搜索引擎
• prometheus 监控
• grafana 可视化
• kibana 日志可视化] + D[监控层 Prometheus + Grafana
• 指标采集
• 告警规则
• 可视化仪表板] + end + + A --> B + B --> C + C --> D ``` ### 1.2 服务器配置 @@ -436,7 +412,7 @@ docker-compose logs -f gym-manage docker-compose logs --tail=100 gym-manage # 查看特定时间的日志 -docker-compose logs --since 2024-01-01T00:00:00 gym-manage +docker-compose logs --since 2026-01-01T00:00:00 gym-manage ``` #### 5.2.2 日志文件 @@ -776,7 +752,7 @@ crontab -e docker-compose stop gym-manage # 恢复数据库 -docker-compose exec -T postgres psql -U postgres gym_manage < backup/gym_manage_20240101_020000.sql +docker-compose exec -T postgres psql -U postgres gym_manage < backup/gym_manage_20260101_020000.sql # 启动应用 docker-compose start gym-manage @@ -834,6 +810,706 @@ spring: --- + +## 六、监控告警详细配置 + +### 6.1 Prometheus 监控配置 + +#### 6.1.1 prometheus.yml 配置 + +**文件位置**: `monitoring/prometheus.yml` + +```yaml +global: + scrape_interval: 15s # 采集间隔 + evaluation_interval: 15s # 规则评估间隔 + external_labels: + monitor: 'gym-manage' + environment: 'production' + +# 告警规则配置 +rule_files: + - "alerts.yml" + +# 告警管理器配置 +alerting: + alertmanagers: + - static_configs: + - targets: + - alertmanager:9093 + +# 采集配置 +scrape_configs: + # Prometheus 自监控 + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + labels: + instance: 'prometheus-server' + + # 应用监控 + - job_name: 'gym-manage' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['gym-manage:8080'] + labels: + application: 'gym-manage' + environment: 'production' + scrape_interval: 10s + + # Node 导出器 + - job_name: 'node-exporter' + static_configs: + - targets: ['node-exporter:9100'] + labels: + instance: 'server-node' + + # Redis 导出器 + - job_name: 'redis-exporter' + static_configs: + - targets: ['redis-exporter:9121'] + labels: + instance: 'redis-server' + + # PostgreSQL 导出器 + - job_name: 'postgres-exporter' + static_configs: + - targets: ['postgres-exporter:9187'] + labels: + instance: 'postgres-server' + + # RabbitMQ 导出器 + - job_name: 'rabbitmq-exporter' + static_configs: + - targets: ['rabbitmq-exporter:9419'] + labels: + instance: 'rabbitmq-server' +``` + +#### 6.1.2 alerts.yml 告警规则 + +**文件位置**: `monitoring/alerts.yml` + +```yaml +groups: + - name: gym-manage-alerts + interval: 30s + rules: + # 应用可用性告警 + - alert: ApplicationDown + expr: up{job="gym-manage"} == 0 + for: 1m + labels: + severity: critical + annotations: + summary: "应用不可用" + description: "应用 {{ $labels.instance }} 已宕机超过 1 分钟" + + # 高错误率告警 + - alert: HighErrorRate + expr: sum(rate(http_server_requests_seconds_count{status=~"5..", job="gym-manage"}[5m])) / sum(rate(http_server_requests_seconds_count{job="gym-manage"}[5m])) > 0.05 + for: 5m + labels: + severity: warning + annotations: + summary: "高错误率" + description: "应用错误率超过 5% (当前值:{{ $value | humanizePercentage }})" + + # 高响应时间告警 + - alert: HighResponseTime + expr: histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{job="gym-manage"}[5m])) by (le)) > 1 + for: 5m + labels: + severity: warning + annotations: + summary: "高响应时间" + description: "应用 P95 响应时间超过 1 秒 (当前值:{{ $value | humanizeDuration }})" + + # 高内存使用率告警 + - alert: HighMemoryUsage + expr: (jvm_memory_used_bytes{area="heap", job="gym-manage"} / jvm_memory_max_bytes{area="heap", job="gym-manage"}) > 0.85 + for: 5m + labels: + severity: warning + annotations: + summary: "高内存使用率" + description: "JVM 堆内存使用率超过 85% (当前值:{{ $value | humanizePercentage }})" + + # OOM 告警 + - alert: OutOfMemory + expr: (jvm_memory_used_bytes{area="heap", job="gym-manage"} / jvm_memory_max_bytes{area="heap", job="gym-manage"}) > 0.95 + for: 2m + labels: + severity: critical + annotations: + summary: "内存即将耗尽" + description: "JVM 堆内存使用率超过 95% (当前值:{{ $value | humanizePercentage }})" + + # 数据库连接池耗尽告警 + - alert: DatabaseConnectionPoolExhausted + expr: hikaricp_active_connections{job="gym-manage"} / hikaricp_max_connections{job="gym-manage"} > 0.9 + for: 5m + labels: + severity: warning + annotations: + summary: "数据库连接池耗尽" + description: "数据库连接池使用率超过 90% (当前值:{{ $value | humanizePercentage }})" + + # Redis 连接失败告警 + - alert: RedisConnectionFailed + expr: redis_up{job="redis-exporter"} == 0 + for: 1m + labels: + severity: critical + annotations: + summary: "Redis 连接失败" + description: "Redis {{ $labels.instance }} 连接失败" + + # PostgreSQL 连接失败告警 + - alert: PostgresConnectionFailed + expr: pg_up{job="postgres-exporter"} == 0 + for: 1m + labels: + severity: critical + annotations: + summary: "PostgreSQL 连接失败" + description: "PostgreSQL {{ $labels.instance }} 连接失败" + + # RabbitMQ 队列堆积告警 + - alert: RabbitMQQueueBacklog + expr: rabbitmq_queue_messages{job="rabbitmq-exporter"} > 1000 + for: 5m + labels: + severity: warning + annotations: + summary: "消息队列堆积" + description: "队列 {{ $labels.queue }} 消息数量超过 1000 (当前值:{{ $value }})" + + # 磁盘空间不足告警 + - alert: DiskSpaceLow + expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) < 0.15 + for: 5m + labels: + severity: warning + annotations: + summary: "磁盘空间不足" + description: "服务器 {{ $labels.instance }} 根分区磁盘空间不足 15% (当前值:{{ $value | humanizePercentage }})" + + # CPU 使用率过高告警 + - alert: HighCPUUsage + expr: 100 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 85 + for: 10m + labels: + severity: warning + annotations: + summary: "CPU 使用率过高" + description: "服务器 {{ $labels.instance }} CPU 使用率超过 85% (当前值:{{ $value | humanize }}%)" +``` + +### 6.2 Grafana 仪表板配置 + +#### 6.2.1 应用监控仪表板 + +**仪表板 ID**: `gym-manage-overview` + +**主要面板**: +1. **应用健康状态** + - 应用在线状态 + - 健康检查状态 + - 运行时长 + +2. **流量指标** + - QPS (每秒请求数) + - 并发连接数 + - 网络吞吐量 + +3. **响应时间** + - 平均响应时间 + - P95 响应时间 + - P99 响应时间 + +4. **错误率** + - HTTP 5xx 错误率 + - HTTP 4xx 错误率 + - 业务错误率 + +5. **JVM 指标** + - 堆内存使用率 + - 非堆内存使用率 + - GC 次数和时间 + - 线程数 + +6. **数据库连接池** + - 活跃连接数 + - 空闲连接数 + - 连接池使用率 + - 平均获取连接时间 + +7. **Redis 缓存** + - 缓存命中率 + - 缓存键数量 + - 内存使用量 + - 命令执行时间 + +8. **消息队列** + - 队列消息数量 + - 消息生产速率 + - 消息消费速率 + - 消息堆积情况 + +#### 6.2.2 系统监控仪表板 + +**仪表板 ID**: `system-overview` + +**主要面板**: +1. **CPU 指标** + - CPU 使用率 + - CPU 负载 (1/5/15 分钟) + - CPU 核心数 + +2. **内存指标** + - 内存使用率 + - 可用内存 + - Swap 使用率 + +3. **磁盘指标** + - 磁盘使用率 + - 磁盘 I/O + - 磁盘读写速率 + +4. **网络指标** + - 网络流量 + - 网络连接数 + - 网络错误率 + +### 6.3 告警通知配置 + +#### 6.3.1 Alertmanager 配置 + +**文件位置**: `monitoring/alertmanager.yml` + +```yaml +global: + # 邮件配置 + smtp_smarthost: 'smtp.example.com:587' + smtp_from: 'alertmanager@example.com' + smtp_auth_username: 'alertmanager@example.com' + smtp_auth_password: 'your-password' + + # 钉钉配置 + dingtalk_configs: + - url: 'https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN' + secret: 'YOUR_SECRET' + send_resolved: true + + # 企业微信配置 + wechat_configs: + - corp_id: 'YOUR_CORP_ID' + agent_id: 'YOUR_AGENT_ID' + secret: 'YOUR_SECRET' + to_user: '@all' + send_resolved: true + +# 模板配置 +templates: + - '/etc/alertmanager/templates/*.tmpl' + +# 路由配置 +route: + receiver: 'default-receiver' + group_by: ['alertname', 'severity'] + group_wait: 30s + group_interval: 5m + repeat_interval: 4h + routes: + # 严重告警立即通知 + - match: + severity: critical + receiver: 'critical-receiver' + group_wait: 10s + repeat_interval: 1h + # 警告告警延迟通知 + - match: + severity: warning + receiver: 'warning-receiver' + group_wait: 5m + repeat_interval: 4h + +# 接收器配置 +receivers: + - name: 'default-receiver' + email_configs: + - to: 'devops-team@example.com' + send_resolved: true + + - name: 'critical-receiver' + email_configs: + - to: 'oncall@example.com' + send_resolved: true + dingtalk_configs: + - send_resolved: true + wechat_configs: + - send_resolved: true + + - name: 'warning-receiver' + email_configs: + - to: 'dev-team@example.com' + send_resolved: true + +# 抑制规则 +inhibit_rules: + # 如果应用宕机,抑制其他告警 + - source_match: + alertname: 'ApplicationDown' + target_match: + severity: 'warning' + equal: ['instance'] +``` + +#### 6.3.2 告警升级策略 + +**升级规则**: +1. **P0 级别 (Critical)** + - 立即通知:钉钉 + 企业微信 + 短信 + 电话 + - 15 分钟未响应:升级至技术总监 + - 30 分钟未响应:升级至 CTO + +2. **P1 级别 (Warning)** + - 立即通知:钉钉 + 企业微信 + - 1 小时未响应:升级至部门经理 + - 2 小时未响应:升级至技术总监 + +3. **P2 级别 (Info)** + - 工作时间通知:邮件 + - 24 小时未处理:升级为 Warning + +#### 6.3.3 告警值班安排 + +**值班表配置**: +```yaml +# 工作日值班 +work_hours: + - Monday to Friday: 09:00-18:00 + +# 值班人员 +on_call_schedule: + - name: "张三" + email: "zhangsan@example.com" + phone: "13800138000" + schedule: "周一,周三" + - name: "李四" + email: "lisi@example.com" + phone: "13900139000" + schedule: "周二,周四" + - name: "王五" + email: "wangwu@example.com" + phone: "13700137000" + schedule: "周五" + +# 周末值班 +weekend_on_call: + - name: "值班团队" + email: "weekend-team@example.com" + phone: "400-xxx-xxxx" +``` + +--- + +## 七、备份恢复详细策略 + +### 7.1 备份策略 + +#### 7.1.1 备份类型 + +**全量备份**: +- 频率:每日凌晨 2 点 +- 保留期限:30 天 +- 备份内容:完整数据库、配置文件 + +**增量备份**: +- 频率:每小时 +- 保留期限:7 天 +- 备份内容:WAL 日志、变更数据 + +**差异备份**: +- 频率:每 6 小时 +- 保留期限:7 天 +- 备份内容:自上次全量备份后的变更 + +#### 7.1.2 备份内容 + +**数据库备份**: +```bash +# PostgreSQL 全量备份脚本 +#!/bin/bash +BACKUP_DIR="/backup/postgres" +DATE=$(date +%Y%m%d_%H%M%S) +DB_NAME="gym_manage" +DB_USER="postgres" + +# 创建备份目录 +mkdir -p ${BACKUP_DIR} + +# 全量备份 +pg_dump -U ${DB_USER} -h localhost ${DB_NAME} | gzip > ${BACKUP_DIR}/${DB_NAME}_${DATE}.sql.gz + +# 备份 WAL 日志 +# 配置 postgresql.conf: +# wal_level = replica +# archive_mode = on +# archive_command = 'cp %p /backup/wal/%f' + +# 清理旧备份 (保留 30 天) +find ${BACKUP_DIR} -name "*.sql.gz" -mtime +30 -delete +``` + +**配置文件备份**: +```bash +# 备份应用配置 +#!/bin/bash +BACKUP_DIR="/backup/config" +DATE=$(date +%Y%m%d_%H%M%S) + +# 备份配置文件 +tar -czf ${BACKUP_DIR}/config_${DATE}.tar.gz application-prod.yml docker-compose.yml nginx/nginx.conf monitoring/prometheus.yml monitoring/alerts.yml + +# 备份环境变量 +docker-compose exec gym-manage env > ${BACKUP_DIR}/env_${DATE}.txt +``` + +**数据文件备份**: +```bash +# 备份 Redis 数据 +#!/bin/bash +BACKUP_DIR="/backup/redis" +DATE=$(date +%Y%m%d_%H%M%S) + +# 触发 RDB 保存 +docker-compose exec redis redis-cli BGSAVE + +# 等待保存完成 +sleep 5 + +# 复制 RDB 文件 +docker cp gym-manage-redis:/data/dump.rdb ${BACKUP_DIR}/dump_${DATE}.rdb + +# 备份 Elasticsearch 数据 +docker-compose exec elasticsearch elasticsearch-snapshot -repository backup -snapshot gym_manage_${DATE} +``` + +#### 7.1.3 备份验证 + +**定期验证**: +- 频率:每周日凌晨 3 点 +- 内容:验证备份文件完整性 +- 方法:恢复测试 + +```bash +# 备份验证脚本 +#!/bin/bash +BACKUP_DIR="/backup/postgres" +LATEST_BACKUP=$(ls -t ${BACKUP_DIR}/*.sql.gz | head -1) + +# 验证备份文件完整性 +if gzip -t ${LATEST_BACKUP}; then + echo "备份文件完整: ${LATEST_BACKUP}" +else + echo "备份文件损坏: ${LATEST_BACKUP}" + # 发送告警 + curl -X POST "https://alert.example.com/backup-failed" +fi + +# 恢复测试 (在测试环境) +# gunzip -c ${LATEST_BACKUP} | psql -U postgres -h test-db gym_manage_test +``` + +### 7.2 恢复策略 + +#### 7.2.1 恢复优先级 + +**P0 - 核心业务恢复** (RTO ≤ 30 分钟): +1. 数据库恢复 +2. 应用服务恢复 +3. 缓存恢复 + +**P1 - 重要业务恢复** (RTO ≤ 2 小时): +4. 消息队列恢复 +5. 搜索引擎恢复 +6. 日志系统恢复 + +**P2 - 辅助业务恢复** (RTO ≤ 4 小时): +7. 监控系统恢复 +8. 报表系统恢复 +9. 备份系统恢复 + +#### 7.2.2 数据库恢复流程 + +**完整恢复流程**: +```bash +#!/bin/bash +# 数据库恢复脚本 + +BACKUP_FILE=$1 +DB_NAME="gym_manage" +DB_USER="postgres" + +echo "开始恢复数据库..." + +# 1. 停止应用 +echo "停止应用..." +docker-compose stop gym-manage + +# 2. 创建临时数据库 +echo "创建临时数据库..." +docker-compose exec postgres psql -U postgres -c "CREATE DATABASE ${DB_NAME}_restore;" + +# 3. 恢复数据 +echo "恢复数据..." +gunzip -c ${BACKUP_FILE} | docker-compose exec -T postgres psql -U postgres ${DB_NAME}_restore + +# 4. 验证数据 +echo "验证数据..." +docker-compose exec postgres psql -U postgres -d ${DB_NAME}_restore -c "SELECT COUNT(*) FROM members;" + +# 5. 备份当前数据库 (如果有) +if docker-compose exec postgres psql -U postgres -lqt | cut -d \| -f 1 | grep -w ${DB_NAME}; then + echo "备份当前数据库..." + docker-compose exec postgres pg_dump -U postgres ${DB_NAME} | gzip > /backup/emergency_${DB_NAME}_$(date +%Y%m%d_%H%M%S).sql.gz +fi + +# 6. 删除原数据库 +echo "删除原数据库..." +docker-compose exec postgres psql -U postgres -c "DROP DATABASE ${DB_NAME};" + +# 7. 重命名恢复的数据库 +echo "重命名数据库..." +docker-compose exec postgres psql -U postgres -c "ALTER DATABASE ${DB_NAME}_restore RENAME TO ${DB_NAME};" + +# 8. 启动应用 +echo "启动应用..." +docker-compose start gym-manage + +# 9. 验证应用 +echo "验证应用..." +sleep 10 +curl -f http://localhost:8080/actuator/health + +echo "数据库恢复完成!" +``` + +#### 7.2.3 应用恢复流程 + +```bash +#!/bin/bash +# 应用恢复脚本 + +echo "开始恢复应用..." + +# 1. 停止应用 +docker-compose stop gym-manage + +# 2. 清理旧容器 +docker-compose rm -f gym-manage + +# 3. 拉取最新镜像 +docker-compose pull gym-manage + +# 4. 恢复配置 +cp backup/application/application-prod.yml.bak ./config/application-prod.yml + +# 5. 启动应用 +docker-compose up -d gym-manage + +# 6. 等待启动 +sleep 30 + +# 7. 健康检查 +curl -f http://localhost:8080/actuator/health || exit 1 + +echo "应用恢复完成!" +``` + +#### 7.2.4 缓存恢复流程 + +```bash +#!/bin/bash +# Redis 恢复脚本 + +echo "开始恢复 Redis..." + +# 1. 停止 Redis +docker-compose stop redis + +# 2. 清理旧数据 +docker-compose run --rm redis rm -rf /data/* + +# 3. 恢复 RDB 文件 +LATEST_RDB=$(ls -t /backup/redis/dump_*.rdb | head -1) +cp ${LATEST_RDB} docker/redis/data/dump.rdb + +# 4. 启动 Redis +docker-compose up -d redis + +# 5. 验证 +docker-compose exec redis redis-cli PING + +echo "Redis 恢复完成!" +``` + +### 7.3 灾难恢复 + +#### 7.3.1 灾难恢复场景 + +**场景 1: 单服务器故障** +- 恢复时间:RTO ≤ 1 小时 +- 恢复点:RPO ≤ 15 分钟 +- 恢复步骤: + 1. 切换到备用服务器 + 2. 从备份恢复数据 + 3. 更新 DNS 解析 + 4. 验证服务可用性 + +**场景 2: 数据中心故障** +- 恢复时间:RTO ≤ 4 小时 +- 恢复点:RPO ≤ 1 小时 +- 恢复步骤: + 1. 启用异地灾备中心 + 2. 从异地备份恢复数据 + 3. 切换流量到灾备中心 + 4. 验证服务可用性 + +**场景 3: 数据损坏/丢失** +- 恢复时间:RTO ≤ 2 小时 +- 恢复点:RPO ≤ 15 分钟 +- 恢复步骤: + 1. 确定数据损坏时间点 + 2. 从损坏前的备份恢复 + 3. 应用增量备份 + 4. 验证数据完整性 + +#### 7.3.2 灾难恢复演练 + +**演练频率**: +- 桌面推演:每月一次 +- 实战演练:每季度一次 +- 全链路演练:每半年一次 + +**演练内容**: +1. 备份恢复验证 +2. 故障切换验证 +3. 监控告警验证 +4. 通讯流程验证 +5. 文档更新验证 + +**演练报告**: +- 演练目标 +- 演练过程 +- 问题记录 +- 改进措施 +- 责任人和时间节点 + +--- + ## 十、总结 ### 10.1 部署要点 diff --git a/docs/design/STD-响应式编程规范.md b/docs/design/STD-响应式编程规范.md deleted file mode 100644 index 144961e..0000000 --- a/docs/design/STD-响应式编程规范.md +++ /dev/null @@ -1,945 +0,0 @@ -# 响应式编程规范文档 - -> 文档编号: GYM-STD-REACTIVE-001 -> 版本: v1.0 -> 日期: 2026-03-04 -> 作者: 张翔 -> 状态: 初稿 - ---- - -## 文档修订历史 - -| 版本 | 日期 | 作者 | 修订内容 | -| ---- | ---------- | ---- | ------------------ | -| v1.0 | 2026-03-04 | 张翔 | 创建响应式编程规范文档 | - ---- - -## 参考文档 - -- 《健身房管理系统技术架构设计文档》 GYM-HLD-TECH-001 -- Project Reactor 官方文档 -- Spring WebFlux 官方文档 -- R2DBC 官方文档 - ---- - -## 一、概述 - -### 1.1 目的 - -本文档旨在为健身房管理系统项目制定响应式编程规范,确保团队成员正确使用响应式编程技术栈,避免常见的反模式,提高代码质量和系统性能。 - -### 1.2 适用范围 - -本规范适用于所有使用 Spring WebFlux + R2DBC 技术栈的代码开发。 - -### 1.3 核心原则 - -1. **永不阻塞**:禁止在响应式流中使用阻塞操作 -2. **链式调用**:使用操作符链式调用,避免嵌套 -3. **错误处理**:使用响应式错误处理机制,避免 try-catch -4. **背压处理**:正确处理背压,避免内存溢出 -5. **资源释放**:确保所有资源正确释放,避免资源泄漏 - ---- - -## 二、响应式编程基础 - -### 2.1 核心概念 - -#### 2.1.1 Mono - -**定义**:表示 0-1 个元素的异步序列,返回单个对象或空。 - -**适用场景**: -- 查询单个对象 -- 保存单个对象 -- 更新单个对象 -- 删除单个对象 - -**示例**: - -```java -// 查询单个会员 -public Mono getMember(Long id) { - return memberRepository.findById(id); -} - -// 保存单个会员 -public Mono saveMember(Member member) { - return memberRepository.save(member); -} -``` - -#### 2.1.2 Flux - -**定义**:表示 0-N 个元素的异步序列,返回多个对象。 - -**适用场景**: -- 查询列表 -- 批量操作 -- 流式处理 -- 实时数据推送 - -**示例**: - -```java -// 查询会员列表 -public Flux listMembers(Long tenantId) { - return memberRepository.findByTenantId(tenantId); -} - -// 批量保存会员 -public Flux saveMembers(List members) { - return Flux.fromIterable(members) - .flatMap(memberRepository::save); -} -``` - -#### 2.1.3 Scheduler - -**定义**:控制响应式操作的执行线程。 - -**常用 Scheduler**: - -| Scheduler | 用途 | 示例 | -|-----------|------|------| -| **Schedulers.parallel()** | CPU 密集型操作 | 数据计算、转换 | -| **Schedulers.boundedElastic()** | 阻塞 I/O 操作 | 文件读写、网络请求 | -| **Schedulers.single()** | 单线程顺序执行 | 顺序处理任务 | -| **Schedulers.immediate()** | 当前线程执行 | 简单操作 | - -**示例**: - -```java -// CPU 密集型操作 -public Flux processMembers(Flux members) { - return members.publishOn(Schedulers.parallel()) - .map(this::calculateLevel); -} - -// 阻塞 I/O 操作 -public Mono readFile(String path) { - return Mono.fromCallable(() -> Files.readString(Paths.get(path))) - .subscribeOn(Schedulers.boundedElastic()); -} -``` - -### 2.2 常用操作符 - -#### 2.2.1 转换操作符 - -| 操作符 | 功能 | 示例 | -|-------|------|------| -| **map** | 一对一转换 | `.map(member -> member.getName())` | -| **flatMap** | 一对多转换(异步) | `.flatMap(member -> loadCards(member.getId()))` | -| **flatMapMany** | 一对多转换(返回 Flux) | `.flatMapMany(member -> listBenefits(member.getId()))` | -| **filter** | 过滤元素 | `.filter(member -> member.getStatus() == 1)` | - -**示例**: - -```java -// map:一对一转换 -public Flux getMemberNames(Long tenantId) { - return memberRepository.findByTenantId(tenantId) - .map(Member::getName); -} - -// flatMap:一对多转换(异步) -public Mono getMemberWithCards(Long id) { - return memberRepository.findById(id) - .flatMap(member -> memberCardRepository.findByMemberId(member.getId()) - .collectList() - .map(cards -> { - member.setCards(cards); - return member; - })); -} - -// filter:过滤元素 -public Flux getActiveMembers(Long tenantId) { - return memberRepository.findByTenantId(tenantId) - .filter(member -> member.getStatus() == 1); -} -``` - -#### 2.2.2 条件操作符 - -| 操作符 | 功能 | 示例 | -|-------|------|------| -| **switchIfEmpty** | 序列为空时返回备选 | `.switchIfEmpty(Mono.error(new BusinessException("会员不存在")))` | -| **defaultIfEmpty** | 序列为空时返回默认值 | `.defaultIfEmpty(Member.builder().build())` | -| **take** | 取前 N 个元素 | `.take(10)` | -| **skip** | 跳过前 N 个元素 | `.skip(10)` | - -**示例**: - -```java -// switchIfEmpty:序列为空时返回备选 -public Mono getMember(Long id) { - return memberRepository.findById(id) - .switchIfEmpty(Mono.error(new BusinessException("会员不存在"))); -} - -// defaultIfEmpty:序列为空时返回默认值 -public Flux listMembers(Long tenantId) { - return memberRepository.findByTenantId(tenantId) - .defaultIfEmpty(Member.builder().build()); -} - -// take:取前 10 个元素 -public Flux listMembers(Long tenantId, int limit) { - return memberRepository.findByTenantId(tenantId) - .take(limit); -} -``` - -#### 2.2.3 错误处理操作符 - -| 操作符 | 功能 | 示例 | -|-------|------|------| -| **onErrorResume** | 捕获错误并返回备选序列 | `.onErrorResume(e -> Mono.empty())` | -| **onErrorReturn** | 捕获错误并返回默认值 | `.onErrorReturn(Member.builder().build())` | -| **doOnError** | 错误时执行副作用 | `.doOnError(e -> log.error("查询失败", e))` | -| **retry** | 重试 | `.retry(3)` | -| **retryWhen** | 高级重试 | `.retryWhen(Retry.backoff(3, Duration.ofSeconds(1)))` | - -**示例**: - -```java -// onErrorResume:捕获错误并返回备选序列 -public Mono getMember(Long id) { - return memberRepository.findById(id) - .onErrorResume(DataAccessException.class, e -> { - log.error("数据库查询失败: memberId={}", id, e); - return Mono.empty(); - }); -} - -// onErrorReturn:捕获错误并返回默认值 -public Mono getMember(Long id) { - return memberRepository.findById(id) - .onErrorReturn(Member.builder().build()); -} - -// doOnError:错误时执行副作用 -public Mono getMember(Long id) { - return memberRepository.findById(id) - .doOnError(e -> log.error("查询会员失败: memberId={}", id, e)); -} - -// retry:重试 3 次 -public Mono getMember(Long id) { - return memberRepository.findById(id) - .retry(3); -} - -// retryWhen:高级重试(指数退避) -public Mono getMember(Long id) { - return memberRepository.findById(id) - .retryWhen(Retry.backoff(3, Duration.ofSeconds(1)) - .filter(throwable -> throwable instanceof TimeoutException) - .doBeforeRetry(signal -> log.warn("重试: attempt={}", signal.totalRetries()))); -} -``` - -#### 2.2.4 生命周期操作符 - -| 操作符 | 功能 | 示例 | -|-------|------|------| -| **doOnSubscribe** | 订阅时执行 | `.doOnSubscribe(s -> log.debug("开始查询"))` | -| **doOnNext** | 每个元素到达时执行 | `.doOnNext(member -> log.debug("查询到会员: {}", member.getName()))` | -| **doOnComplete** | 完成时执行 | `.doOnComplete(() -> log.debug("查询完成"))` | -| **doOnError** | 错误时执行 | `.doOnError(e -> log.error("查询失败", e))` | -| **doOnTerminate** | 终止时执行(无论成功或失败) | `.doOnTerminate(() -> log.debug("查询结束"))` | - -**示例**: - -```java -public Mono getMember(Long id) { - return memberRepository.findById(id) - .doOnSubscribe(s -> log.debug("开始查询会员: memberId={}", id)) - .doOnNext(member -> log.debug("查询到会员: memberId={}, name={}", member.getId(), member.getName())) - .doOnComplete(() -> log.debug("查询会员完成: memberId={}", id)) - .doOnError(e -> log.error("查询会员失败: memberId={}", id, e)) - .doOnTerminate(() -> log.debug("查询会员结束: memberId={}", id)); -} -``` - ---- - -## 三、编码规范 - -### 3.1 基本原则 - -#### 3.1.1 永不阻塞 - -**规则**:禁止在响应式流中使用阻塞操作。 - -**✅ 正确示例**: - -```java -public Mono getMember(Long id) { - return memberRepository.findById(id) - .flatMap(member -> loadMemberCards(member.getId())); -} -``` - -**❌ 错误示例**: - -```java -public Mono getMember(Long id) { - return memberRepository.findById(id) - .flatMap(member -> { - // 错误:使用 block() 阻塞 - List cards = memberCardRepository.findByMemberId(member.getId()) - .collectList().block(); - member.setCards(cards); - return Mono.just(member); - }); -} -``` - -#### 3.1.2 链式调用 - -**规则**:使用操作符链式调用,避免嵌套。 - -**✅ 正确示例**: - -```java -public Mono getMemberWithCardsAndBenefits(Long id) { - return memberRepository.findById(id) - .flatMap(member -> loadMemberCards(member.getId()) - .map(cards -> { - member.setCards(cards); - return member; - })) - .flatMap(member -> loadMemberBenefits(member.getId()) - .map(benefits -> { - member.setBenefits(benefits); - return member; - })); -} -``` - -**❌ 错误示例**: - -```java -public Mono getMemberWithCardsAndBenefits(Long id) { - return memberRepository.findById(id) - .flatMap(member -> { - return loadMemberCards(member.getId()) - .map(cards -> { - member.setCards(cards); - return member; - }) - .flatMap(memberWithCards -> { - return loadMemberBenefits(memberWithCards.getId()) - .map(benefits -> { - memberWithCards.setBenefits(benefits); - return memberWithCards; - }); - }); - }); -} -``` - -#### 3.1.3 错误处理 - -**规则**:使用响应式错误处理机制,避免 try-catch。 - -**✅ 正确示例**: - -```java -public Mono getMember(Long id) { - return memberRepository.findById(id) - .switchIfEmpty(Mono.error(new BusinessException("会员不存在"))) - .onErrorResume(DataAccessException.class, e -> { - log.error("数据库查询失败: memberId={}", id, e); - return Mono.error(new SystemException("系统错误")); - }); -} -``` - -**❌ 错误示例**: - -```java -public Mono getMember(Long id) { - try { - return memberRepository.findById(id) - .switchIfEmpty(Mono.error(new BusinessException("会员不存在"))); - } catch (Exception e) { - // 错误:try-catch 无法捕获响应式异常 - log.error("查询失败", e); - return Mono.error(new SystemException("系统错误")); - } -} -``` - -### 3.2 Service 层规范 - -#### 3.2.1 基本结构 - -```java -@Service -@Slf4j -@RequiredArgsConstructor -public class MemberService { - - private final MemberRepository memberRepository; - private final MemberCardRepository memberCardRepository; - private final BenefitService benefitService; - - /** - * 查询会员 - */ - public Mono getMember(Long id) { - return memberRepository.findById(id) - .switchIfEmpty(Mono.error(new BusinessException("会员不存在"))) - .doOnSubscribe(s -> log.debug("开始查询会员: memberId={}", id)) - .doOnNext(member -> log.debug("查询到会员: memberId={}, name={}", member.getId(), member.getName())) - .doOnError(e -> log.error("查询会员失败: memberId={}", id, e)) - .doOnTerminate(() -> log.debug("查询会员结束: memberId={}", id)); - } - - /** - * 查询会员列表 - */ - public Flux listMembers(Long tenantId, Long storeId) { - return memberRepository.findByTenantIdAndStoreId(tenantId, storeId) - .filter(member -> member.getStatus() == 1) - .sort(Comparator.comparing(Member::getCreatedAt).reversed()); - } - - /** - * 创建会员 - */ - @Transactional - public Mono createMember(MemberCreateRequest request) { - return validateMemberCreateRequest(request) - .flatMap(v -> buildMember(request)) - .flatMap(memberRepository::save) - .flatMap(member -> createDefaultMemberCard(member)) - .doOnSuccess(member -> log.info("创建会员成功: memberId={}", member.getId())) - .doOnError(e -> log.error("创建会员失败: {}", e.getMessage())); - } - - private Mono validateMemberCreateRequest(MemberCreateRequest request) { - return memberRepository.findByPhoneAndTenantId(request.getPhone(), request.getTenantId()) - .flatMap(existing -> Mono.error(new BusinessException("手机号已注册"))) - .switchIfEmpty(Mono.empty()); - } - - private Mono buildMember(MemberCreateRequest request) { - Member member = Member.builder() - .tenantId(request.getTenantId()) - .storeId(request.getStoreId()) - .memberNo(generateMemberNo(request.getTenantId())) - .name(request.getName()) - .phone(encryptPhone(request.getPhone())) - .phoneMask(maskPhone(request.getPhone())) - .gender(request.getGender()) - .birthday(request.getBirthday()) - .status(1) - .build(); - - return Mono.just(member); - } - - private Mono createDefaultMemberCard(Member member) { - MemberCard card = MemberCard.builder() - .tenantId(member.getTenantId()) - .memberId(member.getId()) - .cardNo(generateCardNo(member.getTenantId())) - .status(1) - .build(); - - return memberCardRepository.save(card) - .thenReturn(member); - } -} -``` - -#### 3.2.2 事务管理 - -```java -@Service -@Slf4j -public class BookingService { - - private final BookingRecordRepository bookingRecordRepository; - private final BookingSlotRepository bookingSlotRepository; - private final BenefitService benefitService; - - /** - * 预约时段 - */ - @Transactional - public Mono bookSlot(BookingRequest request) { - return validateBooking(request) - .flatMap(v -> checkSlotAvailability(request.getSlotId())) - .flatMap(slot -> deductBenefit(request.getMemberId(), slot)) - .flatMap(benefit -> createBookingRecord(request, benefit)) - .flatMap(booking -> updateSlotBookedCount(request.getSlotId())) - .doOnSuccess(booking -> log.info("预约成功: bookingId={}", booking.getId())) - .doOnError(e -> log.error("预约失败: {}", e.getMessage())); - } - - private Mono checkSlotAvailability(Long slotId) { - return bookingSlotRepository.findById(slotId) - .switchIfEmpty(Mono.error(new BusinessException("时段不存在"))) - .filter(slot -> slot.getStatus() == 1) - .switchIfEmpty(Mono.error(new BusinessException("时段不可预约"))) - .filter(slot -> slot.getBookedCount() < slot.getCapacity()) - .switchIfEmpty(Mono.error(new BusinessException("时段已满"))); - } - - private Mono deductBenefit(Long memberId, BookingSlot slot) { - return benefitService.deductBenefit(memberId, - slot.getPriceType(), slot.getPriceValue()) - .switchIfEmpty(Mono.error(new BusinessException("权益不足"))); - } - - private Mono createBookingRecord(BookingRequest request, - MemberBenefit benefit) { - BookingRecord record = BookingRecord.builder() - .tenantId(request.getTenantId()) - .storeId(request.getStoreId()) - .memberId(request.getMemberId()) - .slotId(request.getSlotId()) - .bookingNo(generateBookingNo(request.getTenantId())) - .status(1) - .benefitId(benefit.getId()) - .build(); - - return bookingRecordRepository.save(record); - } - - private Mono updateSlotBookedCount(Long slotId) { - return bookingSlotRepository.findById(slotId) - .flatMap(slot -> { - slot.setBookedCount(slot.getBookedCount() + 1); - if (slot.getBookedCount() >= slot.getCapacity()) { - slot.setStatus(2); // 已满 - } - return bookingSlotRepository.save(slot); - }) - .then(); - } -} -``` - -### 3.3 Controller 层规范 - -#### 3.3.1 基本结构 - -```java -@RestController -@RequestMapping("/members") -@RequiredArgsConstructor -@Slf4j -public class MemberController { - - private final MemberService memberService; - - /** - * 查询会员 - */ - @GetMapping("/{id}") - public Mono>> getMember(@PathVariable Long id) { - return memberService.getMember(id) - .map(member -> ResponseEntity.ok(ApiResponse.success(member))) - .switchIfEmpty(Mono.just(ResponseEntity.notFound().build())) - .onErrorResume(BusinessException.class, e -> - Mono.just(ResponseEntity.badRequest() - .body(ApiResponse.error(e.getMessage())))) - .onErrorResume(Exception.class, e -> { - log.error("查询会员失败: memberId={}", id, e); - return Mono.just(ResponseEntity.internalServerError() - .body(ApiResponse.error("系统错误"))); - }); - } - - /** - * 查询会员列表 - */ - @GetMapping - public Mono>>> listMembers( - @RequestParam(required = false) Long tenantId, - @RequestParam(required = false) Long storeId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size) { - return memberService.listMembers(tenantId, storeId, page, size) - .map(members -> ResponseEntity.ok(ApiResponse.success(members))); - } - - /** - * 创建会员 - */ - @PostMapping - public Mono>> createMember( - @Valid @RequestBody MemberCreateRequest request) { - return memberService.createMember(request) - .map(member -> ResponseEntity.ok(ApiResponse.success(member))) - .onErrorResume(BusinessException.class, e -> - Mono.just(ResponseEntity.badRequest() - .body(ApiResponse.error(e.getMessage())))) - .onErrorResume(Exception.class, e -> { - log.error("创建会员失败", e); - return Mono.just(ResponseEntity.internalServerError() - .body(ApiResponse.error("系统错误"))); - }); - } -} -``` - ---- - -## 四、反模式 - -### 4.1 阻塞操作 - -**❌ 反模式**:在响应式流中使用 `block()`、`blockFirst()`、`blockLast()` - -```java -// 错误示例 -public Mono getMember(Long id) { - return memberRepository.findById(id) - .flatMap(member -> { - // 错误:使用 block() 阻塞 - List cards = memberCardRepository.findByMemberId(member.getId()) - .collectList().block(); - member.setCards(cards); - return Mono.just(member); - }); -} -``` - -**✅ 正确做法**:使用 `flatMap` 链式调用 - -```java -// 正确示例 -public Mono getMember(Long id) { - return memberRepository.findById(id) - .flatMap(member -> memberCardRepository.findByMemberId(member.getId()) - .collectList() - .map(cards -> { - member.setCards(cards); - return member; - })); -} -``` - -### 4.2 嵌套订阅 - -**❌ 反模式**:在 `flatMap` 中使用 `subscribe` - -```java -// 错误示例 -public Mono getMember(Long id) { - return memberRepository.findById(id) - .flatMap(member -> { - memberCardRepository.findByMemberId(member.getId()) - .collectList() - .subscribe(cards -> { - // 错误:在 flatMap 中使用 subscribe - member.setCards(cards); - }); - return Mono.just(member); - }); -} -``` - -**✅ 正确做法**:使用 `map` 转换数据 - -```java -// 正确示例 -public Mono getMember(Long id) { - return memberRepository.findById(id) - .zipWith(memberCardRepository.findByMemberId(id).collectList()) - .map(tuple -> { - Member member = tuple.getT1(); - List cards = tuple.getT2(); - member.setCards(cards); - return member; - }); -} -``` - -### 4.3 忽略错误 - -**❌ 反模式**:忽略错误,不处理 - -```java -// 错误示例 -public Mono getMember(Long id) { - return memberRepository.findById(id) - .onErrorResume(e -> Mono.empty()); // 错误:忽略错误 -} -``` - -**✅ 正确做法**:记录错误并处理 - -```java -// 正确示例 -public Mono getMember(Long id) { - return memberRepository.findById(id) - .onErrorResume(e -> { - log.error("查询会员失败: memberId={}", id, e); - return Mono.error(new SystemException("系统错误")); - }); -} -``` - -### 4.4 不处理背压 - -**❌ 反模式**:不处理背压,可能导致内存溢出 - -```java -// 错误示例 -public Flux listAllMembers() { - return memberRepository.findAll(); // 错误:可能返回大量数据 -} -``` - -**✅ 正确做法**:使用 `take` 限制数据量 - -```java -// 正确示例 -public Flux listAllMembers() { - return memberRepository.findAll() - .take(1000); // 限制最多返回 1000 条 -} -``` - -### 4.5 资源泄漏 - -**❌ 反模式**:不释放资源 - -```java -// 错误示例 -public Mono readFile(String path) { - return Mono.fromCallable(() -> { - // 错误:不释放资源 - BufferedReader reader = Files.newBufferedReader(Paths.get(path)); - return reader.readLine(); - }); -} -``` - -**✅ 正确做法**:使用 `using` 确保资源释放 - -```java -// 正确示例 -public Mono readFile(String path) { - return Mono.using( - () -> Files.newBufferedReader(Paths.get(path)), - reader -> Mono.fromCallable(reader::readLine), - reader -> { - try { - reader.close(); - } catch (IOException e) { - log.error("关闭文件失败", e); - } - }); -} -``` - ---- - -## 五、最佳实践 - -### 5.1 日志记录 - -**原则**:使用 `doOnSubscribe`、`doOnNext`、`doOnError`、`doOnTerminate` 记录关键操作。 - -```java -public Mono getMember(Long id) { - return memberRepository.findById(id) - .doOnSubscribe(s -> log.debug("开始查询会员: memberId={}", id)) - .doOnNext(member -> log.debug("查询到会员: memberId={}, name={}", member.getId(), member.getName())) - .doOnComplete(() -> log.debug("查询会员完成: memberId={}", id)) - .doOnError(e -> log.error("查询会员失败: memberId={}", id, e)) - .doOnTerminate(() -> log.debug("查询会员结束: memberId={}", id)); -} -``` - -### 5.2 超时控制 - -**原则**:为所有外部调用设置超时时间。 - -```java -public Mono getMember(Long id) { - return memberRepository.findById(id) - .timeout(Duration.ofSeconds(3)) // 3 秒超时 - .switchIfEmpty(Mono.error(new BusinessException("会员不存在"))); -} -``` - -### 5.3 重试机制 - -**原则**:为可重试的操作设置重试机制。 - -```java -public Mono getMember(Long id) { - return memberRepository.findById(id) - .retryWhen(Retry.backoff(3, Duration.ofSeconds(1)) // 重试 3 次,间隔 1 秒 - .filter(throwable -> throwable instanceof TimeoutException) - .doBeforeRetry(signal -> log.warn("重试: attempt={}", signal.totalRetries()))); -} -``` - -### 5.4 缓存策略 - -**原则**:使用 Cache-Aside 模式,先查缓存,缓存未命中再查数据库。 - -```java -public Mono getMember(Long id) { - String cacheKey = "member:" + id; - - return redisTemplate.opsForValue() - .get(cacheKey) - .cast(Member.class) - .switchIfEmpty( - memberRepository.findById(id) - .flatMap(member -> redisTemplate.opsForValue() - .set(cacheKey, member, Duration.ofMinutes(30)) - .thenReturn(member)) - ); -} -``` - -### 5.5 性能优化 - -**原则**:使用 `parallel()` 并行处理 CPU 密集型操作。 - -```java -public Flux processMembers(Flux members) { - return members.publishOn(Schedulers.parallel()) - .map(this::calculateLevel) - .publishOn(Schedulers.boundedElastic()); -} -``` - ---- - -## 六、测试规范 - -### 6.1 单元测试 - -**原则**:使用 `StepVerifier` 测试响应式流。 - -```java -@SpringBootTest -class MemberServiceTest { - - @Autowired - private MemberService memberService; - - @MockBean - private MemberRepository memberRepository; - - @Test - void testGetMember() { - Member member = Member.builder() - .id(1L) - .name("张三") - .phone("13800138000") - .build(); - - when(memberRepository.findById(1L)) - .thenReturn(Mono.just(member)); - - StepVerifier.create(memberService.getMember(1L)) - .expectNextMatches(m -> m.getName().equals("张三")) - .verifyComplete(); - } - - @Test - void testGetMemberNotFound() { - when(memberRepository.findById(1L)) - .thenReturn(Mono.empty()); - - StepVerifier.create(memberService.getMember(1L)) - .expectErrorMatches(e -> e instanceof BusinessException) - .verify(); - } -} -``` - -### 6.2 集成测试 - -**原则**:使用 `WebTestClient` 测试 Controller。 - -```java -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@AutoConfigureWebTestClient -class MemberControllerTest { - - @Autowired - private WebTestClient webTestClient; - - @Test - void testGetMember() { - webTestClient.get() - .uri("/api/v1/members/1") - .exchange() - .expectStatus().isOk() - .expectBody(Member.class) - .value(member -> { - assertThat(member.getName()).isEqualTo("张三"); - }); - } - - @Test - void testGetMemberNotFound() { - webTestClient.get() - .uri("/api/v1/members/999") - .exchange() - .expectStatus().isNotFound(); - } -} -``` - -### 6.3 性能测试 - -**原则**:使用 `StepVerifier.withVirtualTime` 测试性能。 - -```java -@Test -void testGetMemberPerformance() { - StepVerifier.withVirtualTime(() -> memberService.getMember(1L)) - .expectNextCount(1) - .expectComplete() - .verify(Duration.ofMillis(100)); // 100ms 内完成 -} -``` - ---- - -## 七、总结 - -### 7.1 核心原则回顾 - -1. ✅ **永不阻塞**:禁止在响应式流中使用阻塞操作 -2. ✅ **链式调用**:使用操作符链式调用,避免嵌套 -3. ✅ **错误处理**:使用响应式错误处理机制,避免 try-catch -4. ✅ **背压处理**:正确处理背压,避免内存溢出 -5. ✅ **资源释放**:确保所有资源正确释放,避免资源泄漏 - -### 7.2 关键成功因素 - -1. ✅ 严格遵守响应式编程规范 -2. ✅ 使用 StepVerifier 进行测试 -3. ✅ 完善的日志记录 -4. ✅ 合理的超时和重试机制 -5. ✅ 正确的缓存策略 - -### 7.3 持续改进 - -1. ✅ 定期代码审查 -2. ✅ 性能监控和优化 -3. ✅ 技术分享和培训 -4. ✅ 文档更新和维护 diff --git a/docs/design/business/B-HLD-付费订阅版-业务概要设计.md b/docs/design/business/B-HLD-付费订阅版-业务概要设计.md new file mode 100644 index 0000000..c87ad1f --- /dev/null +++ b/docs/design/business/B-HLD-付费订阅版-业务概要设计.md @@ -0,0 +1,803 @@ +# 健身房管理系统付费订阅版业务概要设计文档(B-HLD) + +> 文档编号: GYM-B-HLD-SUBSCRIPTION-001 +> 版本: v1.0 +> 日期: 2026-03-08 +> 作者: 张翔 +> 状态: 已发布 + +--- + +## 文档修订历史 + +| 版本 | 日期 | 作者 | 修订内容 | +| ---- | ---------- | ---- | -------------------------- | +| v1.0 | 2026-03-08 | 张翔 | 创建付费订阅版业务概要设计文档 | + +--- + +## 一、引言 + +### 1.1 编写目的 + +本文档为健身房管理系统付费订阅版的业务概要设计文档(Business High-Level Design),旨在: + +1. 从业务层面描述付费订阅版的业务范围、核心业务流程、业务规则 +2. 为业务详细设计提供业务指导和约束 +3. 作为产品经理、业务分析师的业务参考 + +### 1.2 项目背景 + +健身房管理系统付费订阅版在基础版基础上,提供丰富的增值功能,满足中大型健身房、连锁品牌等复杂场景需求。 + +### 1.3 术语定义 + +| 术语 | 定义 | +| ----------------------------------- | ------------------------------------------------ | +| 租户(Tenant) | 系统的多租户架构中的独立业务实体,如一个连锁品牌 | +| 门店(Store) | 租户下的具体经营场所 | +| 会员(Member) | 在门店注册的用户 | +| 权益(Benefit) | 会员卡包含的时长、次数、储值、等级等权益 | +| 可预约资源(Bookable Resource) | 团课、私教、场地、线上课程等可被预约的对象 | +| 时段(Slot) | 资源的可预约时间窗口 | +| 订阅模块(Subscription Module) | 按需订阅的增值功能模块 | +| 配置继承(Configuration Inheritance) | 门店配置继承租户配置的机制 | + +### 1.4 参考文档 + +- 《健身房管理系统付费订阅版产品设计文档》 GYM-PRD-SUBSCRIPTION-001 + +--- + +## 二、业务概述 + +### 2.1 业务目标 + +| 目标维度 | 目标描述 | 成功指标 | +| -------- | ---------------------- | -------------------------------- | +| 用户体验 | 提升会员预约和签到体验 | 预约成功率 ≥ 95%,签到耗时 ≤ 3秒 | +| 运营效率 | 降低人工操作成本 | 人工处理时间减少 50% | +| 数据价值 | 提供数据驱动决策支持 | 数据报表使用率 ≥ 80% | +| 业务增长 | 提升会员留存和增长 | 会员留存率提升 20% | + +### 2.2 用户角色 + +| 角色 | 描述 | 主要功能 | +| ---------- | -------------- | ------------------------------------------ | +| 会员 | 健身房注册用户 | 预约课程、签到、查看个人信息、参与社区 | +| 教练 | 健身房教练 | 排课、私教预约确认、学员签到、发布线上课程 | +| 前台 | 门店前台人员 | 会员接待、签到辅助、会员管理 | +| 店长 | 门店管理者 | 单店全功能管理、数据查看、营销活动管理 | +| 运营管理员 | 平台运营人员 | 营销活动配置、数据分析、AI运营建议查看 | +| 财务专员 | 财务人员 | 账单管理、财务报表 | +| 超级管理员 | 平台最高权限 | 全平台管理、系统配置 | + +### 2.3 业务范围 + +```mermaid +graph LR + subgraph 付费订阅版业务范围 + A[基础功能
包含基础版所有功能
• 会员管理
• 预约管理
• 签到管理
• 数据统计
• 系统管理] + B[订阅与配置管理
• 订阅管理
• 配置管理
• 套餐管理
• 计费管理] + C[业务扩展类模块
• 私教管理
• 器械预约
• 线上课程] + D[体验升级类模块
• 人脸识别签到
• NFC签到
• 智能储物柜] + E[营销增长类模块
• 营销活动
• 会员推荐奖励
• 会员互动社区
• 智能获客工具] + F[数据智能类模块
• 营销精算模型
• 自定义促销预测
• 高级数据分析
• 智能报表
• AI运营建议
• 智能体测数据联动] + end +``` + +--- + +## 三、核心业务流程 + +### 3.1 订阅流程 + +#### 3.1.1 业务场景 + +租户管理员通过管理后台订阅增值模块。 + +#### 3.1.2 业务流程 + +```mermaid +flowchart LR + A[租户管理员登录] --> B[查看订阅套餐] + B --> C[选择订阅模块] + C --> D[确认订阅] + D --> E[模块立即启用] +``` + +#### 3.1.3 业务规则 + +- 订阅成功后模块立即启用 +- 年付享受最大折扣 +- 支持多种支付方式 +- 订阅成功后发送通知 + +#### 3.1.4 异常处理 + +| 异常场景 | 处理方式 | +| -------- | -------------------- | +| 支付失败 | 提示用户重新支付 | +| 支付超时 | 提示用户重新发起支付 | + +--- + +### 3.2 配置继承流程 + +#### 3.2.1 业务场景 + +门店管理员配置门店级参数,可以选择继承租户配置。 + +#### 3.2.2 业务流程 + +```mermaid +flowchart LR + A[门店管理员登录] --> B[查看租户级配置] + B --> C[选择继承模式] + C --> D[配置门店级参数] + D --> E[配置立即生效] +``` + +#### 3.2.3 业务规则 + +- 查询优先级:门店配置 → 租户配置 → 默认配置 +- 支持三种继承模式(继承/继承+覆盖/自定义) +- 配置变更后立即生效 +- 配置变更记录版本,支持回滚 + +#### 3.2.4 异常处理 + +| 异常场景 | 处理方式 | +| -------- | ---------------------- | +| 配置冲突 | 提示用户选择覆盖或合并 | +| 配置无效 | 提示用户重新配置 | + +--- + +### 3.3 私教预约流程 + +#### 3.3.1 业务场景 + +会员通过小程序预约私教课程。 + +#### 3.3.2 业务流程 + +```mermaid +flowchart LR + A[会员打开小程序] --> B[查看私教课程列表] + B --> C[选择私教课程] + C --> D[确认预约] + D --> E[预约成功] +``` + +#### 3.3.3 业务规则 + +- 私教预约需提前至少24小时 +- 私教取消需提前至少12小时 +- 私教签到后记录考勤 + +#### 3.3.4 异常处理 + +| 异常场景 | 处理方式 | +| -------------- | -------------------- | +| 教练时间冲突 | 提示用户选择其他时间 | +| 会员卡权益不足 | 提示用户购买会员卡 | + +--- + +### 3.4 营销活动创建流程 + +#### 3.4.1 业务场景 + +运营管理员通过管理后台创建营销活动。 + +#### 3.4.2 业务流程 + +```mermaid +flowchart LR + A[运营管理员登录] --> B[创建营销活动] + B --> C[配置活动规则] + C --> D[发布活动] + D --> E[活动生效] +``` + +#### 3.4.3 业务规则 + +- 营销活动需指定时间、规则、奖励 +- 营销活动发布后不可修改规则 +- 营销活动统计按活动、时间维度 + +#### 3.4.4 异常处理 + +| 异常场景 | 处理方式 | +| ------------ | -------------------- | +| 活动时间冲突 | 提示用户调整活动时间 | +| 活动规则无效 | 提示用户重新配置 | + +--- + +### 3.5 营销分析与预测流程 + +#### 3.5.1 业务场景 + +运营管理员使用营销精算模型预测促销策略。 + +#### 3.5.2 业务流程 + +```mermaid +flowchart LR + A[运营管理员登录] --> B[选择营销精算模型] + B --> C[配置促销参数] + C --> D[预测效果] + D --> E[查看预测结果] +``` + +#### 3.5.3 业务规则 + +- 营销精算模型基于历史数据 +- 促销策略预测提供多种方案 +- 促销活动效果预测基于历史数据 + +#### 3.5.4 异常处理 + +| 异常场景 | 处理方式 | +| ------------ | -------------------- | +| 历史数据不足 | 提示用户积累更多数据 | +| 预测失败 | 提示用户调整参数 | + +--- + +### 3.6 智能获客流程 + +#### 3.6.1 业务场景 + +运营管理员使用智能获客工具进行节后健身潮获客、私域流量获客、推荐裂变获客。 + +#### 3.6.2 业务流程 + +**节后健身潮获客**: + +```mermaid +flowchart LR + A[运营管理员登录] --> B[创建获客活动] + B --> C[配置活动参数] + C --> D[生成海报和文案] + D --> E[分发渠道并追踪] +``` + +**私域流量获客**: + +```mermaid +flowchart LR + A[运营管理员登录] --> B[管理私域流量池] + B --> C[精准推送消息] + C --> D[自动化运营] + D --> E[分析转化效果] +``` + +**推荐裂变获客**: + +```mermaid +flowchart LR + A[会员打开小程序] --> B[生成推荐码] + B --> C[分享推荐链接] + C --> D[追踪推荐关系链] + D --> E[自动发放奖励] +``` + +#### 3.6.3 业务规则 + +- 节后健身潮获客年度流量窗口期自动激活(1月1日-3月31日) +- 私域流量获客基于用户标签精准推送 +- 推荐裂变获客支持多级推荐 +- 每个渠道的获客效果可追踪 +- 推荐奖励自动发放 + +#### 3.6.4 异常处理 + +| 异常场景 | 处理方式 | +| ------------ | ---------------- | +| 海报生成失败 | 提示用户重新生成 | +| 文案生成失败 | 提示用户手动编辑 | +| 推荐码失效 | 提示用户重新生成 | + +--- + +### 3.7 智能体测数据联动流程 + +#### 3.7.1 业务场景 + +会员进行体测后,体测设备自动上传数据到系统,系统进行数据转换、存储、分析,生成体测报告。 + +#### 3.7.2 业务流程 + +```mermaid +flowchart LR + A["会员进行体测"] --> B["设备自动上传数据"] + B --> C["系统数据转换"] + C --> D["数据存储到档案"] + D --> E["生成体测报告"] + + style A fill:#e1f5ff + style B fill:#fff4e1 + style C fill:#f0e1ff + style D fill:#e1ffe1 + style E fill:#ffe1e1 +``` + +#### 3.7.3 业务规则 + +- 支持主流体测设备(InBody、Tanita等) +- 提供标准API接口,支持任意体测设备对接 +- 数据自动上传和转换 +- 数据统一存储到会员健康档案 +- 支持体测数据查询和分析 +- 支持体测报告生成 + +#### 3.7.4 异常处理 + +| 异常场景 | 处理方式 | +| ------------ | ------------------------ | +| 设备连接失败 | 提示用户检查设备连接 | +| 数据上传失败 | 提示用户重新上传 | +| 数据转换失败 | 记录错误日志,通知管理员 | + +--- + +### 3.8 器械预约流程 + +#### 3.8.1 业务场景 + +会员通过小程序预约器械使用时段,避免等待,提升器械使用效率。 + +#### 3.8.2 业务流程 + +```mermaid +flowchart LR + A[会员打开小程序] --> B[查看器械列表] + B --> C[选择器械] + C --> D[查看可用时段] + D --> E[选择时段] + E --> F[确认预约] + F --> G{预约结果} + G -->|成功| H[预约成功] + G -->|失败| I[提示失败原因] + H --> J[接收预约提醒] + J --> K[到店使用器械] + K --> L[使用结束] + L --> M[释放器械] + + style A fill:#e1f5ff + style G fill:#fff4e1 + style K fill:#e1ffe1 +``` + +#### 3.8.3 业务规则 + +- **器械预约时间**:器械预约需提前至少30分钟 +- **器械取消时间**:器械取消需提前至少1小时 +- **器械预约时长**:每次预约时长不超过2小时 +- **器械预约冲突**:同一器械同一时段只能预约1人 +- **器械使用超时**:超时10分钟自动释放器械 +- **器械使用统计**:记录器械使用时长和次数 + +#### 3.8.4 异常处理 + +| 异常场景 | 处理方式 | +|---------|---------| +| 器械已被预约 | 提示用户选择其他时段 | +| 预约时间过短 | 提示用户提前预约 | +| 器械维护中 | 提示用户选择其他器械 | +| 预约冲突 | 提示用户选择其他时段 | + +--- + +### 3.9 人脸识别签到流程 + +#### 3.9.1 业务场景 + +会员通过人脸识别进行签到,提升签到体验,实现无感通行。 + +#### 3.9.2 业务流程 + +```mermaid +flowchart LR + A[会员到店] --> B[人脸识别设备] + B --> C{识别结果} + C -->|成功| D[验证会员卡] + D --> E{验证结果} + E -->|有效| F[签到成功] + E -->|无效| G[提示会员卡无效] + C -->|失败| H[降级为扫码签到] + F --> I[记录到店时间] + G --> H + H --> I + + style A fill:#e1f5ff + style C fill:#fff4e1 + style E fill:#fff4e1 + style H fill:#ffe1e1 +``` + +#### 3.9.3 业务规则 + +- **人脸信息采集**:人脸信息需会员授权 +- **人脸识别准确率**:人脸识别准确率 ≥ 95% +- **人脸识别失败**:人脸识别失败后降级为扫码签到 +- **人脸信息存储**:人脸信息加密存储 +- **人脸信息管理**:会员可以删除人脸信息 +- **人脸识别考勤**:人脸识别签到后记录考勤 + +#### 3.9.4 异常处理 + +| 异常场景 | 处理方式 | +|---------|---------| +| 人脸识别失败 | 降级为扫码签到 | +| 会员卡无效 | 提示用户购买会员卡 | +| 人脸信息不存在 | 提示用户采集人脸信息 | +| 设备连接失败 | 提示用户检查设备连接 | + +--- + +### 3.10 NFC签到流程 + +#### 3.10.1 业务场景 + +会员通过NFC手环/卡片进行签到,支持储物柜联动,提升签到体验。 + +#### 3.10.2 业务流程 + +```mermaid +flowchart LR + A[会员到店] --> B[刷NFC卡] + B --> C[读取NFC信息] + C --> D[验证会员卡] + D --> E{验证结果} + E -->|有效| F[签到成功] + E -->|无效| G[提示会员卡无效] + F --> H{是否需要储物柜} + H -->|是| I[自动开锁储物柜] + H -->|否| J[记录到店时间] + I --> J + G --> K[降级为扫码签到] + K --> J + + style A fill:#e1f5ff + style E fill:#fff4e1 + style H fill:#fff4e1 + style K fill:#ffe1e1 +``` + +#### 3.10.3 业务规则 + +- **NFC卡绑定**:NFC卡需绑定会员 +- **NFC签到验证**:NFC签到需验证会员卡有效性 +- **NFC签到失败**:NFC签到失败后降级为扫码签到 +- **NFC卡管理**:会员可以解绑NFC卡 +- **储物柜联动**:支持储物柜自动开锁 +- **NFC卡丢失**:NFC卡丢失后可解绑 + +#### 3.10.4 异常处理 + +| 异常场景 | 处理方式 | +|---------|---------| +| NFC卡未绑定 | 提示用户绑定NFC卡 | +| 会员卡无效 | 提示用户购买会员卡 | +| NFC卡失效 | 提示用户更换NFC卡 | +| 储物柜故障 | 提示用户使用其他储物柜 | + +--- + +### 3.11 在线课程流程 + +#### 3.11.1 业务场景 + +会员通过小程序预约和观看线上课程,拓展线上业务,提升会员活跃度。 + +#### 3.11.2 业务流程 + +```mermaid +flowchart LR + A[教练发布线上课程] --> B[填写课程信息] + B --> C[上传课程视频] + C --> D[发布课程] + D --> E[会员查看课程列表] + E --> F[选择课程] + F --> G[预约课程] + G --> H[接收预约提醒] + H --> I[观看课程] + I --> J[课程评价] + J --> K[课程统计] + + style A fill:#e1f5ff + style D fill:#fff4e1 + style G fill:#fff4e1 + style I fill:#e1ffe1 +``` + +#### 3.11.3 业务规则 + +- **线上课程发布**:线上课程需指定教练、时间、链接 +- **线上课程预约**:线上课程预约需提前至少30分钟 +- **线上课程观看**:线上课程观看需验证预约 +- **线上课程评价**:线上课程观看后可以评价 +- **线上课程统计**:线上课程统计按课程、时间维度 +- **视频点播**:支持视频点播功能 +- **直播课管理**:支持直播课管理 + +#### 3.11.4 异常处理 + +| 异常场景 | 处理方式 | +|---------|---------| +| 课程视频上传失败 | 提示教练重新上传 | +| 预约时间过短 | 提示用户提前预约 | +| 课程视频无法播放 | 提示用户检查网络连接 | +| 直播课中断 | 提示用户等待直播恢复 | + +--- + +## 四、用户角色和权限 + +### 4.1 角色定义 + +| 角色 | 描述 | 主要功能 | +| ---------- | -------------- | ------------------------------------------ | +| 会员 | 健身房注册用户 | 预约课程、签到、查看个人信息、参与社区 | +| 教练 | 健身房教练 | 排课、私教预约确认、学员签到、发布线上课程 | +| 前台 | 门店前台人员 | 会员接待、签到辅助、会员管理 | +| 店长 | 门店管理者 | 单店全功能管理、数据查看、营销活动管理 | +| 运营管理员 | 平台运营人员 | 营销活动配置、数据分析、AI运营建议查看 | +| 财务专员 | 财务人员 | 账单管理、财务报表 | +| 超级管理员 | 平台最高权限 | 全平台管理、系统配置 | + +### 4.2 权限矩阵 + +| 功能模块 | 会员 | 教练 | 前台 | 店长 | 运营管理员 | 财务专员 | 超级管理员 | +| ------------ | ---- | ---- | ---- | ---- | ---------- | --------- | ---------- | +| 会员信息查看 | 自己 | 所有 | 所有 | 所有 | 所有 | 所有 | 所有 | +| 会员信息编辑 | 自己 | 无 | 所有 | 所有 | 所有 | 无 | 所有 | +| 团课创建 | 无 | 是 | 否 | 是 | 否 | 否 | 是 | +| 团课编辑 | 无 | 自己 | 否 | 所有 | 否 | 否 | 所有 | +| 团课取消 | 无 | 自己 | 否 | 所有 | 否 | 否 | 所有 | +| 私教创建 | 无 | 是 | 否 | 是 | 否 | 否 | 是 | +| 私教编辑 | 无 | 自己 | 否 | 所有 | 否 | 否 | 所有 | +| 私教取消 | 无 | 自己 | 否 | 所有 | 否 | 否 | 所有 | +| 签到管理 | 无 | 是 | 是 | 是 | 否 | 否 | 是 | +| 营销活动创建 | 无 | 无 | 否 | 是 | 是 | 否 | 是 | +| 营销活动编辑 | 无 | 无 | 否 | 自己 | 所有 | 否 | 所有 | +| 营销活动取消 | 无 | 无 | 否 | 自己 | 所有 | 否 | 所有 | +| 数据统计查看 | 自己 | 自己 | 所有 | 所有 | 所有 | 所有 | 所有 | +| 财务报表查看 | 无 | 无 | 否 | 所有 | 所有 | 所有 | 所有 | +| 系统配置 | 无 | 无 | 无 | 无 | 否 | 否 | 是 | + +--- + +## 五、业务规则汇总 + +### 5.1 订阅管理规则 + +| 规则 | 描述 | +| -------- | ---------------------------- | +| 订阅生效 | 订阅成功后模块立即启用 | +| 计费周期 | 支持月付、季付、半年付、年付 | +| 试用政策 | 不同模块类型提供不同试用时长 | +| 组合套餐 | 支持组合套餐,享受更多优惠 | + +### 5.2 配置管理规则 + +| 规则 | 描述 | +| ---------- | ----------------------------------- | +| 配置继承 | 支持门店配置继承租户配置 | +| 继承模式 | 支持继承、继承+覆盖、自定义三种模式 | +| 配置优先级 | 门店配置 → 租户配置 → 默认配置 | +| 配置版本 | 配置变更记录版本,支持回滚 | + +### 5.3 私教管理规则 + +| 规则 | 描述 | +| ------------ | ------------------------ | +| 私教预约时间 | 私教预约需提前至少24小时 | +| 私教取消时间 | 私教取消需提前至少12小时 | +| 私教考勤 | 私教签到后记录考勤 | + +### 5.4 营销活动规则 + +| 规则 | 描述 | +| -------- | ------------------------------ | +| 活动规则 | 营销活动需指定时间、规则、奖励 | +| 活动修改 | 营销活动发布后不可修改规则 | +| 活动统计 | 营销活动统计按活动、时间维度 | + +### 5.5 营销分析与预测规则 + +| 规则 | 描述 | +| -------- | ---------------------------- | +| 模型基础 | 营销精算模型基于历史数据 | +| 预测方案 | 促销策略预测提供多种方案 | +| 效果预测 | 促销活动效果预测基于历史数据 | + +### 5.6 智能获客工具规则 + +| 规则 | 描述 | +| -------------- | ---------------------------------------- | +| 节后健身潮获客 | 年度流量窗口期自动激活(1月1日-3月31日) | +| 私域流量获客 | 基于用户标签精准推送 | +| 推荐裂变获客 | 支持多级推荐 | +| 获客效果追踪 | 每个渠道的获客效果可追踪 | +| 推荐奖励发放 | 推荐奖励自动发放 | + +### 5.7 智能体测数据联动规则 + +| 规则 | 描述 | +| -------- | ------------------------------------- | +| 设备对接 | 支持主流体测设备(InBody、Tanita等) | +| API接口 | 提供标准API接口,支持任意体测设备对接 | +| 数据上传 | 数据自动上传和转换 | +| 数据存储 | 数据统一存储到会员健康档案 | +| 数据查询 | 支持体测数据查询和分析 | +| 报告生成 | 支持体测报告生成 | + +### 5.8 器械预约规则 + +| 规则 | 描述 | +|------|------| +| 预约时间 | 器械预约需提前至少30分钟 | +| 取消时间 | 器械取消需提前至少1小时 | +| 预约时长 | 每次预约时长不超过2小时 | +| 预约冲突 | 同一器械同一时段只能预约1人 | +| 使用超时 | 超时10分钟自动释放器械 | +| 使用统计 | 记录器械使用时长和次数 | + +### 5.9 人脸识别签到规则 + +| 规则 | 描述 | +|------|------| +| 人脸信息采集 | 人脸信息需会员授权 | +| 人脸识别准确率 | 人脸识别准确率 ≥ 95% | +| 人脸识别失败 | 人脸识别失败后降级为扫码签到 | +| 人脸信息存储 | 人脸信息加密存储 | +| 人脸信息管理 | 会员可以删除人脸信息 | +| 人脸识别考勤 | 人脸识别签到后记录考勤 | + +### 5.10 NFC签到规则 + +| 规则 | 描述 | +|------|------| +| NFC卡绑定 | NFC卡需绑定会员 | +| NFC签到验证 | NFC签到需验证会员卡有效性 | +| NFC签到失败 | NFC签到失败后降级为扫码签到 | +| NFC卡管理 | 会员可以解绑NFC卡 | +| 储物柜联动 | 支持储物柜自动开锁 | +| NFC卡丢失 | NFC卡丢失后可解绑 | + +### 5.11 在线课程规则 + +| 规则 | 描述 | +|------|------| +| 线上课程发布 | 线上课程需指定教练、时间、链接 | +| 线上课程预约 | 线上课程预约需提前至少30分钟 | +| 线上课程观看 | 线上课程观看需验证预约 | +| 线上课程评价 | 线上课程观看后可以评价 | +| 线上课程统计 | 线上课程统计按课程、时间维度 | +| 视频点播 | 支持视频点播功能 | +| 直播课管理 | 支持直播课管理 | + +--- + +## 六、异常处理汇总 + +| 异常场景 | 处理方式 | +| ---------------- | ---------------------------- | +| 支付失败 | 提示用户重新支付 | +| 支付超时 | 提示用户重新发起支付 | +| 配置冲突 | 提示用户选择覆盖或合并 | +| 配置无效 | 提示用户重新配置 | +| 教练时间冲突 | 提示用户选择其他时间 | +| 会员卡权益不足 | 提示用户购买会员卡 | +| 活动时间冲突 | 提示用户调整活动时间 | +| 活动规则无效 | 提示用户重新配置 | +| 历史数据不足 | 提示用户积累更多数据 | +| 预测失败 | 提示用户调整参数 | +| 海报生成失败 | 提示用户重新生成 | +| 文案生成失败 | 提示用户手动编辑 | +| 推荐码失效 | 提示用户重新生成 | +| 设备连接失败 | 提示用户检查设备连接 | +| 数据上传失败 | 提示用户重新上传 | +| 数据转换失败 | 记录错误日志,通知管理员 | +| 器械已被预约 | 提示用户选择其他时段 | +| 预约时间过短 | 提示用户提前预约 | +| 器械维护中 | 提示用户选择其他器械 | +| 预约冲突 | 提示用户选择其他时段 | +| 人脸识别失败 | 降级为扫码签到 | +| 会员卡无效 | 提示用户购买会员卡 | +| 人脸信息不存在 | 提示用户采集人脸信息 | +| 设备连接失败 | 提示用户检查设备连接 | +| NFC卡未绑定 | 提示用户绑定NFC卡 | +| NFC卡失效 | 提示用户更换NFC卡 | +| 储物柜故障 | 提示用户使用其他储物柜 | +| 课程视频上传失败 | 提示教练重新上传 | +| 预约时间过短 | 提示用户提前预约 | +| 课程视频无法播放 | 提示用户检查网络连接 | +| 直播课中断 | 提示用户等待直播恢复 | + +--- + +## 七、附录 + +### 7.1 业务流程图索引 + +| 流程名称 | 图表位置 | +| ---------------- | ------------ | +| 订阅流程 | 3.1.2 | +| 配置继承流程 | 3.2.2 | +| 私教预约流程 | 3.3.2 | +| 营销活动创建流程 | 3.4.2 | +| 营销分析与预测流程 | 3.5.2 | +| 智能获客流程 | 3.6.2 | +| 智能体测数据联动流程 | 3.7.2 | +| 器械预约流程 | 3.8.2 | +| 人脸识别签到流程 | 3.9.2 | +| NFC签到流程 | 3.10.2 | +| 在线课程流程 | 3.11.2 | + +### 7.2 业务规则索引 + +| 规则分类 | 规则名称 | 图表位置 | +| ---------------- | ---------------- | ------------ | +| 订阅管理规则 | 订阅生效 | 5.1 | +| 订阅管理规则 | 计费周期 | 5.1 | +| 订阅管理规则 | 试用政策 | 5.1 | +| 订阅管理规则 | 组合套餐 | 5.1 | +| 配置管理规则 | 配置继承 | 5.2 | +| 配置管理规则 | 继承模式 | 5.2 | +| 配置管理规则 | 配置优先级 | 5.2 | +| 配置管理规则 | 配置版本 | 5.2 | +| 私教管理规则 | 私教预约时间 | 5.3 | +| 私教管理规则 | 私教取消时间 | 5.3 | +| 私教管理规则 | 私教考勤 | 5.3 | +| 营销活动规则 | 活动规则 | 5.4 | +| 营销活动规则 | 活动修改 | 5.4 | +| 营销活动规则 | 活动统计 | 5.4 | +| 营销分析与预测规则 | 模型基础 | 5.5 | +| 营销分析与预测规则 | 预测方案 | 5.5 | +| 营销分析与预测规则 | 效果预测 | 5.5 | +| 智能获客工具规则 | 节后健身潮获客 | 5.6 | +| 智能获客工具规则 | 私域流量获客 | 5.6 | +| 智能获客工具规则 | 推荐裂变获客 | 5.6 | +| 智能获客工具规则 | 获客效果追踪 | 5.6 | +| 智能获客工具规则 | 推荐奖励发放 | 5.6 | +| 智能体测数据联动规则 | 设备对接 | 5.7 | +| 智能体测数据联动规则 | API接口 | 5.7 | +| 智能体测数据联动规则 | 数据上传 | 5.7 | +| 智能体测数据联动规则 | 数据存储 | 5.7 | +| 智能体测数据联动规则 | 数据查询 | 5.7 | +| 智能体测数据联动规则 | 报告生成 | 5.7 | +| 器械预约规则 | 预约时间 | 5.8 | +| 器械预约规则 | 取消时间 | 5.8 | +| 器械预约规则 | 预约时长 | 5.8 | +| 器械预约规则 | 预约冲突 | 5.8 | +| 器械预约规则 | 使用超时 | 5.8 | +| 器械预约规则 | 使用统计 | 5.8 | +| 人脸识别签到规则 | 人脸信息采集 | 5.9 | +| 人脸识别签到规则 | 人脸识别准确率 | 5.9 | +| 人脸识别签到规则 | 人脸识别失败 | 5.9 | +| 人脸识别签到规则 | 人脸信息存储 | 5.9 | +| 人脸识别签到规则 | 人脸信息管理 | 5.9 | +| 人脸识别签到规则 | 人脸识别考勤 | 5.9 | +| NFC签到规则 | NFC卡绑定 | 5.10 | +| NFC签到规则 | NFC签到验证 | 5.10 | +| NFC签到规则 | NFC签到失败 | 5.10 | +| NFC签到规则 | NFC卡管理 | 5.10 | +| NFC签到规则 | 储物柜联动 | 5.10 | +| NFC签到规则 | NFC卡丢失 | 5.10 | +| 在线课程规则 | 线上课程发布 | 5.11 | +| 在线课程规则 | 线上课程预约 | 5.11 | +| 在线课程规则 | 线上课程观看 | 5.11 | +| 在线课程规则 | 线上课程评价 | 5.11 | +| 在线课程规则 | 线上课程统计 | 5.11 | +| 在线课程规则 | 视频点播 | 5.11 | +| 在线课程规则 | 直播课管理 | 5.11 | + +--- + +**文档结束** diff --git a/docs/design/business/B-HLD-基础版-业务概要设计.md b/docs/design/business/B-HLD-基础版-业务概要设计.md new file mode 100644 index 0000000..70bd619 --- /dev/null +++ b/docs/design/business/B-HLD-基础版-业务概要设计.md @@ -0,0 +1,477 @@ +# 健身房管理系统基础版业务概要设计文档(B-HLD) + +> 文档编号: GYM-B-HLD-BASIC-001 +> 版本: v1.0 +> 日期: 2026-03-08 +> 作者: 张翔 +> 状态: 已发布 + +--- + +## 文档修订历史 + +| 版本 | 日期 | 作者 | 修订内容 | +| ---- | ---------- | ---- | ---------------------- | +| v1.0 | 2026-03-08 | 张翔 | 创建基础版业务概要设计文档 | + +--- + +## 一、引言 + +### 1.1 编写目的 + +本文档为健身房管理系统基础版的业务概要设计文档(Business High-Level Design),旨在: + +1. 从业务层面描述基础版的业务范围、核心业务流程、业务规则 +2. 为业务详细设计提供业务指导和约束 +3. 作为产品经理、业务分析师的业务参考 + +### 1.2 项目背景 + +健身房管理系统基础版是面向小型工作室、个人教练等场景的核心版本,保证业务闭环,提供完整的会员管理、预约、签到等核心功能。 + +### 1.3 术语定义 + +| 术语 | 定义 | +| ----------------------------- | ------------------------------------------------ | +| 租户(Tenant) | 系统的多租户架构中的独立业务实体,如一个连锁品牌 | +| 门店(Store) | 租户下的具体经营场所 | +| 会员(Member) | 在门店注册的用户 | +| 权益(Benefit) | 会员卡包含的时长、次数、储值、等级等权益 | +| 可预约资源(Bookable Resource) | 团课等可被预约的对象 | +| 时段(Slot) | 资源的可预约时间窗口 | + +### 1.4 参考文档 + +- 《健身房管理系统基础版产品设计文档》 GYM-PRD-BASIC-001 + +--- + +## 二、业务概述 + +### 2.1 业务目标 + +| 目标维度 | 目标描述 | 成功指标 | +| -------- | ---------------------- | -------------------------------- | +| 用户体验 | 提升会员预约和签到体验 | 预约成功率 ≥ 95%,签到耗时 ≤ 3秒 | +| 运营效率 | 降低人工操作成本 | 人工处理时间减少 50% | +| 数据价值 | 提供基础数据支持 | 数据报表使用率 ≥ 80% | + +### 2.2 用户角色 + +| 角色 | 描述 | 主要功能 | +| ---------- | -------------- | ---------------------------- | +| 会员 | 健身房注册用户 | 预约课程、签到、查看个人信息 | +| 教练 | 健身房教练 | 排课、团课签到管理 | +| 前台 | 门店前台人员 | 会员接待、签到辅助、会员管理 | +| 店长 | 门店管理者 | 单店全功能管理、数据查看 | +| 超级管理员 | 平台最高权限 | 全平台管理、系统配置 | + +### 2.3 业务范围 + +```mermaid +flowchart LR + subgraph "基础版业务范围" + M1[会员管理
• 会员注册
• 会员卡管理
• 权益管理] + M2[预约管理
• 团课预约
• 团课管理] + M3[签到管理
• 扫码签到
• 签到记录管理] + M4[数据统计
• 基础数据统计] + M5[系统管理
• 用户管理
• 角色权限管理] + M6[UI模版定制
• 品牌定制
• 布局调整
• 预设模板
• 配置历史] + end +``` + +--- + +## 三、核心业务流程 + +### 3.1 会员注册流程 + +#### 3.1.1 业务场景 + +新用户通过小程序或前台进行注册,成为健身房会员。 + +#### 3.1.2 业务流程 + +```mermaid +flowchart LR + A[用户打开小程序] --> B[填写手机号] + B --> C[验证手机号] + C --> D[填写基本信息] + D --> E[注册成功] +``` + +#### 3.1.3 业务规则 + +- 手机号需验证唯一性 +- 手机号需通过短信验证码验证 +- 支持微信授权快速注册 +- 注册成功后自动创建会员档案 + +#### 3.1.4 异常处理 + +| 异常场景 | 处理方式 | +| ------------ | ---------------- | +| 手机号已存在 | 提示用户直接登录 | +| 验证码错误 | 提示用户重新输入 | +| 验证码过期 | 提示用户重新获取 | + +--- + +### 3.2 团课预约流程 + +#### 3.2.1 业务场景 + +会员通过小程序预约团课,教练通过管理后台创建团课。 + +#### 3.2.2 业务流程 + +**会员预约团课**: + +```mermaid +flowchart LR + A[会员打开小程序] --> B[查看团课列表] + B --> C[选择团课] + C --> D[确认预约] + D --> E[预约成功] +``` + +**教练创建团课**: + +```mermaid +flowchart LR + A[教练打开管理后台] --> B[点击创建团课] + B --> C[填写团课信息] + C --> D[发布团课] + D --> E[发布成功] +``` + +#### 3.2.3 业务规则 + +**预约时间规则** +- 预约需在课程开始前至少30分钟 + - ✅ 场景1:团课18:00开始,会员17:30可以预约 + - ✅ 场景2:团课18:00开始,会员17:31可以预约 + - ❌ 场景3:团课18:00开始,会员17:29无法预约 + - ❌ 场景4:团课18:00开始,会员18:00无法预约 + +**取消预约规则** +- 取消预约需在课程开始前至少2小时 + - ✅ 场景1:团课18:00开始,会员16:00可以取消预约 + - ✅ 场景2:团课18:00开始,会员15:59可以取消预约 + - ❌ 场景3:团课18:00开始,会员16:01无法取消预约 + - ❌ 场景4:团课18:00开始,会员17:00无法取消预约 + +**课程容量规则** +- 每节课最多20人 + - ✅ 场景1:团课当前预约19人,第20人可以预约 + - ✅ 场景2:团课当前预约18人,2人同时预约,都成功 + - ❌ 场景3:团课当前预约20人,第21人无法预约 + - ❌ 场景4:团课当前预约19人,2人同时预约,1人成功1人失败 + +**权益扣减规则** +- 预约成功后扣减权益 + - ✅ 场景1:会员有5次团课权益,预约1次后剩余4次 + - ✅ 场景2:会员有30天时长卡,预约后时长不变,仅记录预约信息 + - ✅ 场景3:会员有储值卡,预约团课费用100元,余额从500元变为400元 + - ❌ 场景4:会员权益为0时,无法预约团课 + +**团课创建规则** +- 团课需指定教练、时间、地点 + - ✅ 场景1:教练张三创建团课,指定时间为18:00-19:00,地点为A教室 + - ✅ 场景2:教练张三创建团课,指定时间为每周一18:00-19:00,地点为A教室 + - ❌ 场景3:创建团课未指定教练,系统提示"请选择教练" + - ❌ 场景4:创建团课未指定时间,系统提示"请选择时间" + +**团课取消规则** +- 团课取消需提前24小时通知 +- 团课取消后自动退款 + - ✅ 场景1:团课18:00开始,教练在前一天16:00取消,已预约会员自动退款 + - ✅ 场景2:团课18:00开始,教练在前一天18:00取消,已预约会员自动退款 + - ❌ 场景3:团课18:00开始,教练在前一天18:01取消,系统提示"取消时间过晚" + - ❌ 场景4:团课18:00开始,教练在当天17:00取消,系统提示"取消时间过晚" + +#### 3.2.4 异常处理 + +| 异常场景 | 处理方式 | +| -------------- | -------------------- | +| 课程已满 | 提示用户选择其他课程 | +| 会员卡权益不足 | 提示用户购买会员卡 | +| 预约时间过短 | 提示用户提前预约 | + +--- + +### 3.3 签到流程 + +#### 3.3.1 业务场景 + +会员到店后通过扫码进行签到,记录到店信息。 + +#### 3.3.2 业务流程 + +```mermaid +flowchart LR + A[会员到店] --> B[扫描签到码] + B --> C[验证会员卡] + C --> D[签到成功] + D --> E[记录到店时间] +``` + +#### 3.3.3 业务规则 + +**会员卡验证规则** +- 签到需验证会员卡有效性 + - ✅ 场景1:会员卡有效期至2026-12-31,今日签到成功 + - ✅ 场景2:会员卡有5次权益,签到后剩余4次 + - ✅ 场景3:会员卡为30天时长卡,签到后时长不变 + - ❌ 场景4:会员卡已过期(2026-01-01到期),签到失败提示"会员卡已过期" + - ❌ 场景5:会员卡权益为0,签到失败提示"会员卡权益不足" + +**预约验证规则** +- 签到需验证预约信息(如有) + - ✅ 场景1:会员预约了18:00的团课,18:00签到成功 + - ✅ 场景2:会员预约了18:00的团课,17:50签到成功 + - ✅ 场景3:会员未预约团课,签到成功记录为自由训练 + - ❌ 场景4:会员预约了18:00的团课,19:00签到失败提示"课程已结束" + - ❌ 场景5:会员预约了A教室的团课,在B教室签到失败提示"签到地点错误" + +**签到记录规则** +- 签到成功后记录到店时间 + - ✅ 场景1:会员18:00:00签到,记录到店时间为2026-03-08 18:00:00 + - ✅ 场景2:会员同一天多次签到,记录每次签到时间 + - ✅ 场景3:会员签到后离开,再次签到记录新的到店时间 + - ❌ 场景4:会员签到失败,不记录到店时间 + +#### 3.3.4 异常处理 + +| 异常场景 | 处理方式 | +| ---------- | ------------------ | +| 会员卡无效 | 提示用户购买会员卡 | +| 会员卡过期 | 提示用户续费 | +| 签到码无效 | 提示用户重新扫描 | + +--- + +### 3.4 会员卡购买流程 + +#### 3.4.1 业务场景 + +会员通过小程序购买会员卡,获得相应权益。 + +#### 3.4.2 业务流程 + +```mermaid +flowchart LR + A[会员打开小程序] --> B[查看会员卡列表] + B --> C[选择会员卡] + C --> D[确认购买] + D --> E[购买成功] +``` + +#### 3.4.3 业务规则 + +**会员卡类型规则** +- 支持时长卡、次卡、储值卡 + - ✅ 场景1:会员购买30天时长卡,有效期从购买日起30天 + - ✅ 场景2:会员购买10次次卡,获得10次团课预约权益 + - ✅ 场景3:会员购买1000元储值卡,余额为1000元 + - ✅ 场景4:会员购买组合卡(30天时长卡+5次次卡),同时获得时长和次数权益 + - ❌ 场景5:会员购买不存在的会员卡类型,系统提示"会员卡类型不存在" + +**到期提醒规则** +- 会员卡到期前7天提醒 + - ✅ 场景1:会员卡2026-03-15到期,系统在2026-03-08发送提醒 + - ✅ 场景2:会员卡2026-03-08到期,系统在2026-03-01发送提醒 + - ✅ 场景3:会员卡2026-03-08到期,系统每天发送提醒直到到期 + - ❌ 场景4:会员卡2026-03-08到期,系统在2026-03-09发送提醒(已过期) + +**续费生效规则** +- 会员卡续费后权益立即生效 + - ✅ 场景1:会员卡剩余5次,续费10次后剩余15次 + - ✅ 场景2:会员卡2026-03-08到期,续费30天后有效期延长至2026-04-07 + - ✅ 场景3:会员卡余额200元,续费500元后余额700元 + - ✅ 场景4:会员卡已过期,续费后立即恢复使用 + - ❌ 场景5:会员卡续费失败,原权益保持不变 + +**使用记录规则** +- 会员卡使用记录永久保存 + - ✅ 场景1:会员预约团课,记录预约时间、课程信息、权益扣减 + - ✅ 场景2:会员签到,记录签到时间、地点、权益扣减 + - ✅ 场景3:会员购买会员卡,记录购买时间、金额、权益获得 + - ✅ 场景4:会员卡过期,历史使用记录仍可查询 + - ✅ 场景5:会员注销账户,使用记录保留用于数据分析 + +#### 3.4.4 异常处理 + +| 异常场景 | 处理方式 | +| -------- | -------------------- | +| 支付失败 | 提示用户重新支付 | +| 支付超时 | 提示用户重新发起支付 | + +--- + +### 3.5 UI模版定制流程 + +#### 3.5.1 业务场景 + +租户通过管理后台的可视化配置器定制自己的UI,包括品牌元素、布局结构和预设模板。 + +#### 3.5.2 业务流程 + +```mermaid +flowchart LR + A[租户登录管理后台] --> B[打开UI定制器] + B --> C[品牌定制] + C --> D[布局调整] + D --> E[配置保存] +``` + +#### 3.5.3 业务规则 + +- 品牌元素应用范围包括小程序和管理后台 +- 布局调整支持拖拽排序和模块隐藏 +- 预设模板应用后保留品牌配置 +- 配置变更实时生效,无需重新部署 +- 配置变更自动记录到历史 + +#### 3.5.4 异常处理 + +| 异常场景 | 处理方式 | +| ------------ | -------------------- | +| Logo上传失败 | 提示用户重新上传 | +| 配置保存失败 | 提示用户检查配置格式 | +| 模板应用失败 | 提示用户调整品牌配置 | +| 配置回滚失败 | 提示用户选择其他版本 | + +--- + +## 四、用户角色和权限 + +### 4.1 角色定义 + +| 角色 | 描述 | 主要职责 | +| ---------- | -------------- | ---------------------------- | +| 会员 | 健身房注册用户 | 预约课程、签到、查看个人信息 | +| 教练 | 健身房教练 | 排课、团课签到管理 | +| 前台 | 门店前台人员 | 会员接待、签到辅助、会员管理 | +| 店长 | 门店管理者 | 单店全功能管理、数据查看 | +| 超级管理员 | 平台最高权限 | 全平台管理、系统配置 | + +### 4.2 权限矩阵 + +| 功能模块 | 会员 | 教练 | 前台 | 店长 | 超级管理员 | +| ------------ | ---- | ---- | ---- | ---- | ---------- | +| 会员信息查看 | 自己 | 所有 | 所有 | 所有 | 所有 | +| 会员信息编辑 | 自己 | 无 | 所有 | 所有 | 所有 | +| 团课创建 | 无 | 是 | 否 | 是 | 是 | +| 团课编辑 | 无 | 自己 | 否 | 所有 | 所有 | +| 团课取消 | 无 | 自己 | 否 | 所有 | 所有 | +| 签到管理 | 无 | 是 | 是 | 是 | 是 | +| 数据统计查看 | 自己 | 自己 | 所有 | 所有 | 所有 | +| 系统配置 | 无 | 无 | 无 | 无 | 是 | + +--- + +## 五、业务规则汇总 + +### 5.1 预约规则 + +| 规则名称 | 规则描述 | +| ---------------- | ---------------------------- | +| 预约时间限制 | 课程开始前至少30分钟 | +| 取消预约限制 | 课程开始前至少2小时 | +| 课程容量限制 | 每节课最多20人 | +| 权益扣减规则 | 预约成功后扣减权益 | +| 团课创建规则 | 需指定教练、时间、地点 | +| 团课取消规则 | 需提前24小时通知,自动退款 | + +### 5.2 签到规则 + +| 规则名称 | 规则描述 | +| ---------------- | ---------------------------- | +| 会员卡验证规则 | 签到需验证会员卡有效性 | +| 预约验证规则 | 签到需验证预约信息(如有) | +| 签到记录规则 | 签到成功后记录到店时间 | + +### 5.3 会员卡规则 + +| 规则名称 | 规则描述 | +| ---------------- | ---------------------------- | +| 会员卡类型规则 | 支持时长卡、次卡、储值卡 | +| 到期提醒规则 | 会员卡到期前7天提醒 | +| 续费生效规则 | 会员卡续费后权益立即生效 | +| 使用记录规则 | 会员卡使用记录永久保存 | + +### 5.4 UI定制规则 + +| 规则名称 | 规则描述 | +| ---------------- | ---------------------------- | +| 品牌元素应用 | 应用范围包括小程序和管理后台 | +| 布局调整规则 | 支持拖拽排序和模块隐藏 | +| 预设模板规则 | 应用后保留品牌配置 | +| 配置生效规则 | 配置变更实时生效 | +| 配置历史规则 | 配置变更自动记录到历史 | + +--- + +## 六、异常处理汇总 + +| 异常场景 | 处理方式 | +| ---------------- | ---------------------------- | +| 手机号已存在 | 提示用户直接登录 | +| 验证码错误 | 提示用户重新输入 | +| 验证码过期 | 提示用户重新获取 | +| 课程已满 | 提示用户选择其他课程 | +| 会员卡权益不足 | 提示用户购买会员卡 | +| 预约时间过短 | 提示用户提前预约 | +| 会员卡无效 | 提示用户购买会员卡 | +| 会员卡过期 | 提示用户续费 | +| 签到码无效 | 提示用户重新扫描 | +| 支付失败 | 提示用户重新支付 | +| 支付超时 | 提示用户重新发起支付 | +| Logo上传失败 | 提示用户重新上传 | +| 配置保存失败 | 提示用户检查配置格式 | +| 模板应用失败 | 提示用户调整品牌配置 | +| 配置回滚失败 | 提示用户选择其他版本 | + +--- + +## 七、附录 + +### 7.1 业务流程图索引 + +| 流程名称 | 图表位置 | +| ---------------- | ------------ | +| 会员注册流程 | 3.1.2 | +| 会员预约团课流程 | 3.2.2 | +| 教练创建团课流程 | 3.2.2 | +| 签到流程 | 3.3.2 | +| 会员卡购买流程 | 3.4.2 | +| UI模版定制流程 | 3.5.2 | + +### 7.2 业务规则索引 + +| 规则分类 | 规则名称 | 图表位置 | +| ---------------- | ---------------- | ------------ | +| 预约规则 | 预约时间限制 | 5.1 | +| 预约规则 | 取消预约限制 | 5.1 | +| 预约规则 | 课程容量限制 | 5.1 | +| 预约规则 | 权益扣减规则 | 5.1 | +| 预约规则 | 团课创建规则 | 5.1 | +| 预约规则 | 团课取消规则 | 5.1 | +| 签到规则 | 会员卡验证规则 | 5.2 | +| 签到规则 | 预约验证规则 | 5.2 | +| 签到规则 | 签到记录规则 | 5.2 | +| 会员卡规则 | 会员卡类型规则 | 5.3 | +| 会员卡规则 | 到期提醒规则 | 5.3 | +| 会员卡规则 | 续费生效规则 | 5.3 | +| 会员卡规则 | 使用记录规则 | 5.3 | +| UI定制规则 | 品牌元素应用 | 5.4 | +| UI定制规则 | 布局调整规则 | 5.4 | +| UI定制规则 | 预设模板规则 | 5.4 | +| UI定制规则 | 配置生效规则 | 5.4 | +| UI定制规则 | 配置历史规则 | 5.4 | + +--- + +**文档结束** diff --git a/docs/design/business/B-LLD-付费订阅版-业务详细设计.md b/docs/design/business/B-LLD-付费订阅版-业务详细设计.md new file mode 100644 index 0000000..243283e --- /dev/null +++ b/docs/design/business/B-LLD-付费订阅版-业务详细设计.md @@ -0,0 +1,414 @@ +# 健身房管理系统付费订阅版业务详细设计文档(B-LLD) + +> 文档编号: GYM-B-LLD-SUBSCRIPTION-001 +> 版本: v1.0 +> 日期: 2026-03-08 +> 作者: 张翔 +> 状态: 已发布 + +--- + +## 文档修订历史 + +| 版本 | 日期 | 作者 | 修订内容 | +| ---- | ---------- | ---- | -------------------------- | +| v1.0 | 2026-03-08 | 张翔 | 创建付费订阅版业务详细设计文档 | + +--- + +## 一、引言 + +### 1.1 编写目的 + +本文档为健身房管理系统付费订阅版的业务详细设计文档(Business Low-Level Design),旨在: + +1. 详细描述业务数据流转、业务指标 +2. 为技术实现提供详细的业务指导 +3. 作为业务分析师、开发人员的业务参考 + +### 1.2 项目背景 + +健身房管理系统付费订阅版在基础版基础上,提供丰富的增值功能,满足中大型健身房、连锁品牌等复杂场景需求。 + +### 1.3 术语定义 + +| 术语 | 定义 | +| ----------------------------------- | ------------------------------------------------ | +| 租户(Tenant) | 系统的多租户架构中的独立业务实体,如一个连锁品牌 | +| 门店(Store) | 租户下的具体经营场所 | +| 会员(Member) | 在门店注册的用户 | +| 权益(Benefit) | 会员卡包含的时长、次数、储值、等级等权益 | +| 可预约资源(Bookable Resource) | 团课、私教、场地、线上课程等可被预约的对象 | +| 时段(Slot) | 资源的可预约时间窗口 | +| 订阅模块(Subscription Module) | 按需订阅的增值功能模块 | +| 配置继承(Configuration Inheritance) | 门店配置继承租户配置的机制 | + +### 1.4 参考文档 + +- 《健身房管理系统付费订阅版产品设计文档》 GYM-PRD-SUBSCRIPTION-001 +- 《健身房管理系统付费订阅版业务概要设计文档》 GYM-B-HLD-SUBSCRIPTION-001 + +--- + +## 二、业务数据流转 + +### 2.1 订阅数据流转 + +```mermaid +flowchart LR + A[租户订阅模块] --> B[创建订阅记录] + B --> C[启用模块功能] + C --> D[模块使用统计] + D --> E[计费周期结算] + E --> F[发送账单] + F --> G[支付确认] + G --> H[续费提醒] + H --> A + + style A fill:#e1f5ff + style C fill:#fff4e1 + style E fill:#ffe1e1 + style G fill:#e1ffe1 +``` + +### 2.2 配置数据流转 + +```mermaid +flowchart LR + A[租户级配置] --> B[门店继承配置] + B --> C[门店级配置覆盖] + C --> D[配置生效] + D --> E[配置版本记录] + E --> F[配置变更回滚] + + style A fill:#e1f5ff + style C fill:#fff4e1 + style E fill:#ffe1e1 +``` + +### 2.3 私教预约数据流转 + +```mermaid +flowchart LR + A[会员预约私教] --> B[创建预约记录] + B --> C[扣减会员权益] + C --> D[发送预约提醒] + D --> E[私教签到] + E --> F[记录考勤] + F --> G[私教评价] + G --> H[数据统计] + + style A fill:#e1f5ff + style C fill:#fff4e1 + style E fill:#e1ffe1 + style H fill:#ffe1e1 +``` + +### 2.4 营销活动数据流转 + +```mermaid +flowchart LR + A[创建营销活动] --> B[配置活动规则] + B --> C[发布活动] + C --> D[会员参与活动] + D --> E[发放活动奖励] + E --> F[活动效果统计] + F --> G[活动数据分析] + G --> H[生成活动报告] + + style A fill:#e1f5ff + style C fill:#fff4e1 + style E fill:#e1ffe1 + style H fill:#ffe1e1 +``` + +### 2.5 智能获客数据流转 + +```mermaid +flowchart LR + A[创建获客活动] --> B[生成推广素材] + B --> C[分发到渠道] + C --> D[用户点击链接] + D --> E[记录推荐关系] + E --> F[用户注册] + F --> G[发放推荐奖励] + G --> H[获客效果统计] + H --> I[获客数据分析] + + style A fill:#e1f5ff + style C fill:#fff4e1 + style E fill:#e1ffe1 + style I fill:#ffe1e1 +``` + +### 2.6 智能体测数据流转 + +```mermaid +flowchart LR + A[会员进行体测] --> B[设备上传数据] + B --> C[系统数据转换] + C --> D[数据存储到档案] + D --> E[数据分析] + E --> F[生成体测报告] + F --> G[会员查看报告] + G --> H[历史数据对比] + + style A fill:#e1f5ff + style C fill:#fff4e1 + style E fill:#e1ffe1 + style H fill:#ffe1e1 +``` + +### 2.7 器械预约数据流转 + +```mermaid +flowchart LR + A[会员预约器械] --> B[创建预约记录] + B --> C[锁定器械时段] + C --> D[发送预约提醒] + D --> E[会员到店使用] + E --> F[记录使用时长] + F --> G[释放器械] + G --> H[器械使用统计] + + style A fill:#e1f5ff + style C fill:#fff4e1 + style E fill:#e1ffe1 + style H fill:#ffe1e1 +``` + +### 2.8 人脸识别签到数据流转 + +```mermaid +flowchart LR + A[会员到店] --> B[人脸识别] + B --> C{识别结果} + C -->|成功| D[验证会员卡] + D --> E{验证结果} + E -->|有效| F[签到成功] + E -->|无效| G[提示会员卡无效] + C -->|失败| H[降级为扫码签到] + F --> I[记录到店时间] + G --> H + H --> I + + style A fill:#e1f5ff + style C fill:#fff4e1 + style E fill:#fff4e1 + style H fill:#ffe1e1 +``` + +### 2.9 NFC签到数据流转 + +```mermaid +flowchart LR + A[会员到店] --> B[刷NFC卡] + B --> C[读取NFC信息] + C --> D[验证会员卡] + D --> E{验证结果} + E -->|有效| F[签到成功] + E -->|无效| G[提示会员卡无效] + F --> H{是否需要储物柜} + H -->|是| I[自动开锁储物柜] + H -->|否| J[记录到店时间] + I --> J + G --> K[降级为扫码签到] + K --> J + + style A fill:#e1f5ff + style E fill:#fff4e1 + style H fill:#fff4e1 + style K fill:#ffe1e1 +``` + +### 2.10 在线课程数据流转 + +```mermaid +flowchart LR + A[教练发布课程] --> B[上传课程视频] + B --> C[发布课程] + C --> D[会员预约课程] + D --> E[发送预约提醒] + E --> F[会员观看课程] + F --> G[记录观看时长] + G --> H[会员评价课程] + H --> I[课程数据统计] + + style A fill:#e1f5ff + style C fill:#fff4e1 + style E fill:#e1ffe1 + style I fill:#ffe1e1 +``` + +--- + +## 三、业务指标 + +### 3.1 核心业务指标 + +| 指标名称 | 目标值 | 计算方式 | +| ------------------ | ------------ | ---------------------------- | +| 预约成功率 | ≥ 95% | 成功预约次数 / 总预约次数 | +| 签到耗时 | ≤ 3秒 | 签到请求到签到完成的时间 | +| 人工处理时间减少 | 50% | (优化前时间 - 优化后时间) / 优化前时间 | +| 数据报表使用率 | ≥ 80% | 使用报表的用户数 / 总用户数 | +| 新会员激活率 | ≥ 70% | 7天内首次到店的新会员数 / 新会员总数 | +| 会员流失率 | ≤ 10% | 流失会员数 / 总会员数 | +| 投诉处理满意度 | ≥ 90% | 满意投诉数 / 总投诉数 | +| 会员留存率 | ≥ 80% | 留存会员数 / 总会员数 | + +### 3.2 运营指标 + +| 指标名称 | 目标值 | 计算方式 | +| ------------------ | ------------ | ---------------------------- | +| 团课满课率 | ≥ 80% | 满员课程数 / 总课程数 | +| 会员活跃度 | ≥ 60% | 活跃会员数 / 总会员数 | +| 会员续费率 | ≥ 70% | 续费会员数 / 到期会员数 | +| 会员卡使用率 | ≥ 85% | 使用会员卡的会员数 / 持卡会员数 | +| 私教预约成功率 | ≥ 90% | 成功预约私教次数 / 总预约次数 | +| 营销活动参与率 | ≥ 50% | 参与活动的会员数 / 总会员数 | +| 推荐转化率 | ≥ 20% | 推荐成功注册数 / 推荐链接点击数 | +| 获客成本 | ≤ 100元 | 获客总成本 / 新增会员数 | + +### 3.3 订阅指标 + +| 指标名称 | 目标值 | 计算方式 | +| ------------------ | ------------ | ---------------------------- | +| 订阅转化率 | ≥ 30% | 订阅租户数 / 总租户数 | +| 订阅续费率 | ≥ 80% | 续费订阅数 / 到期订阅数 | +| 模块使用率 | ≥ 70% | 使用模块的租户数 / 订阅该模块的租户数 | +| 订阅ARPU | ≥ 1000元 | 订阅总收入 / 订阅租户数 | + +### 3.4 技术指标 + +| 指标名称 | 目标值 | 计算方式 | +| ------------------ | ------------ | ---------------------------- | +| API响应时间 | ≤ 500ms | API请求到响应完成的时间 | +| 系统可用性 | ≥ 99.9% | 系统正常运行时间 / 总时间 | +| 并发用户数 | 500 | 系统支持的最大并发用户数 | +| 数据库查询时间 | ≤ 1s | 数据库查询的响应时间 | +| 人脸识别准确率 | ≥ 95% | 人脸识别成功次数 / 总识别次数 | + +--- + +## 四、业务规则补充 + +### 4.1 订阅计费规则 + +| 规则类型 | 规则描述 | +| ------------ | ---------------------------- | +| 基础版月费 | ¥299/月,标准价格 | +| 基础版季费 | ¥269/月,9折优惠 | +| 基础版半年费 | ¥254/月,85折优惠 | +| 基础版年费 | ¥239/月,8折优惠 | +| 订阅模块定价 | ¥199-499/月,按模块定价 | +| 试用时长 | 14天免费试用 | +| 组合折扣 | 订阅模块数量越多折扣越大,详见PRD动态折扣规则 | + +### 4.2 营销活动效果评估规则 + +| 规则类型 | 规则描述 | +| ------------ | ---------------------------- | +| 活动参与率 | 参与活动的会员数 / 目标会员数 | +| 活动转化率 | 完成活动的会员数 / 参与活动的会员数 | +| 活动ROI | 活动收益 / 活动成本 | +| 活动满意度 | 满意会员数 / 参与活动的会员数 | + +### 4.3 推荐奖励规则 + +| 规则类型 | 规则描述 | +| ------------ | ---------------------------- | +| 推荐奖励 | 推荐成功注册,推荐人获得100元优惠券 | +| 被推荐奖励 | 被推荐人注册成功,获得50元优惠券 | +| 多级推荐 | 支持3级推荐,每级奖励递减 | +| 奖励发放 | 推荐成功后24小时内自动发放 | + +### 4.4 体测数据管理规则 + +| 规则类型 | 规则描述 | +| ------------ | ---------------------------- | +| 数据保留期限 | 体测数据永久保存 | +| 数据对比 | 支持最近10次体测数据对比 | +| 报告生成 | 体测完成后10分钟内生成报告 | +| 数据分享 | 支持会员分享体测报告到社交平台 | + +### 4.5 器械使用规则 + +| 规则类型 | 规则描述 | +| ------------ | ---------------------------- | +| 预约超时 | 超时10分钟自动释放器械 | +| 使用统计 | 记录器械使用时长和次数 | +| 维护提醒 | 器械使用达到100小时后提醒维护 | +| 预约取消 | 取消预约后释放器械时段 | + +--- + +## 五、附录 + +### 5.1 业务术语表 + +| 术语 | 定义 | +| ----------------------------------- | ------------------------------------------------ | +| 租户(Tenant) | 系统的多租户架构中的独立业务实体,如一个连锁品牌 | +| 门店(Store) | 租户下的具体经营场所 | +| 会员(Member) | 在门店注册的用户 | +| 权益(Benefit) | 会员卡包含的时长、次数、储值、等级等权益 | +| 可预约资源(Bookable Resource) | 团课、私教、场地、线上课程等可被预约的对象 | +| 时段(Slot) | 资源的可预约时间窗口 | +| 订阅模块(Subscription Module) | 按需订阅的增值功能模块 | +| 配置继承(Configuration Inheritance) | 门店配置继承租户配置的机制 | + +### 5.2 参考文档 + +- 《健身房管理系统付费订阅版产品设计文档》 GYM-PRD-SUBSCRIPTION-001 +- 《健身房管理系统付费订阅版业务概要设计文档》 GYM-B-HLD-SUBSCRIPTION-001 +- 《健身房管理系统付费订阅版技术实现详细设计文档》 GYM-T-ILD-SUBSCRIPTION-001 + +### 5.3 业务数据流转图索引 + +| 流程名称 | 图表位置 | +| ---------------- | ------------ | +| 订阅数据流转 | 2.1 | +| 配置数据流转 | 2.2 | +| 私教预约数据流转 | 2.3 | +| 营销活动数据流转 | 2.4 | +| 智能获客数据流转 | 2.5 | +| 智能体测数据流转 | 2.6 | +| 器械预约数据流转 | 2.7 | +| 人脸识别签到数据流转 | 2.8 | +| NFC签到数据流转 | 2.9 | +| 在线课程数据流转 | 2.10 | + +### 5.4 业务指标索引 + +| 指标分类 | 指标名称 | 图表位置 | +| ---------------- | ---------------- | ------------ | +| 核心业务指标 | 预约成功率 | 3.1 | +| 核心业务指标 | 签到耗时 | 3.1 | +| 核心业务指标 | 人工处理时间减少 | 3.1 | +| 核心业务指标 | 数据报表使用率 | 3.1 | +| 核心业务指标 | 新会员激活率 | 3.1 | +| 核心业务指标 | 会员流失率 | 3.1 | +| 核心业务指标 | 投诉处理满意度 | 3.1 | +| 核心业务指标 | 会员留存率 | 3.1 | +| 运营指标 | 团课满课率 | 3.2 | +| 运营指标 | 会员活跃度 | 3.2 | +| 运营指标 | 会员续费率 | 3.2 | +| 运营指标 | 会员卡使用率 | 3.2 | +| 运营指标 | 私教预约成功率 | 3.2 | +| 运营指标 | 营销活动参与率 | 3.2 | +| 运营指标 | 推荐转化率 | 3.2 | +| 运营指标 | 获客成本 | 3.2 | +| 订阅指标 | 订阅转化率 | 3.3 | +| 订阅指标 | 订阅续费率 | 3.3 | +| 订阅指标 | 模块使用率 | 3.3 | +| 订阅指标 | 订阅ARPU | 3.3 | +| 技术指标 | API响应时间 | 3.4 | +| 技术指标 | 系统可用性 | 3.4 | +| 技术指标 | 并发用户数 | 3.4 | +| 技术指标 | 数据库查询时间 | 3.4 | +| 技术指标 | 人脸识别准确率 | 3.4 | + +--- + +**文档结束** diff --git a/docs/design/business/B-LLD-基础版-业务详细设计.md b/docs/design/business/B-LLD-基础版-业务详细设计.md new file mode 100644 index 0000000..3c0d3c2 --- /dev/null +++ b/docs/design/business/B-LLD-基础版-业务详细设计.md @@ -0,0 +1,654 @@ +# 健身房管理系统基础版业务详细设计文档(B-LLD) + +> 文档编号: GYM-B-LLD-BASIC-001 +> 版本: v1.0 +> 日期: 2026-03-08 +> 作者: 张翔 +> 状态: 已发布 + +--- + +## 文档修订历史 + +| 版本 | 日期 | 作者 | 修订内容 | +| ---- | ---------- | ---- | ---------------------- | +| v1.0 | 2026-03-08 | 张翔 | 创建基础版业务详细设计文档 | + +--- + +## 一、引言 + +### 1.1 编写目的 + +本文档为健身房管理系统基础版的业务详细设计文档(Business Low-Level Design),旨在: + +1. 详细描述业务流程、业务规则、异常处理 +2. 为技术实现提供详细的业务指导 +3. 作为业务分析师、开发人员的业务参考 + +### 1.2 项目背景 + +健身房管理系统基础版是面向小型工作室、个人教练等场景的核心版本,保证业务闭环,提供完整的会员管理、预约、签到等核心功能。 + +### 1.3 术语定义 + +| 术语 | 定义 | +| ----------------------------- | ------------------------------------------------ | +| 租户(Tenant) | 系统的多租户架构中的独立业务实体,如一个连锁品牌 | +| 门店(Store) | 租户下的具体经营场所 | +| 会员(Member) | 在门店注册的用户 | +| 权益(Benefit) | 会员卡包含的时长、次数、储值、等级等权益 | +| 可预约资源(Bookable Resource) | 团课等可被预约的对象 | +| 时段(Slot) | 资源的可预约时间窗口 | + +### 1.4 参考文档 + +- 《健身房管理系统基础版产品设计文档》 GYM-PRD-BASIC-001 +- 《健身房管理系统基础版业务概要设计文档》 GYM-B-HLD-BASIC-001 + +--- + +## 二、详细业务流程 + +### 2.1 会员全生命周期流程 + +#### 2.1.1 业务场景 + +从会员注册到流失的完整生命周期管理,包括新会员激活、活跃期维护、沉默期干预、流失预警和挽回。 + +#### 2.1.2 业务流程 + +```mermaid +flowchart LR + A[新会员注册] --> B[首次到店引导] + B --> C[新会员激活期
7天内完成首次到店] + C --> D[活跃期维护
持续到店和消费] + D --> E{活跃度评估} + E -->|活跃| F[持续运营
推送个性化内容] + E -->|沉默| G[沉默期干预
7天未到店触发] + G --> H{干预效果} + H -->|成功| D + H -->|失败| I[流失预警
30天未到店触发] + I --> J{挽回策略} + J -->|挽回成功| D + J -->|挽回失败| K[会员流失
标记为流失状态] + K --> L[归档分析
流失原因分析] + + style A fill:#e1f5ff + style C fill:#fff4e1 + style G fill:#ffe1e1 + style I fill:#ffe1e1 + style K fill:#ffcccc +``` + +#### 2.1.3 业务规则 + +**新会员激活期规则** +- 注册后7天内完成首次到店,否则进入沉默期干预 + - ✅ 场景1:会员2026-03-01注册,2026-03-07首次到店,激活成功 + - ✅ 场景2:会员2026-03-01注册,2026-03-08首次到店,激活成功 + - ❌ 场景3:会员2026-03-01注册,2026-03-09首次到店,已进入沉默期干预 + - ❌ 场景4:会员2026-03-01注册,2026-03-15首次到店,已进入流失预警 + +**活跃期定义规则** +- 30天内至少到店2次或消费1次 + - ✅ 场景1:会员30天内到店2次,保持活跃状态 + - ✅ 场景2:会员30天内到店1次但消费1次,保持活跃状态 + - ✅ 场景3:会员30天内到店3次,保持活跃状态 + - ❌ 场景4:会员30天内到店1次且未消费,进入沉默期 + - ❌ 场景5:会员30天内未到店但消费1次,保持活跃状态 + +**沉默期触发规则** +- 7天未到店触发沉默期干预 + - ✅ 场景1:会员最后到店2026-03-01,2026-03-08触发沉默期干预 + - ✅ 场景2:会员最后到店2026-03-01,2026-03-09仍处于沉默期 + - ✅ 场景3:会员沉默期干预成功,到店后重新计算活跃期 + - ❌ 场景4:会员最后到店2026-03-01,2026-03-07未触发沉默期干预 + +**沉默期干预策略** +- 发送个性化关怀短信 +- 提供专属优惠券 +- 推荐适合的团课 +- 教练主动联系 + - ✅ 场景1:会员沉默7天,发送关怀短信"好久不见,期待您的到来" + - ✅ 场景2:会员沉默7天,提供专属优惠券"限时9折优惠" + - ✅ 场景3:会员沉默7天,推荐适合的团课"瑜伽课程适合您" + - ✅ 场景4:会员沉默7天,教练主动电话联系 + - ❌ 场景5:会员沉默7天,未采取任何干预措施 + +**流失预警规则** +- 30天未到店触发流失预警 + - ✅ 场景1:会员最后到店2026-02-01,2026-03-03触发流失预警 + - ✅ 场景2:会员最后到店2026-02-01,2026-03-04启动挽回流程 + - ✅ 场景3:会员挽回成功,到店后重新计算活跃期 + - ❌ 场景4:会员最后到店2026-02-01,2026-03-02未触发流失预警 + +**流失定义规则** +- 90天未到店且未消费 + - ✅ 场景1:会员最后到店2026-01-01,2026-04-01标记为流失状态 + - ✅ 场景2:会员最后到店2026-01-01,2026-03-31仍处于流失预警期 + - ✅ 场景3:会员90天内未到店但消费1次,不标记为流失 + - ❌ 场景4:会员最后到店2026-01-01,2026-03-31标记为流失状态(错误) + +**挽回策略规则** +- 根据会员等级和历史行为制定个性化挽回方案 + - ✅ 场景1:VIP会员流失预警,提供专属私教课程优惠 + - ✅ 场景2:普通会员流失预警,发送关怀短信和优惠券 + - ✅ 场景3:高消费会员流失预警,客服主动电话联系 + - ❌ 场景4:流失预警会员未制定挽回方案,系统自动发送通用短信 + +**流失归档规则** +- 流失会员归档保存,用于流失原因分析 + - ✅ 场景1:会员标记为流失,归档保存所有历史数据 + - ✅ 场景2:会员流失后重新激活,归档数据仍保留用于分析 + - ✅ 场景3:定期分析流失会员数据,生成流失原因报告 + - ❌ 场景4:会员标记为流失,删除历史数据(错误) + +#### 2.1.4 异常处理 + +| 异常场景 | 处理方式 | +|---------|---------| +| 新会员激活失败 | 发送个性化邀请短信,提供首次到店优惠 | +| 沉默期干预无效 | 升级干预策略,提供专属优惠或服务 | +| 流失预警触发 | 启动挽回流程,由客服主动联系 | +| 会员数据异常 | 标记异常状态,暂停自动化运营,人工介入处理 | + +--- + +### 2.2 支付与退款全流程 + +#### 2.2.1 业务场景 + +会员购买会员卡、私教课程等服务的支付流程,以及退款申请、审批、退款、财务对账的完整流程。 + +#### 2.2.2 业务流程 + +```mermaid +flowchart TB + subgraph 支付流程 + A[会员发起支付] --> B[选择支付方式] + B --> C[创建支付订单] + C --> D[调用支付网关] + D --> E{支付结果} + E -->|成功| F[更新订单状态] + F --> G[发放会员卡权益] + G --> H[发送支付成功通知] + E -->|失败| I[记录支付失败] + I --> J[提示用户重新支付] + end + + subgraph 退款流程 + K[会员申请退款] --> L[填写退款原因] + L --> M[提交退款申请] + M --> N{退款类型} + N -->|自动退款| O[系统自动审核] + N -->|人工审核| P[店长审核] + P --> Q{审核结果} + Q -->|通过| R[财务专员复核] + Q -->|拒绝| S[通知会员拒绝原因] + O --> R + R --> T{复核结果} + T -->|通过| U[调用退款接口] + T -->|拒绝| S + U --> V[更新订单状态] + V --> W[收回会员卡权益] + W --> X[发送退款成功通知] + X --> Y[财务对账] + end + + style E fill:#fff4e1 + style Q fill:#fff4e1 + style T fill:#fff4e1 + style K fill:#e1f5ff +``` + +#### 2.2.3 业务规则 + +**支付方式规则** +- 支持微信支付、支付宝、银行卡支付 + - ✅ 场景1:会员选择微信支付,调用微信支付接口 + - ✅ 场景2:会员选择支付宝,调用支付宝接口 + - ✅ 场景3:会员选择银行卡,调用银行卡支付接口 + - ❌ 场景4:会员选择不支持的支付方式,提示"暂不支持该支付方式" + +**支付超时规则** +- 订单创建后30分钟内未支付自动取消 + - ✅ 场景1:订单18:00创建,18:30未支付,订单自动取消 + - ✅ 场景2:订单18:00创建,18:29支付,支付成功 + - ❌ 场景3:订单18:00创建,18:31支付,支付失败提示"订单已取消" + - ❌ 场景4:订单18:00创建,18:00支付,支付成功 + +**自动退款条件规则** +- 7天内购买且未使用的会员卡、私教课程 + - ✅ 场景1:会员购买会员卡后第1天申请退款,未使用,自动退款 + - ✅ 场景2:会员购买会员卡后第7天申请退款,未使用,自动退款 + - ❌ 场景3:会员购买会员卡后第8天申请退款,未使用,需人工审核 + - ❌ 场景4:会员购买会员卡后第1天申请退款,已使用,需人工审核 + +**人工审核条件规则** +- 超过7天、已使用部分权益、金额超过1000元 + - ✅ 场景1:会员购买会员卡后第8天申请退款,需人工审核 + - ✅ 场景2:会员购买会员卡后第1天申请退款,已使用,需人工审核 + - ✅ 场景3:会员购买1500元会员卡后第1天申请退款,需人工审核 + - ❌ 场景4:会员购买会员卡后第7天申请退款,未使用,金额500元,自动退款 + +**退款时效规则** +- 审核通过后1-3个工作日到账 + - ✅ 场景1:退款审核通过,第1个工作日到账 + - ✅ 场景2:退款审核通过,第3个工作日到账 + - ❌ 场景3:退款审核通过,第4个工作日到账(超时) + +**财务对账规则** +- 每日自动对账,异常订单人工处理 + - ✅ 场景1:系统每日凌晨自动对账,生成对账报告 + - ✅ 场景2:对账发现异常订单,标记异常,财务专员人工核查 + - ❌ 场景3:对账发现异常订单,未标记异常(错误) + +**退款手续费规则** +- 7天内无手续费,7-30天收取5%手续费,30天以上收取10%手续费 + - ✅ 场景1:会员购买会员卡后第1天申请退款,无手续费 + - ✅ 场景2:会员购买会员卡后第15天申请退款,收取5%手续费 + - ✅ 场景3:会员购买会员卡后第45天申请退款,收取10%手续费 + - ❌ 场景4:会员购买会员卡后第1天申请退款,收取5%手续费(错误) + +#### 2.2.4 异常处理 + +| 异常场景 | 处理方式 | +|---------|---------| +| 支付超时 | 订单自动取消,释放库存和权益 | +| 支付重复 | 检测重复支付,自动退款重复金额 | +| 退款失败 | 重试3次,失败后人工介入处理 | +| 财务对账异常 | 标记异常订单,财务专员人工核查 | +| 退款申请超时 | 退款申请提交后48小时内未处理自动升级 | + +--- + +### 2.3 投诉与反馈处理流程 + +#### 2.3.1 业务场景 + +会员提交投诉或反馈,系统自动分类、分配、处理、反馈,并进行满意度调查和归档分析。 + +#### 2.3.2 业务流程 + +```mermaid +flowchart LR + A[会员提交投诉/反馈] --> B[填写投诉详情] + B --> C[选择投诉类型] + C --> D[上传相关凭证] + D --> E[提交投诉] + E --> F[系统自动分类] + F --> G{投诉类型} + G -->|服务投诉| H[分配给店长] + G -->|设施投诉| I[分配给运营管理员] + G -->|财务投诉| J[分配给财务专员] + G -->|技术投诉| K[分配给技术支持] + H --> L[处理人接收] + I --> L + J --> L + K --> L + L --> M[调查处理] + M --> N{处理结果} + N -->|解决| O[反馈处理结果] + N -->|无法解决| P[升级处理] + P --> Q[上级介入处理] + Q --> O + O --> R[会员确认] + R --> S{满意度调查} + S -->|满意| T[归档分析] + S -->|不满意| U[重新处理] + U --> M + + style A fill:#e1f5ff + style F fill:#fff4e1 + style N fill:#fff4e1 + style S fill:#ffe1e1 +``` + +#### 2.3.3 业务规则 + +**投诉分类规则** +- 服务投诉、设施投诉、财务投诉、技术投诉、其他 + - ✅ 场景1:会员投诉教练服务态度,分类为服务投诉 + - ✅ 场景2:会员投诉器械损坏,分类为设施投诉 + - ✅ 场景3:会员投诉退款问题,分类为财务投诉 + - ✅ 场景4:会员投诉系统故障,分类为技术投诉 + - ✅ 场景5:会员投诉其他问题,分类为其他 + +**响应时效规则** +- 投诉提交后2小时内响应 + - ✅ 场景1:投诉14:00提交,16:00前响应 + - ✅ 场景2:投诉14:00提交,15:59响应 + - ❌ 场景3:投诉14:00提交,16:01响应(超时) + +**处理时效规则** +- 一般投诉24小时内处理完毕,复杂投诉48小时内处理完毕 + - ✅ 场景1:一般投诉14:00提交,次日14:00前处理完毕 + - ✅ 场景2:复杂投诉14:00提交,后日14:00前处理完毕 + - ❌ 场景3:一般投诉14:00提交,次日14:01处理完毕(超时) + +**升级机制规则** +- 处理人无法解决时自动升级给上级 + - ✅ 场景1:店长无法解决服务投诉,自动升级给运营管理员 + - ✅ 场景2:运营管理员无法解决设施投诉,自动升级给超级管理员 + - ❌ 场景3:处理人无法解决投诉,未升级(错误) + +**满意度调查规则** +- 投诉处理完成后自动发送满意度调查 + - ✅ 场景1:投诉处理完成,系统自动发送满意度调查问卷 + - ✅ 场景2:会员完成满意度调查,系统记录满意度评分 + - ❌ 场景3:投诉处理完成,未发送满意度调查(错误) + +**归档分析规则** +- 投诉归档后进行分类统计和原因分析 + - ✅ 场景1:投诉归档,系统自动分类统计 + - ✅ 场景2:定期分析投诉数据,生成投诉原因报告 + - ❌ 场景3:投诉归档,未进行分类统计(错误) + +**投诉闭环规则** +- 所有投诉必须闭环处理,不得遗漏 + - ✅ 场景1:投诉处理完成,会员确认,归档 + - ✅ 场景2:投诉处理完成,会员不满意,重新处理,会员确认,归档 + - ❌ 场景3:投诉处理完成,未会员确认,归档(错误) + +#### 2.3.4 异常处理 + +| 异常场景 | 处理方式 | +|---------|---------| +| 投诉信息不完整 | 提示会员补充必要信息 | +| 处理人未响应 | 2小时未响应自动升级给上级 | +| 处理超时 | 24小时未处理自动升级给店长 | +| 会员不满意 | 重新处理,升级处理级别 | +| 投诉重复提交 | 合并重复投诉,关联处理 | + +--- + +## 三、业务数据流转 + +### 3.1 会员数据流转 + +```mermaid +flowchart LR + A[会员注册] --> B[创建会员档案] + B --> C[购买会员卡] + C --> D[获得权益] + D --> E[预约团课] + E --> F[扣减权益] + F --> G[签到] + G --> H[记录到店] + H --> I[消费记录] + I --> J[数据统计] +``` + +### 3.2 权益数据流转 + +```mermaid +flowchart LR + A[购买会员卡] --> B[发放权益] + B --> C[预约扣减] + C --> D[签到扣减] + D --> E[权益使用记录] + E --> F[权益查询] + F --> G[权益续费] + G --> B +``` + +--- + +## 四、业务规则汇总 + +### 4.1 时间相关规则 + +| 规则类型 | 时间要求 | 说明 | +| -------------- | ------------------ | ------------------------ | +| 预约时间 | 课程开始前30分钟 | 会员预约团课的最短时间 | +| 取消预约 | 课程开始前2小时 | 会员取消预约的最短时间 | +| 团课取消 | 提前24小时 | 教练取消团课的最短时间 | +| 支付超时 | 30分钟 | 订单未支付自动取消时间 | +| 新会员激活期 | 7天 | 新会员首次到店时间要求 | +| 沉默期触发 | 7天未到店 | 触发沉默期干预的时间 | +| 流失预警 | 30天未到店 | 触发流失预警的时间 | +| 流失定义 | 90天未到店 | 会员流失的时间定义 | +| 投诉响应 | 2小时 | 投诉响应时间要求 | +| 投诉处理 | 24-48小时 | 投诉处理完成时间 | +| 退款时效 | 1-3个工作日 | 退款到账时间 | + +### 4.2 数量相关规则 + +| 规则类型 | 数量限制 | 说明 | +| ------------ | -------- | -------------- | +| 团课容量 | 20人 | 每节课最大人数 | +| 自动退款 | 7天内 | 自动退款条件 | +| 手续费7-30天 | 5% | 退款手续费 | +| 手续费30天以上 | 10% | 退款手续费 | + +### 4.3 状态相关规则 + +| 规则类型 | 状态定义 | 说明 | +| ------------ | -------- | -------------- | +| 活跃期 | 30天内到店2次或消费1次 | 会员活跃状态 | +| 沉默期 | 7天未到店 | 会员沉默状态 | +| 流失预警 | 30天未到店 | 流失预警状态 | +| 流失 | 90天未到店且未消费 | 会员流失状态 | + +--- + +## 五、业务异常处理 + +### 5.1 会员相关异常 + +| 异常类型 | 处理方式 | +| ------------ | ---------------------------- | +| 手机号已存在 | 提示用户直接登录 | +| 验证码错误 | 提示用户重新输入 | +| 验证码过期 | 提示用户重新获取 | +| 会员卡无效 | 提示用户购买会员卡 | +| 会员卡过期 | 提示用户续费 | +| 会员卡权益不足 | 提示用户购买会员卡或续费 | + +### 5.2 预约相关异常 + +| 异常类型 | 处理方式 | +| ------------ | ---------------------------- | +| 课程已满 | 提示用户选择其他课程 | +| 会员卡权益不足 | 提示用户购买会员卡 | +| 预约时间过短 | 提示用户提前预约 | +| 团课取消过晚 | 系统提示"取消时间过晚" | + +### 5.3 支付相关异常 + +| 异常类型 | 处理方式 | +| ------------ | ---------------------------- | +| 支付失败 | 提示用户重新支付 | +| 支付超时 | 订单自动取消,释放库存和权益 | +| 支付重复 | 检测重复支付,自动退款重复金额 | +| 退款失败 | 重试3次,失败后人工介入处理 | +| 财务对账异常 | 标记异常订单,财务专员人工核查 | + +### 5.4 投诉相关异常 + +| 异常类型 | 处理方式 | +| -------------- | ---------------------------- | +| 投诉信息不完整 | 提示会员补充必要信息 | +| 处理人未响应 | 2小时未响应自动升级给上级 | +| 处理超时 | 24小时未处理自动升级给店长 | +| 会员不满意 | 重新处理,升级处理级别 | +| 投诉重复提交 | 合并重复投诉,关联处理 | + +--- + +## 六、业务指标 + +### 6.1 核心业务指标 + +| 指标名称 | 目标值 | 计算方式 | +| ------------------ | ------------ | ---------------------------- | +| 预约成功率 | ≥ 95% | 成功预约次数 / 总预约次数 | +| 签到耗时 | ≤ 3秒 | 签到请求到签到完成的时间 | +| 人工处理时间减少 | 50% | (优化前时间 - 优化后时间) / 优化前时间 | +| 数据报表使用率 | ≥ 80% | 使用报表的用户数 / 总用户数 | +| 新会员激活率 | ≥ 70% | 7天内首次到店的新会员数 / 新会员总数 | +| 会员流失率 | ≤ 10% | 流失会员数 / 总会员数 | +| 投诉处理满意度 | ≥ 90% | 满意投诉数 / 总投诉数 | + +### 6.2 运营指标 + +| 指标名称 | 目标值 | 计算方式 | +| ------------------ | ------------ | ---------------------------- | +| 团课满课率 | ≥ 80% | 满员课程数 / 总课程数 | +| 会员活跃度 | ≥ 60% | 活跃会员数 / 总会员数 | +| 会员续费率 | ≥ 70% | 续费会员数 / 到期会员数 | +| 会员卡使用率 | ≥ 85% | 使用会员卡的会员数 / 持卡会员数 | + +--- + +## 七、附录 + +### 7.1 业务术语表 + +| 术语 | 定义 | +| ----------------------------- | ------------------------------------------------ | +| 租户(Tenant) | 系统的多租户架构中的独立业务实体,如一个连锁品牌 | +| 门店(Store) | 租户下的具体经营场所 | +| 会员(Member) | 在门店注册的用户 | +| 权益(Benefit) | 会员卡包含的时长、次数、储值、等级等权益 | +| 可预约资源(Bookable Resource) | 团课等可被预约的对象 | +| 时段(Slot) | 资源的可预约时间窗口 | + +### 7.2 参考文档 + +- 《健身房管理系统基础版产品设计文档》 GYM-PRD-BASIC-001 +- 《健身房管理系统基础版业务概要设计文档》 GYM-B-HLD-BASIC-001 +- 《健身房管理系统基础版技术实现详细设计文档》 GYM-T-ILD-BASIC-001 + +### 7.3 业务流程图索引 + +| 流程名称 | 图表位置 | +| ---------------- | ------------ | +| 会员全生命周期流程 | 2.1.2 | +| 支付与退款全流程 | 2.2.2 | +| 投诉与反馈处理流程 | 2.3.2 | +| 会员数据流转 | 3.1 | +| 权益数据流转 | 3.2 | + +### 7.4 业务规则索引 + +| 规则分类 | 规则名称 | 图表位置 | +| ---------------- | ---------------- | ------------ | +| 时间相关规则 | 预约时间 | 4.1 | +| 时间相关规则 | 取消预约 | 4.1 | +| 时间相关规则 | 团课取消 | 4.1 | +| 时间相关规则 | 支付超时 | 4.1 | +| 时间相关规则 | 新会员激活期 | 4.1 | +| 时间相关规则 | 沉默期触发 | 4.1 | +| 时间相关规则 | 流失预警 | 4.1 | +| 时间相关规则 | 流失定义 | 4.1 | +| 时间相关规则 | 投诉响应 | 4.1 | +| 时间相关规则 | 投诉处理 | 4.1 | +| 时间相关规则 | 退款时效 | 4.1 | +| 数量相关规则 | 团课容量 | 4.2 | +| 数量相关规则 | 自动退款 | 4.2 | +| 数量相关规则 | 手续费7-30天 | 4.2 | +| 数量相关规则 | 手续费30天以上 | 4.2 | +| 状态相关规则 | 活跃期 | 4.3 | +| 状态相关规则 | 沉默期 | 4.3 | +| 状态相关规则 | 流失预警 | 4.3 | +| 状态相关规则 | 流失 | 4.3 | + +--- + +**文档结束** + +--- + +## 二、详细业务流程(续) + +### 2.4 UI 模版定制模块 + +#### 2.4.1 业务场景 + +健身房管理者可以根据品牌特色自定义系统界面,包括品牌 Logo、主题色、布局风格等,提升品牌形象和用户体验。 + +#### 2.4.2 业务数据流转 + +```mermaid +flowchart TB + subgraph UI 模版定制流程 + A[管理员进入 UI 设置] --> B[选择定制类型] + B --> C{定制类型} + C -->|品牌定制 | D[上传品牌 Logo] + C -->|主题色定制 | E[选择主题色] + C -->|布局定制 | F[选择布局模板] + D --> G[预览效果] + E --> G + F --> G + G --> H{确认发布} + H -->|是 | I[保存到数据库] + H -->|否 | B + I --> J[通知所有用户] + J --> K[更新缓存] + K --> L[完成定制] + end + + style A fill:#e1f5ff + style G fill:#fff4e1 + style I fill:#e8f5e9 + style L fill:#e8f5e9 +``` + +#### 2.4.3 业务规则 + +**品牌定制规则** +- 支持上传 PNG、JPG 格式的 Logo 文件,最大 5MB + - ✅ 场景 1:管理员上传 PNG 格式 Logo(2MB),上传成功 + - ✅ 场景 2:管理员上传 JPG 格式 Logo(3MB),上传成功 + - ❌ 场景 3:管理员上传 GIF 格式 Logo(1MB),格式不支持 + - ❌ 场景 4:管理员上传 PNG 格式 Logo(6MB),文件大小超限 + +**主题色定制规则** +- 提供预设色板,支持自定义色值输入 + - ✅ 场景 1:管理员从预设色板选择蓝色主题,应用成功 + - ✅ 场景 2:管理员输入自定义色值#1890FF,应用成功 + - ❌ 场景 3:管理员输入无效色值#GGGGGG,提示格式错误 + - ❌ 场景 4:管理员选择与 Logo 颜色冲突的主题色,系统提示建议 + +**布局定制规则** +- 提供 3 种预设布局模板(经典、现代、简约) + - ✅ 场景 1:管理员选择经典布局,应用成功 + - ✅ 场景 2:管理员选择现代布局,应用成功 + - ✅ 场景 3:管理员选择简约布局,应用成功 + - ❌ 场景 4:管理员自定义布局超出预设范围,提示不支持 + +**预览规则** +- 支持实时预览,预览效果与实际效果一致 + - ✅ 场景 1:管理员修改主题色,实时预览更新 + - ✅ 场景 2:管理员切换布局模板,实时预览更新 + - ✅ 场景 3:管理员上传 Logo,实时预览更新 + - ❌ 场景 4:管理员修改后未预览直接发布,系统强制要求预览 + +**发布规则** +- 发布后即时生效,所有用户端同步更新 + - ✅ 场景 1:管理员发布新主题,会员小程序即时更新 + - ✅ 场景 2:管理员发布新主题,教练端 App 即时更新 + - ✅ 场景 3:管理员发布新主题,管理后台 PC 即时更新 + - ❌ 场景 4:管理员发布后部分用户未更新,系统自动清理缓存 + +#### 2.4.4 异常处理 + +| 异常场景 | 处理方式 | +|---------|---------| +| Logo 上传失败 | 提示文件大小或格式错误,建议重新上传 | +| 主题色不兼容 | 提示颜色冲突,推荐兼容色板 | +| 预览加载失败 | 重新加载预览,失败则提示网络问题 | +| 发布失败 | 回滚到上一个版本,提示发布失败原因 | +| 缓存更新失败 | 强制清理缓存,通知运维介入 | + +#### 2.4.5 业务指标 + +| 指标名称 | 目标值 | 计算方式 | +|---------|--------|---------| +| UI 定制使用率 | ≥ 60% | 使用定制的门店数 / 总门店数 | +| 定制满意度 | ≥ 85% | 满意评价数 / 总评价数 | +| 预览加载时间 | ≤ 2 秒 | 预览请求到渲染完成的时间 | +| 发布成功率 | ≥ 99% | 成功发布次数 / 总发布次数 | + diff --git a/docs/design/modules/LLD-会员模块详细设计.md b/docs/design/modules/LLD-会员模块详细设计.md deleted file mode 100644 index ede5db3..0000000 --- a/docs/design/modules/LLD-会员模块详细设计.md +++ /dev/null @@ -1,1815 +0,0 @@ -# 健身房管理系统详细设计文档 - 会员模块(LLD) - -> 文档编号: GYM-LLD-001 -> 版本: v1.0 -> 日期: 2026-02-28 -> 作者: 张翔 -> 状态: 初稿 -> **归属版本**: 基础版 - -**说明**:本文档为健身房管理系统**基础版**的会员模块详细设计文档,描述会员管理模块的数据库设计、API设计、业务逻辑实现等技术细节。 - ---- - -## 文档修订历史 - -| 版本 | 日期 | 作者 | 修订内容 | -| ---- | ---------- | ---- | -------- | -| v1.0 | 2026-02-28 | 张翔 | 初稿 | - ---- - -## 参考文档 - -- 《健身房管理系统产品设计文档》 GYM-PRD-001 -- 《健身房管理系统业务概要设计文档》 GYM-HLD-001 -- 《健身房管理系统详细设计文档》 GYM-LLD-000 -- Spring Boot 3 官方文档 -- R2DBC 规范文档 -- PostgreSQL 官方文档 - ---- - -## 一、模块概述 - -### 1.1 模块定位 - -会员模块是健身房管理系统的核心基础模块,负责管理会员全生命周期,包括: - -- 会员注册与信息管理 -- 会员卡购买与管理 -- 会员权益(时长/次数/储值/等级)管理 -- 会员等级体系与积分管理 - -### 1.2 模块边界 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 会员模块边界 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 会员模块内部 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 会员管理 • 会员卡管理 • 权益管理 • 等级管理 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 外部依赖 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 租户模块 (获取租户信息) │ │ -│ │ • 门店模块 (获取门店信息) │ │ -│ │ • 认证模块 (用户登录认证) │ │ -│ │ • 消息模块 (发送短信验证码) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 被依赖 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 预约模块 (查询会员权益、扣减权益) │ │ -│ │ • 签到模块 (查询会员信息、扣减权益) │ │ -│ │ • 财务模块 (查询会员消费记录) │ │ -│ │ • 数据模块 (会员数据分析) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 二、数据模型设计 - -### 2.1 实体关系图 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 实体关系图 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────┐ │ -│ │ tenant │ │ -│ │ (租户表) │ │ -│ └──────┬───────┘ │ -│ │ 1:N │ -│ ┌────┴────┐ │ -│ ▼ ▼ │ -│ ┌──────┐ ┌──────┐ │ -│ │store │ │member│ │ -│ │(门店)│ │(会员)│ │ -│ └──┬───┘ └──┬───┘ │ -│ │ 1:N │ 1:N │ -│ │ │ │ -│ │ └─────────────┐ │ -│ │ │ │ -│ │ ▼ │ -│ │ ┌──────────────────┐ │ -│ │ │ member_card │ │ -│ │ │ (会员卡) │ │ -│ │ └────────┬─────────┘ │ -│ │ │ N:1 │ -│ │ ▼ │ -│ │ ┌──────────────────┐ │ -│ │ │ card_type │ │ -│ │ │ (卡类型) │ │ -│ │ └────────┬─────────┘ │ -│ │ │ 1:N │ -│ │ ▼ │ -│ │ ┌──────────────────┐ │ -│ │ │ level_rule │ │ -│ │ │ (等级规则) │ │ -│ │ └──────────────────┘ │ -│ │ │ -│ │ ┌──────────────────┐ │ -│ └───────────────────────┤ member_benefit │ │ -│ │ (会员权益) │ │ -│ └──────────────────┘ │ -│ │ -│ 关系说明: │ -│ • tenant (1) ─── (N) store : 一个租户有多个门店 │ -│ • tenant (1) ─── (N) member : 一个租户有多个会员 │ -│ • store (1) ─── (N) member : 一个门店有多个会员 │ -│ • member (1) ─── (N) member_card : 一个会员有多张卡 │ -│ • member (1) ─── (N) member_benefit : 一个会员有多个权益 │ -│ • member_card (N) ─── (1) card_type : 卡属于一种类型 │ -│ • member_benefit (N) ─── (1) card_type : 权益属于一种卡类型 │ -│ • card_type (1) ─── (N) level_rule : 卡类型有多个等级规则 │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 2.2 数据表设计 - -#### 2.2.1 会员表 (member) - -```sql -CREATE TABLE member ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - store_id BIGINT NOT NULL, - member_no VARCHAR(32) NOT NULL, - name VARCHAR(64), - phone VARCHAR(64) NOT NULL, -- AES加密存储 - phone_mask VARCHAR(20), -- 脱敏手机号 - avatar VARCHAR(512), - gender SMALLINT DEFAULT 0, -- 0:未知 1:男 2:女 - birthday DATE, - id_card VARCHAR(128), -- AES加密存储 - emergency_contact VARCHAR(64), - emergency_phone VARCHAR(64), - level SMALLINT DEFAULT 0, -- 会员等级 - exp INT DEFAULT 0, -- 经验值 - total_exp INT DEFAULT 0, -- 累计经验值 - status SMALLINT DEFAULT 1, -- 1:正常 2:冻结 3:注销 - register_source VARCHAR(32), -- 注册来源 - last_login_at TIMESTAMP, - last_login_ip VARCHAR(64), - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - created_by BIGINT, - updated_by BIGINT, - deleted_at TIMESTAMP DEFAULT NULL, - - CONSTRAINT uk_member_no UNIQUE (tenant_id, member_no), - CONSTRAINT uk_member_phone UNIQUE (tenant_id, phone), - CONSTRAINT fk_member_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id), - CONSTRAINT fk_member_store FOREIGN KEY (store_id) REFERENCES store(id) -); - -CREATE INDEX idx_member_tenant ON member(tenant_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_member_store ON member(store_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_member_phone ON member(phone) WHERE deleted_at IS NULL; -CREATE INDEX idx_member_level ON member(level) WHERE deleted_at IS NULL; -CREATE INDEX idx_member_status ON member(status) WHERE deleted_at IS NULL; -``` - -#### 2.2.2 会员卡类型表 (card_type) - -```sql -CREATE TABLE card_type ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - name VARCHAR(64) NOT NULL, - code VARCHAR(32) NOT NULL, - type SMALLINT NOT NULL, -- 1:时长卡 2:次卡 3:储值卡 4:等级卡 - category SMALLINT DEFAULT 1, -- 1:团课卡 2:私教卡 3:通用卡 - price DECIMAL(10,2) NOT NULL, - original_price DECIMAL(10,2), - duration_days INT, -- 时长卡有效期(天) - total_times INT, -- 次卡总次数 - stored_value DECIMAL(10,2), -- 储值卡金额 - level SMALLINT, -- 等级卡等级 - discount DECIMAL(3,2) DEFAULT 1.00, -- 折扣率 - description TEXT, - benefits JSONB, -- 权益配置 - valid_days INT DEFAULT 365, -- 激活后有效天数 - transferable BOOLEAN DEFAULT FALSE, -- 是否可转让 - refundable BOOLEAN DEFAULT FALSE, -- 是否可退款 - status SMALLINT DEFAULT 1, -- 1:上架 2:下架 - sort_order INT DEFAULT 0, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - created_by BIGINT, - updated_by BIGINT, - deleted_at TIMESTAMP DEFAULT NULL, - - CONSTRAINT uk_card_type_code UNIQUE (tenant_id, code), - CONSTRAINT fk_card_type_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id) -); - -CREATE INDEX idx_card_type_tenant ON card_type(tenant_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_card_type_status ON card_type(status) WHERE deleted_at IS NULL; -``` - -#### 2.2.3 会员卡表 (member_card) - -```sql -CREATE TABLE member_card ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - member_id BIGINT NOT NULL, - card_type_id BIGINT NOT NULL, - card_no VARCHAR(32) NOT NULL, - status SMALLINT DEFAULT 1, -- 1:未激活 2:有效 3:已过期 4:已用完 5:已冻结 - price DECIMAL(10,2) NOT NULL, -- 购买价格 - paid_amount DECIMAL(10,2) NOT NULL, -- 实付金额 - start_date DATE, -- 生效日期 - end_date DATE, -- 到期日期 - freeze_at TIMESTAMP, -- 冻结时间 - freeze_reason VARCHAR(256), -- 冻结原因 - transfer_from BIGINT, -- 转让来源会员ID - transfer_to BIGINT, -- 转让目标会员ID - order_id BIGINT, -- 关联订单ID - remark VARCHAR(256), - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - created_by BIGINT, - updated_by BIGINT, - deleted_at TIMESTAMP DEFAULT NULL, - - CONSTRAINT uk_member_card_no UNIQUE (tenant_id, card_no), - CONSTRAINT fk_member_card_member FOREIGN KEY (member_id) REFERENCES member(id), - CONSTRAINT fk_member_card_type FOREIGN KEY (card_type_id) REFERENCES card_type(id) -); - -CREATE INDEX idx_member_card_member ON member_card(member_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_member_card_status ON member_card(status) WHERE deleted_at IS NULL; -CREATE INDEX idx_member_card_end_date ON member_card(end_date) WHERE deleted_at IS NULL; -``` - -#### 2.2.4 会员权益表 (member_benefit) - -```sql -CREATE TABLE member_benefit ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - member_id BIGINT NOT NULL, - card_id BIGINT, -- 关联会员卡ID - type SMALLINT NOT NULL, -- 1:时长 2:次数 3:储值 4:等级 - category SMALLINT DEFAULT 1, -- 1:团课 2:私教 3:通用 - name VARCHAR(64) NOT NULL, -- 权益名称 - value DECIMAL(12,2) NOT NULL, -- 权益值(天数/次数/金额) - used_value DECIMAL(12,2) DEFAULT 0, -- 已使用值 - remain_value DECIMAL(12,2) NOT NULL, -- 剩余值 - unit VARCHAR(16), -- 单位: 天/次/元 - expire_date DATE, -- 过期日期 - status SMALLINT DEFAULT 1, -- 1:有效 2:已过期 3:已用完 - source VARCHAR(32), -- 来源: purchase/reward/activity - source_id BIGINT, -- 来源ID - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - deleted_at TIMESTAMP DEFAULT NULL, - - CONSTRAINT fk_benefit_member FOREIGN KEY (member_id) REFERENCES member(id), - CONSTRAINT fk_benefit_card FOREIGN KEY (card_id) REFERENCES member_card(id) -); - -CREATE INDEX idx_benefit_member ON member_benefit(member_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_benefit_type ON member_benefit(type, category) WHERE deleted_at IS NULL; -CREATE INDEX idx_benefit_status ON member_benefit(status) WHERE deleted_at IS NULL; -CREATE INDEX idx_benefit_expire ON member_benefit(expire_date) WHERE deleted_at IS NULL; -``` - -#### 2.2.5 权益变更记录表 (benefit_log) - -```sql -CREATE TABLE benefit_log ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - member_id BIGINT NOT NULL, - benefit_id BIGINT NOT NULL, - type SMALLINT NOT NULL, -- 1:增加 2:扣减 3:过期 4:冻结 5:解冻 - before_value DECIMAL(12,2) NOT NULL, -- 变更前值 - change_value DECIMAL(12,2) NOT NULL, -- 变更值 - after_value DECIMAL(12,2) NOT NULL, -- 变更后值 - reason VARCHAR(256), -- 变更原因 - biz_type VARCHAR(32), -- 业务类型: booking/checkin/reward/refund - biz_id BIGINT, -- 业务ID - operator_id BIGINT, -- 操作人ID - operator_type VARCHAR(32), -- 操作人类型: member/staff/system - created_at TIMESTAMP DEFAULT NOW() -); - -CREATE INDEX idx_benefit_log_member ON benefit_log(member_id); -CREATE INDEX idx_benefit_log_benefit ON benefit_log(benefit_id); -CREATE INDEX idx_benefit_log_biz ON benefit_log(biz_type, biz_id); -CREATE INDEX idx_benefit_log_created ON benefit_log(created_at); -``` - -#### 2.2.6 会员等级规则表 (level_rule) - -```sql -CREATE TABLE level_rule ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - level SMALLINT NOT NULL, -- 等级 - name VARCHAR(32) NOT NULL, -- 等级名称 - icon VARCHAR(256), -- 等级图标 - min_exp INT NOT NULL, -- 最低经验值 - max_exp INT, -- 最高经验值(为空表示无上限) - discount DECIMAL(3,2) DEFAULT 1.00, -- 折扣率 - benefits JSONB, -- 等级权益 - upgrade_reward INT DEFAULT 0, -- 升级奖励经验值 - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - deleted_at TIMESTAMP DEFAULT NULL, - - CONSTRAINT uk_level_rule UNIQUE (tenant_id, level), - CONSTRAINT fk_level_rule_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id) -); - -CREATE INDEX idx_level_rule_tenant ON level_rule(tenant_id) WHERE deleted_at IS NULL; -``` - -#### 2.2.7 经验值记录表 (exp_log) - -```sql -CREATE TABLE exp_log ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - member_id BIGINT NOT NULL, - type SMALLINT NOT NULL, -- 1:获得 2:消耗 - change_exp INT NOT NULL, -- 变更经验值 - before_exp INT NOT NULL, -- 变更前经验值 - after_exp INT NOT NULL, -- 变更后经验值 - before_level SMALLINT NOT NULL, -- 变更前等级 - after_level SMALLINT NOT NULL, -- 变更后等级 - source VARCHAR(32), -- 来源: checkin/booking/purchase/reward - source_id BIGINT, -- 来源ID - remark VARCHAR(256), - created_at TIMESTAMP DEFAULT NOW() -); - -CREATE INDEX idx_exp_log_member ON exp_log(member_id); -CREATE INDEX idx_exp_log_created ON exp_log(created_at); -``` - ---- - -## 三、领域模型设计 - -### 3.1 领域模型类图 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 会员领域模型 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌───────────────────────────────────────────────────────────────────┐ │ -│ │ <> │ │ -│ │ Member │ │ -│ ├───────────────────────────────────────────────────────────────────┤ │ -│ │ - id: Long │ │ -│ │ - tenantId: Long │ │ -│ │ - storeId: Long │ │ -│ │ - memberNo: String │ │ -│ │ - name: String │ │ -│ │ - phone: String │ │ -│ │ - avatar: String │ │ -│ │ - gender: Gender │ │ -│ │ - birthday: LocalDate │ │ -│ │ - level: Integer │ │ -│ │ - exp: Integer │ │ -│ │ - status: MemberStatus │ │ -│ │ - cards: List │ │ -│ │ - benefits: List │ │ -│ ├───────────────────────────────────────────────────────────────────┤ │ -│ │ + activate(): void │ │ -│ │ + freeze(reason: String): void │ │ -│ │ + unfreeze(): void │ │ -│ │ + addExp(exp: Integer): void │ │ -│ │ + canLevelUp(): Boolean │ │ -│ │ + levelUp(): void │ │ -│ │ + getValidBenefits(): List │ │ -│ │ + getUsableBenefit(type: BenefitType): MemberBenefit │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ │ 1:N │ -│ ▼ │ -│ ┌────────────────────────────┐ ┌────────────────────────────┐ │ -│ │ <> │ │ <> │ │ -│ │ MemberCard │ │ MemberBenefit │ │ -│ ├────────────────────────────┤ ├────────────────────────────┤ │ -│ │ - id: Long │ │ - id: Long │ │ -│ │ - memberId: Long │ │ - memberId: Long │ │ -│ │ - cardTypeId: Long │ │ - cardId: Long │ │ -│ │ - cardNo: String │ │ - type: BenefitType │ │ -│ │ - status: CardStatus │ │ - category: BenefitCategory│ │ -│ │ - startDate: LocalDate │ │ - value: BigDecimal │ │ -│ │ - endDate: LocalDate │ │ - usedValue: BigDecimal │ │ -│ │ - price: BigDecimal │ │ - remainValue: BigDecimal │ │ -│ ├────────────────────────────┤ │ - expireDate: LocalDate │ │ -│ │ + activate(): void │ │ - status: BenefitStatus │ │ -│ │ + freeze(): void │ ├────────────────────────────┤ │ -│ │ + unfreeze(): void │ │ + deduct(value): void │ │ -│ │ + isExpired(): Boolean │ │ + add(value): void │ │ -│ │ + isUsable(): Boolean │ │ + isExpired(): Boolean │ │ -│ │ + getRemainDays(): Integer │ │ + isUsable(): Boolean │ │ -│ └────────────────────────────┘ └────────────────────────────┘ │ -│ │ -│ ┌────────────────────────────┐ ┌────────────────────────────┐ │ -│ │ <> │ │ <> │ │ -│ │ MemberStatus │ │ BenefitType │ │ -│ ├────────────────────────────┤ ├────────────────────────────┤ │ -│ │ NORMAL(1, "正常") │ │ DURATION(1, "时长") │ │ -│ │ FROZEN(2, "冻结") │ │ TIMES(2, "次数") │ │ -│ │ CANCELLED(3, "注销") │ │ STORED_VALUE(3, "储值") │ │ -│ └────────────────────────────┘ │ LEVEL(4, "等级") │ │ -│ └────────────────────────────┘ │ -│ │ -│ ┌────────────────────────────┐ ┌────────────────────────────┐ │ -│ │ <> │ │ <> │ │ -│ │ CardStatus │ │ BenefitCategory │ │ -│ ├────────────────────────────┤ ├────────────────────────────┤ │ -│ │ INACTIVE(1, "未激活") │ │ GROUP_CLASS(1, "团课") │ │ -│ │ ACTIVE(2, "有效") │ │ PRIVATE(2, "私教") │ │ -│ │ EXPIRED(3, "已过期") │ │ GENERAL(3, "通用") │ │ -│ │ USED_UP(4, "已用完") │ └────────────────────────────┘ │ -│ │ FROZEN(5, "已冻结") │ │ -│ └────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 3.2 领域服务 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 领域服务设计 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌───────────────────────────────────────────────────────────────────┐ │ -│ │ <> │ │ -│ │ MemberDomainService │ │ -│ ├───────────────────────────────────────────────────────────────────┤ │ -│ │ + registerMember(command: RegisterMemberCommand): Member │ │ -│ │ + updateMemberInfo(memberId: Long, command: UpdateMemberCommand) │ │ -│ │ + freezeMember(memberId: Long, reason: String): void │ │ -│ │ + unfreezeMember(memberId: Long): void │ │ -│ │ + calculateLevel(memberId: Long): Integer │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌───────────────────────────────────────────────────────────────────┐ │ -│ │ <> │ │ -│ │ BenefitDomainService │ │ -│ ├───────────────────────────────────────────────────────────────────┤ │ -│ │ + purchaseCard(command: PurchaseCardCommand): MemberCard │ │ -│ │ + activateCard(cardId: Long): void │ │ -│ │ + deductBenefit(memberId: Long, request: DeductRequest): void │ │ -│ │ + refundBenefit(memberId: Long, request: RefundRequest): void │ │ -│ │ + expireBenefits(): void │ │ -│ │ + getUsableBenefits(memberId: Long, type: BenefitType): List │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌───────────────────────────────────────────────────────────────────┐ │ -│ │ <> │ │ -│ │ LevelDomainService │ │ -│ ├───────────────────────────────────────────────────────────────────┤ │ -│ │ + addExp(memberId: Long, exp: Integer, source: String): void │ │ -│ │ + calculateLevel(tenantId: Long, exp: Integer): Integer │ │ -│ │ + getLevelBenefits(tenantId: Long, level: Integer): LevelBenefit │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 四、业务流程设计 - -### 4.1 会员注册流程 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 会员注册流程 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ 会员端 API层 Service层 数据层 │ -│ │ │ │ │ │ -│ │ 1.输入手机号 │ │ │ │ -│ │─────────────────▶│ │ │ │ -│ │ │ 2.发送验证码 │ │ │ -│ │ │───────────────────▶│ │ │ -│ │ │ │ 3.调用短信服务 │ │ -│ │ │ │─────────────────▶│ │ -│ │ │ │◀─────────────────│ │ -│ │ │◀───────────────────│ │ │ -│ │◀─────────────────│ 返回验证码ID │ │ │ -│ │ │ │ │ │ -│ │ 4.提交注册信息 │ │ │ │ -│ │─────────────────▶│ │ │ │ -│ │ │ 5.验证验证码 │ │ │ -│ │ │───────────────────▶│ │ │ -│ │ │ │ 6.查询手机号 │ │ -│ │ │ │─────────────────▶│ │ -│ │ │ │◀─────────────────│ │ -│ │ │ │ 7.检查是否已注册 │ │ -│ │ │ │ │ │ -│ │ │ │ 8.生成会员编号 │ │ -│ │ │ │ 9.创建会员 │ │ -│ │ │ │─────────────────▶│ │ -│ │ │ │◀─────────────────│ │ -│ │ │ │ 10.生成JWT Token │ │ -│ │ │◀───────────────────│ │ │ -│ │◀─────────────────│ 返回Token和会员信息 │ │ │ -│ │ │ │ │ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 4.2 会员卡购买流程 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 会员卡购买流程 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ 会员端 API层 OrderService MemberService PaymentService │ -│ │ │ │ │ │ │ -│ │ 1.选择卡种│ │ │ │ │ -│ │─────────▶│ │ │ │ │ -│ │ │ 2.创建订单 │ │ │ │ -│ │ │───────────▶│ │ │ │ -│ │ │ │ 3.校验卡种 │ │ │ -│ │ │ │─────────────▶│ │ │ -│ │ │ │◀─────────────│ │ │ -│ │ │ │ 4.创建支付单 │ │ │ -│ │ │ │─────────────────────────────▶│ │ -│ │ │ │◀─────────────────────────────│ │ -│ │◀────────│ 返回支付参数│ │ │ │ -│ │ │ │ │ │ │ -│ │ 5.完成支付│ │ │ │ │ -│ │──────────────────────────────────────────────────▶│ │ -│ │ │ │ │ 6.支付回调 │ │ -│ │ │ │◀─────────────────────────────│ │ -│ │ │ │ 7.更新订单状态│ │ │ -│ │ │ │─────────────▶│ │ │ -│ │ │ │ │ 8.创建会员卡 │ │ -│ │ │ │ │─────────────▶ │ │ -│ │ │ │ │ 9.创建权益 │ │ -│ │ │ │ │─────────────▶ │ │ -│ │ │ │ │ 10.增加经验值 │ │ -│ │ │ │ │─────────────▶ │ │ -│ │ │ │◀─────────────│ │ │ -│ │◀────────│ 购买成功通知│ │ │ │ -│ │ │ │ │ │ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 4.3 权益扣减流程 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 权益扣减流程 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ 调用方 BenefitService Repository │ -│ │ │ │ │ -│ │ 1.请求扣减权益 │ │ │ -│ │─────────────────▶│ │ │ -│ │ │ 2.查询可用权益 │ │ -│ │ │────────────────────▶│ │ -│ │ │◀────────────────────│ │ -│ │ │ │ │ -│ │ │ 3.按优先级排序 │ │ -│ │ │ (即将过期优先) │ │ -│ │ │ │ │ -│ │ │ 4.校验余额充足 │ │ -│ │ │ │ │ -│ │ │ 5.执行扣减(事务) │ │ -│ │ │────────────────────▶│ │ -│ │ │ UPDATE member_benefit │ -│ │ │ SET remain_value = remain_value - ? │ -│ │ │ used_value = used_value + ? │ -│ │ │ WHERE id = ? AND remain_value >= ? │ -│ │ │ │ │ -│ │ │ 6.记录变更日志 │ │ -│ │ │────────────────────▶│ │ -│ │ │ │ │ -│ │ │ 7.检查是否用完 │ │ -│ │ │ 更新状态 │ │ -│ │ │────────────────────▶│ │ -│ │ │ │ │ -│ │◀─────────────────│ 返回扣减结果 │ │ -│ │ │ │ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 4.4 等级升级流程 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 等级升级流程 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ 触发源 LevelService Member LevelRule │ -│ │ │ │ │ │ -│ │ 1.增加经验值 │ │ │ │ -│ │────────────────▶│ │ │ │ -│ │ │ 2.查询当前会员 │ │ │ -│ │ │───────────────────▶│ │ │ -│ │ │◀───────────────────│ │ │ -│ │ │ │ │ │ -│ │ │ 3.计算新等级 │ │ │ -│ │ │─────────────────────────────────────▶│ │ -│ │ │◀─────────────────────────────────────│ │ -│ │ │ │ │ │ -│ │ │ 4.比较是否升级 │ │ │ -│ │ │ │ │ │ -│ │ │ [如果升级] │ │ │ -│ │ │ 5.更新会员等级 │ │ │ -│ │ │───────────────────▶│ │ │ -│ │ │ │ │ │ -│ │ │ 6.发放升级奖励 │ │ │ -│ │ │ (经验值/优惠券) │ │ │ -│ │ │───────────────────▶│ │ │ -│ │ │ │ │ │ -│ │ │ 7.记录升级日志 │ │ │ -│ │ │───────────────────▶│ │ │ -│ │ │ │ │ │ -│ │ │ 8.发送升级通知 │ │ │ -│ │ │ │ │ │ -│ │◀────────────────│ 返回升级结果 │ │ │ -│ │ │ │ │ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 五、接口设计 - -### 5.1 会员接口 - -#### 5.1.1 会员注册 - -``` -POST /v1/members/register - -Request: -{ - "storeId": 1, - "phone": "13800138000", - "verifyCode": "123456", - "verifyCodeId": "uuid-xxx", - "name": "张三", - "gender": 1, - "birthday": "1990-01-01" -} - -Response: -{ - "code": 0, - "message": "success", - "data": { - "memberId": 10001, - "memberNo": "M202602280001", - "name": "张三", - "phone": "138****8000", - "level": 0, - "levelName": "普通会员", - "accessToken": "eyJhbGciOiJIUzI1NiIs...", - "refreshToken": "eyJhbGciOiJIUzI1NiIs...", - "expiresIn": 7200 - } -} -``` - -#### 5.1.2 获取会员信息 - -``` -GET /v1/members/{id} - -Response: -{ - "code": 0, - "message": "success", - "data": { - "id": 10001, - "memberNo": "M202602280001", - "name": "张三", - "phone": "138****8000", - "avatar": "https://xxx.com/avatar.jpg", - "gender": 1, - "genderName": "男", - "birthday": "1990-01-01", - "level": 2, - "levelName": "银卡会员", - "exp": 1500, - "totalExp": 1500, - "nextLevelExp": 3000, - "cards": [ - { - "id": 1, - "cardNo": "C202602280001", - "cardTypeName": "年卡", - "status": 2, - "statusName": "有效", - "startDate": "2026-02-28", - "endDate": "2027-02-27", - "remainDays": 365 - } - ], - "benefits": [ - { - "id": 1, - "type": 1, - "typeName": "时长", - "name": "团课时长", - "remainValue": 30, - "unit": "天", - "expireDate": "2027-02-27" - } - ], - "createdAt": "2026-02-28T10:00:00" - } -} -``` - -#### 5.1.3 更新会员信息 - -``` -PUT /v1/members/{id} - -Request: -{ - "name": "张三", - "avatar": "https://xxx.com/new-avatar.jpg", - "gender": 1, - "birthday": "1990-01-01", - "emergencyContact": "李四", - "emergencyPhone": "13900139000" -} - -Response: -{ - "code": 0, - "message": "success", - "data": { - "id": 10001, - "name": "张三", - "avatar": "https://xxx.com/new-avatar.jpg", - "updatedAt": "2026-02-28T11:00:00" - } -} -``` - -### 5.2 会员卡接口 - -#### 5.2.1 获取可购买卡种列表 - -``` -GET /v1/card-types?storeId=1&status=1 - -Response: -{ - "code": 0, - "message": "success", - "data": { - "list": [ - { - "id": 1, - "name": "月卡", - "code": "MONTH_CARD", - "type": 1, - "typeName": "时长卡", - "category": 3, - "categoryName": "通用卡", - "price": 299.00, - "originalPrice": 399.00, - "durationDays": 30, - "validDays": 365, - "description": "30天无限次使用", - "benefits": { - "groupClass": true, - "privateClass": false, - "locker": true - } - } - ], - "total": 5 - } -} -``` - -#### 5.2.2 购买会员卡 - -``` -POST /v1/member-cards/purchase - -Request: -{ - "memberId": 10001, - "cardTypeId": 1, - "quantity": 1, - "couponId": null, - "remark": "首次购卡" -} - -Response: -{ - "code": 0, - "message": "success", - "data": { - "orderId": "O202602280001", - "paymentUrl": "weixin://wxpay/bizpayurl?...", - "amount": 299.00 - } -} -``` - -#### 5.2.3 获取会员卡列表 - -``` -GET /v1/members/{memberId}/cards - -Response: -{ - "code": 0, - "message": "success", - "data": { - "list": [ - { - "id": 1, - "cardNo": "C202602280001", - "cardTypeName": "年卡", - "type": 1, - "typeName": "时长卡", - "status": 2, - "statusName": "有效", - "price": 1999.00, - "paidAmount": 1799.10, - "startDate": "2026-02-28", - "endDate": "2027-02-27", - "remainDays": 365, - "benefits": [ - { - "type": 1, - "typeName": "时长", - "remainValue": 365, - "unit": "天" - } - ] - } - ], - "total": 1 - } -} -``` - -### 5.3 权益接口 - -#### 5.3.1 获取会员权益 - -``` -GET /v1/members/{memberId}/benefits?type=1&status=1 - -Response: -{ - "code": 0, - "message": "success", - "data": { - "list": [ - { - "id": 1, - "type": 1, - "typeName": "时长", - "category": 3, - "categoryName": "通用", - "name": "年卡时长", - "value": 365, - "usedValue": 0, - "remainValue": 365, - "unit": "天", - "expireDate": "2027-02-27", - "status": 1, - "statusName": "有效", - "cardId": 1, - "cardNo": "C202602280001" - } - ], - "total": 1, - "summary": { - "totalDuration": 365, - "totalTimes": 50, - "totalStoredValue": 1000.00 - } - } -} -``` - -#### 5.3.2 获取权益变更记录 - -``` -GET /v1/members/{memberId}/benefit-logs?startDate=2026-01-01&endDate=2026-02-28 - -Response: -{ - "code": 0, - "message": "success", - "data": { - "list": [ - { - "id": 1, - "benefitId": 1, - "benefitName": "年卡时长", - "type": 1, - "typeName": "增加", - "beforeValue": 0, - "changeValue": 365, - "afterValue": 365, - "reason": "购买年卡", - "bizType": "purchase", - "bizId": "O202602280001", - "createdAt": "2026-02-28T10:00:00" - }, - { - "id": 2, - "benefitId": 2, - "benefitName": "团课次数", - "type": 2, - "typeName": "扣减", - "beforeValue": 10, - "changeValue": 1, - "afterValue": 9, - "reason": "预约团课: 瑜伽课", - "bizType": "booking", - "bizId": "B202602280001", - "createdAt": "2026-02-28T14:00:00" - } - ], - "total": 2, - "page": 1, - "pageSize": 20 - } -} -``` - ---- - -## 六、核心代码设计 - -### 6.1 会员实体 - -```java -package com.gym.domain.model.member; - -import com.gym.domain.model.base.BaseEntity; -import com.gym.domain.model.base.AggregateRoot; -import lombok.Getter; -import lombok.Setter; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; - -@Getter -@Setter -public class Member extends BaseEntity implements AggregateRoot { - - private Long tenantId; - private Long storeId; - private String memberNo; - private String name; - private String phone; - private String phoneMask; - private String avatar; - private Gender gender; - private LocalDate birthday; - private String idCard; - private String emergencyContact; - private String emergencyPhone; - private Integer level; - private Integer exp; - private Integer totalExp; - private MemberStatus status; - private String registerSource; - private LocalDateTime lastLoginAt; - private String lastLoginIp; - - private List cards = new ArrayList<>(); - private List benefits = new ArrayList<>(); - - public boolean isNormal() { - return MemberStatus.NORMAL.equals(this.status); - } - - public boolean isFrozen() { - return MemberStatus.FROZEN.equals(this.status); - } - - public void freeze(String reason) { - if (!isNormal()) { - throw new BusinessException("会员状态异常,无法冻结"); - } - this.status = MemberStatus.FROZEN; - this.updatedAt = LocalDateTime.now(); - } - - public void unfreeze() { - if (!isFrozen()) { - throw new BusinessException("会员未冻结"); - } - this.status = MemberStatus.NORMAL; - this.updatedAt = LocalDateTime.now(); - } - - public void addExp(Integer exp) { - if (exp <= 0) { - return; - } - this.exp += exp; - this.totalExp += exp; - this.updatedAt = LocalDateTime.now(); - } - - public void updateLevel(Integer newLevel) { - if (newLevel > this.level) { - this.level = newLevel; - this.updatedAt = LocalDateTime.now(); - } - } - - public List getValidBenefits() { - return benefits.stream() - .filter(MemberBenefit::isUsable) - .toList(); - } - - public List getUsableBenefits(BenefitType type, BenefitCategory category) { - return benefits.stream() - .filter(b -> b.getType().equals(type)) - .filter(b -> category == null || b.getCategory().equals(category)) - .filter(MemberBenefit::isUsable) - .sorted((a, b) -> { - if (a.getExpireDate() == null && b.getExpireDate() == null) return 0; - if (a.getExpireDate() == null) return 1; - if (b.getExpireDate() == null) return -1; - return a.getExpireDate().compareTo(b.getExpireDate()); - }) - .toList(); - } -} -``` - -### 6.2 会员权益实体 - -```java -package com.gym.domain.model.member; - -import com.gym.domain.model.base.BaseEntity; -import lombok.Getter; -import lombok.Setter; -import java.math.BigDecimal; -import java.time.LocalDate; -import java.time.LocalDateTime; - -@Getter -@Setter -public class MemberBenefit extends BaseEntity { - - private Long tenantId; - private Long memberId; - private Long cardId; - private BenefitType type; - private BenefitCategory category; - private String name; - private BigDecimal value; - private BigDecimal usedValue; - private BigDecimal remainValue; - private String unit; - private LocalDate expireDate; - private BenefitStatus status; - private String source; - private Long sourceId; - - public boolean isUsable() { - if (!BenefitStatus.VALID.equals(status)) { - return false; - } - if (remainValue.compareTo(BigDecimal.ZERO) <= 0) { - return false; - } - if (expireDate != null && expireDate.isBefore(LocalDate.now())) { - return false; - } - return true; - } - - public boolean isExpired() { - return expireDate != null && expireDate.isBefore(LocalDate.now()); - } - - public boolean canDeduct(BigDecimal amount) { - return remainValue.compareTo(amount) >= 0; - } - - public void deduct(BigDecimal amount) { - if (!canDeduct(amount)) { - throw new BusinessException("权益余额不足"); - } - this.usedValue = this.usedValue.add(amount); - this.remainValue = this.remainValue.subtract(amount); - this.updatedAt = LocalDateTime.now(); - - if (this.remainValue.compareTo(BigDecimal.ZERO) == 0) { - this.status = BenefitStatus.USED_UP; - } - } - - public void add(BigDecimal amount) { - this.value = this.value.add(amount); - this.remainValue = this.remainValue.add(amount); - this.updatedAt = LocalDateTime.now(); - } - - public void expire() { - this.status = BenefitStatus.EXPIRED; - this.updatedAt = LocalDateTime.now(); - } -} -``` - -### 6.3 权益服务 - -```java -package com.gym.domain.service; - -import com.gym.domain.model.member.*; -import com.gym.domain.repository.MemberBenefitRepository; -import com.gym.domain.repository.BenefitLogRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import java.math.BigDecimal; -import java.time.LocalDateTime; - -@Service -@RequiredArgsConstructor -public class BenefitDomainService { - - private final MemberBenefitRepository benefitRepository; - private final BenefitLogRepository benefitLogRepository; - - public Flux getUsableBenefits(Long memberId, BenefitType type, BenefitCategory category) { - return benefitRepository.findUsableByMemberId(memberId, type, category) - .sort((a, b) -> { - if (a.getExpireDate() == null && b.getExpireDate() == null) return 0; - if (a.getExpireDate() == null) return 1; - if (b.getExpireDate() == null) return -1; - return a.getExpireDate().compareTo(b.getExpireDate()); - }); - } - - @Transactional - public Mono deductBenefit(Long memberId, BenefitType type, BenefitCategory category, - BigDecimal amount, String bizType, Long bizId, String reason) { - return getUsableBenefits(memberId, type, category) - .collectList() - .flatMap(benefits -> { - BigDecimal remaining = amount; - - for (MemberBenefit benefit : benefits) { - if (remaining.compareTo(BigDecimal.ZERO) <= 0) break; - - BigDecimal deductAmount = benefit.getRemainValue().min(remaining); - BigDecimal beforeValue = benefit.getRemainValue(); - - benefit.deduct(deductAmount); - remaining = remaining.subtract(deductAmount); - - BenefitLog log = BenefitLog.builder() - .tenantId(benefit.getTenantId()) - .memberId(memberId) - .benefitId(benefit.getId()) - .type(BenefitLogType.DEDUCT) - .beforeValue(beforeValue) - .changeValue(deductAmount) - .afterValue(benefit.getRemainValue()) - .reason(reason) - .bizType(bizType) - .bizId(bizId) - .build(); - - benefitLogRepository.save(log).subscribe(); - } - - if (remaining.compareTo(BigDecimal.ZERO) > 0) { - return Mono.error(new BusinessException("权益余额不足")); - } - - return Mono.when(benefits.stream() - .map(benefitRepository::save) - .toArray(Mono[]::new)); - }); - } - - @Transactional - public Mono addBenefit(Long memberId, Long cardId, BenefitType type, - BenefitCategory category, String name, BigDecimal value, - String unit, LocalDate expireDate, String source, Long sourceId) { - MemberBenefit benefit = new MemberBenefit(); - benefit.setMemberId(memberId); - benefit.setCardId(cardId); - benefit.setType(type); - benefit.setCategory(category); - benefit.setName(name); - benefit.setValue(value); - benefit.setUsedValue(BigDecimal.ZERO); - benefit.setRemainValue(value); - benefit.setUnit(unit); - benefit.setExpireDate(expireDate); - benefit.setStatus(BenefitStatus.VALID); - benefit.setSource(source); - benefit.setSourceId(sourceId); - - return benefitRepository.save(benefit) - .doOnNext(saved -> { - BenefitLog log = BenefitLog.builder() - .tenantId(saved.getTenantId()) - .memberId(memberId) - .benefitId(saved.getId()) - .type(BenefitLogType.ADD) - .beforeValue(BigDecimal.ZERO) - .changeValue(value) - .afterValue(value) - .reason("购买会员卡") - .bizType("purchase") - .bizId(sourceId) - .build(); - - benefitLogRepository.save(log).subscribe(); - }); - } -} -``` - -### 6.4 会员仓储 - -```java -package com.gym.infrastructure.repository; - -import com.gym.domain.model.member.Member; -import com.gym.infrastructure.r2dbc.MemberR2dbcRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.r2dbc.core.DatabaseClient; -import org.springframework.stereotype.Repository; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import java.time.LocalDateTime; - -@Repository -@RequiredArgsConstructor -public class MemberRepository { - - private final MemberR2dbcRepository r2dbcRepository; - private final DatabaseClient databaseClient; - - public Mono findById(Long id) { - return r2dbcRepository.findByIdAndDeletedAtIsNull(id); - } - - public Mono findByPhone(Long tenantId, String phone) { - return r2dbcRepository.findByTenantIdAndPhoneAndDeletedAtIsNull(tenantId, phone); - } - - public Mono findByMemberNo(Long tenantId, String memberNo) { - return r2dbcRepository.findByTenantIdAndMemberNoAndDeletedAtIsNull(tenantId, memberNo); - } - - public Flux findByStoreId(Long storeId) { - return r2dbcRepository.findByStoreIdAndDeletedAtIsNull(storeId); - } - - public Mono save(Member member) { - member.setUpdatedAt(LocalDateTime.now()); - if (member.getId() == null) { - member.setCreatedAt(LocalDateTime.now()); - return r2dbcRepository.save(member); - } - return r2dbcRepository.save(member); - } - - public Mono softDelete(Long id, Long operatorId) { - return databaseClient.sql(""" - UPDATE member - SET deleted_at = NOW(), updated_at = NOW(), updated_by = :operatorId - WHERE id = :id AND deleted_at IS NULL - """) - .bind("id", id) - .bind("operatorId", operatorId) - .fetch() - .rowsUpdated() - .then(); - } - - public Mono existsByPhone(Long tenantId, String phone) { - return r2dbcRepository.existsByTenantIdAndPhoneAndDeletedAtIsNull(tenantId, phone); - } - - public Mono countByStoreId(Long storeId) { - return r2dbcRepository.countByStoreIdAndDeletedAtIsNull(storeId); - } - - public Mono generateMemberNo(Long tenantId) { - String prefix = "M" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); - - return databaseClient.sql(""" - SELECT COALESCE(MAX(CAST(SUBSTRING(member_no, 10) AS BIGINT)), 0) + 1 as next_no - FROM member - WHERE tenant_id = :tenantId - AND member_no LIKE :prefix - AND deleted_at IS NULL - """) - .bind("tenantId", tenantId) - .bind("prefix", prefix + "%") - .map(row -> row.get("next_no", Long.class)) - .first() - .map(nextNo -> prefix + String.format("%04d", nextNo)); - } -} -``` - ---- - -## 七、缓存设计 - -### 7.1 缓存策略 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 会员模块缓存策略 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ 1. 会员信息缓存 │ -│ ├── Key: member:info:{memberId} │ -│ ├── Value: Member JSON │ -│ ├── TTL: 30分钟 │ -│ ├── 更新策略: 写穿透(Write-Through) │ -│ └── 失效策略: 更新时删除 │ -│ │ -│ 2. 会员权益缓存 │ -│ ├── Key: member:benefits:{memberId} │ -│ ├── Value: List JSON │ -│ ├── TTL: 10分钟 │ -│ ├── 更新策略: 写穿透 │ -│ └── 失效策略: 权益变更时删除 │ -│ │ -│ 3. 会员卡类型缓存 │ -│ ├── Key: card-types:tenant:{tenantId} │ -│ ├── Value: List JSON │ -│ ├── TTL: 1小时 │ -│ ├── 更新策略: 定时刷新 │ -│ └── 失效策略: 卡种变更时删除 │ -│ │ -│ 4. 等级规则缓存 │ -│ ├── Key: level-rules:tenant:{tenantId} │ -│ ├── Value: List JSON │ -│ ├── TTL: 1天 │ -│ ├── 更新策略: 定时刷新 │ -│ └── 失效策略: 规则变更时删除 │ -│ │ -│ 5. 会员编号生成锁 │ -│ ├── Key: member:no:lock:{tenantId} │ -│ ├── Value: 1 │ -│ ├── TTL: 5秒 │ -│ └── 用途: 防止并发生成重复编号 │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 7.2 缓存配置 - -```java -package com.gym.infrastructure.cache; - -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import java.util.concurrent.TimeUnit; - -@Configuration -public class CacheConfig { - - @Bean - public Cache memberCache() { - return Caffeine.newBuilder() - .maximumSize(10000) - .expireAfterWrite(30, TimeUnit.MINUTES) - .recordStats() - .build(); - } - - @Bean - public Cache benefitCache() { - return Caffeine.newBuilder() - .maximumSize(20000) - .expireAfterWrite(10, TimeUnit.MINUTES) - .recordStats() - .build(); - } - - @Bean - public Cache cardTypeCache() { - return Caffeine.newBuilder() - .maximumSize(1000) - .expireAfterWrite(1, TimeUnit.HOURS) - .recordStats() - .build(); - } - - @Bean - public Cache levelRuleCache() { - return Caffeine.newBuilder() - .maximumSize(500) - .expireAfterWrite(1, TimeUnit.DAYS) - .recordStats() - .build(); - } -} -``` - ---- - -## 八、异常处理 - -### 8.1 异常定义 - -```java -package com.gym.domain.exception; - -public class MemberException extends BusinessException { - - public static final MemberException MEMBER_NOT_FOUND = - new MemberException(40001, "会员不存在"); - - public static final MemberException MEMBER_ALREADY_EXISTS = - new MemberException(40002, "会员已存在"); - - public static final MemberException MEMBER_FROZEN = - new MemberException(40003, "会员已冻结"); - - public static final MemberException MEMBER_CANCELLED = - new MemberException(40004, "会员已注销"); - - public static final MemberException PHONE_ALREADY_REGISTERED = - new MemberException(40005, "手机号已注册"); - - public static final MemberException VERIFY_CODE_ERROR = - new MemberException(40006, "验证码错误"); - - public static final MemberException VERIFY_CODE_EXPIRED = - new MemberException(40007, "验证码已过期"); - - public MemberException(int code, String message) { - super(code, message); - } -} - -public class BenefitException extends BusinessException { - - public static final BenefitException BENEFIT_NOT_FOUND = - new BenefitException(40101, "权益不存在"); - - public static final BenefitException BENEFIT_INSUFFICIENT = - new BenefitException(40102, "权益余额不足"); - - public static final BenefitException BENEFIT_EXPIRED = - new BenefitException(40103, "权益已过期"); - - public static final BenefitException BENEFIT_USED_UP = - new BenefitException(40104, "权益已用完"); - - public static final BenefitException CARD_NOT_FOUND = - new BenefitException(40105, "会员卡不存在"); - - public static final BenefitException CARD_EXPIRED = - new BenefitException(40106, "会员卡已过期"); - - public static final BenefitException CARD_FROZEN = - new BenefitException(40107, "会员卡已冻结"); - - public BenefitException(int code, String message) { - super(code, message); - } -} -``` - -### 8.2 异常处理 - -```java -package com.gym.api.exception; - -import com.gym.domain.exception.*; -import com.gym.api.dto.ApiResponse; -import lombok.extern.slf4j.Slf4j; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; -import reactor.core.publisher.Mono; - -@Slf4j -@RestControllerAdvice -public class GlobalExceptionHandler { - - @ExceptionHandler(MemberException.class) - public Mono> handleMemberException(MemberException e) { - log.warn("会员业务异常: code={}, message={}", e.getCode(), e.getMessage()); - return Mono.just(ApiResponse.error(e.getCode(), e.getMessage())); - } - - @ExceptionHandler(BenefitException.class) - public Mono> handleBenefitException(BenefitException e) { - log.warn("权益业务异常: code={}, message={}", e.getCode(), e.getMessage()); - return Mono.just(ApiResponse.error(e.getCode(), e.getMessage())); - } - - @ExceptionHandler(BusinessException.class) - public Mono> handleBusinessException(BusinessException e) { - log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage()); - return Mono.just(ApiResponse.error(e.getCode(), e.getMessage())); - } - - @ExceptionHandler(Exception.class) - public Mono> handleException(Exception e) { - log.error("系统异常", e); - return Mono.just(ApiResponse.error(50001, "系统异常,请稍后重试")); - } -} -``` - ---- - -## 九、测试设计 - -### 9.1 单元测试 - -```java -package com.gym.domain.model.member; - -import org.junit.jupiter.api.Test; -import java.math.BigDecimal; -import java.time.LocalDate; -import static org.junit.jupiter.api.Assertions.*; - -class MemberBenefitTest { - - @Test - void testIsUsable_WhenValid_ShouldReturnTrue() { - MemberBenefit benefit = new MemberBenefit(); - benefit.setStatus(BenefitStatus.VALID); - benefit.setRemainValue(BigDecimal.TEN); - benefit.setExpireDate(LocalDate.now().plusDays(10)); - - assertTrue(benefit.isUsable()); - } - - @Test - void testIsUsable_WhenExpired_ShouldReturnFalse() { - MemberBenefit benefit = new MemberBenefit(); - benefit.setStatus(BenefitStatus.VALID); - benefit.setRemainValue(BigDecimal.TEN); - benefit.setExpireDate(LocalDate.now().minusDays(1)); - - assertFalse(benefit.isUsable()); - } - - @Test - void testIsUsable_WhenUsedUp_ShouldReturnFalse() { - MemberBenefit benefit = new MemberBenefit(); - benefit.setStatus(BenefitStatus.VALID); - benefit.setRemainValue(BigDecimal.ZERO); - benefit.setExpireDate(LocalDate.now().plusDays(10)); - - assertFalse(benefit.isUsable()); - } - - @Test - void testDeduct_WhenSufficient_ShouldSuccess() { - MemberBenefit benefit = new MemberBenefit(); - benefit.setStatus(BenefitStatus.VALID); - benefit.setValue(BigDecimal.TEN); - benefit.setUsedValue(BigDecimal.ZERO); - benefit.setRemainValue(BigDecimal.TEN); - - benefit.deduct(BigDecimal.valueOf(3)); - - assertEquals(BigDecimal.valueOf(7), benefit.getRemainValue()); - assertEquals(BigDecimal.valueOf(3), benefit.getUsedValue()); - } - - @Test - void testDeduct_WhenInsufficient_ShouldThrowException() { - MemberBenefit benefit = new MemberBenefit(); - benefit.setStatus(BenefitStatus.VALID); - benefit.setRemainValue(BigDecimal.ONE); - - assertThrows(BusinessException.class, () -> benefit.deduct(BigDecimal.TEN)); - } - - @Test - void testDeduct_WhenFullyUsed_ShouldUpdateStatus() { - MemberBenefit benefit = new MemberBenefit(); - benefit.setStatus(BenefitStatus.VALID); - benefit.setValue(BigDecimal.TEN); - benefit.setUsedValue(BigDecimal.ZERO); - benefit.setRemainValue(BigDecimal.TEN); - - benefit.deduct(BigDecimal.TEN); - - assertEquals(BenefitStatus.USED_UP, benefit.getStatus()); - } -} -``` - -### 9.2 集成测试 - -```java -package com.gym.domain.service; - -import com.gym.domain.model.member.*; -import com.gym.domain.repository.MemberBenefitRepository; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.transaction.reactive.TransactionalOperator; -import reactor.core.publisher.Mono; -import reactor.test.StepVerifier; -import java.math.BigDecimal; -import java.time.LocalDate; - -@SpringBootTest -class BenefitDomainServiceIntegrationTest { - - @Autowired - private BenefitDomainService benefitService; - - @Autowired - private MemberBenefitRepository benefitRepository; - - @Autowired - private TransactionalOperator rxtx; - - @Test - void testDeductBenefit_ShouldSuccess() { - Long memberId = 1L; - Long benefitId = 1L; - - Mono result = benefitRepository.findById(benefitId) - .flatMap(benefit -> { - BigDecimal beforeValue = benefit.getRemainValue(); - - return benefitService.deductBenefit( - memberId, - BenefitType.TIMES, - BenefitCategory.GROUP_CLASS, - BigDecimal.ONE, - "booking", - 1L, - "预约扣减" - ).then(Mono.just(beforeValue)); - }) - .flatMap(beforeValue -> benefitRepository.findById(benefitId) - .map(benefit -> { - assertEquals(beforeValue.subtract(BigDecimal.ONE), benefit.getRemainValue()); - return true; - })) - .as(rxtx::transactional) - .then(); - - StepVerifier.create(result) - .verifyComplete(); - } -} -``` - ---- - -## 十、附录 - -### 10.1 枚举定义 - -```java -public enum MemberStatus { - NORMAL(1, "正常"), - FROZEN(2, "冻结"), - CANCELLED(3, "注销"); - - private final int code; - private final String name; -} - -public enum CardStatus { - INACTIVE(1, "未激活"), - ACTIVE(2, "有效"), - EXPIRED(3, "已过期"), - USED_UP(4, "已用完"), - FROZEN(5, "已冻结"); - - private final int code; - private final String name; -} - -public enum BenefitType { - DURATION(1, "时长"), - TIMES(2, "次数"), - STORED_VALUE(3, "储值"), - LEVEL(4, "等级"); - - private final int code; - private final String name; -} - -public enum BenefitCategory { - GROUP_CLASS(1, "团课"), - PRIVATE(2, "私教"), - GENERAL(3, "通用"); - - private final int code; - private final String name; -} - -public enum BenefitStatus { - VALID(1, "有效"), - EXPIRED(2, "已过期"), - USED_UP(3, "已用完"); - - private final int code; - private final String name; -} - -public enum BenefitLogType { - ADD(1, "增加"), - DEDUCT(2, "扣减"), - EXPIRE(3, "过期"), - FREEZE(4, "冻结"), - UNFREEZE(5, "解冻"); - - private final int code; - private final String name; -} - -public enum Gender { - UNKNOWN(0, "未知"), - MALE(1, "男"), - FEMALE(2, "女"); - - private final int code; - private final String name; -} -``` - -### 10.2 配置项 - -```yaml -member: - register: - default-level: 0 - default-exp: 0 - member-no-prefix: "M" - - benefit: - expire-notice-days: 7 - max-benefits-per-member: 100 - - level: - exp-rules: - checkin: 10 - booking: 20 - purchase: 100 -``` - ---- - -## 十一、版本历史 - -| 版本 | 日期 | 作者 | 变更内容 | -| ---- | ---------- | ---- | -------- | -| v1.0 | 2026-02-28 | 张翔 | 初稿 | - ---- - -_文档结束_ diff --git a/docs/design/modules/LLD-签到模块详细设计.md b/docs/design/modules/LLD-签到模块详细设计.md deleted file mode 100644 index d0a3551..0000000 --- a/docs/design/modules/LLD-签到模块详细设计.md +++ /dev/null @@ -1,1973 +0,0 @@ -# 健身房管理系统详细设计文档 - 签到模块(LLD) - -> 文档编号: GYM-LLD-003 -> 版本: v1.0 -> 日期: 2026-02-28 -> 作者: 张翔 -> 状态: 初稿 -> **归属版本**: 基础版 - -**说明**:本文档为健身房管理系统**基础版**的签到模块详细设计文档,描述扫码签到模块的数据库设计、API设计、业务逻辑实现等技术细节。 - ---- - -## 文档修订历史 - -| 版本 | 日期 | 作者 | 修订内容 | -| ---- | ---------- | ---- | -------- | -| v1.0 | 2026-02-28 | 张翔 | 初稿 | - ---- - -## 参考文档 - -- 《健身房管理系统产品设计文档》 GYM-PRD-001 -- 《健身房管理系统业务概要设计文档》 GYM-HLD-001 -- 《健身房管理系统详细设计文档》 GYM-LLD-000 -- Spring Boot 3 官方文档 -- R2DBC 规范文档 -- PostgreSQL 官方文档 - ---- - -## 一、模块概述 - -### 1.1 模块定位 - -签到模块是健身房管理系统的核心业务模块,负责管理会员的入场签到和课程签到,支持多种签到方式: - -- **二维码签到**:会员出示二维码,扫码签到 -- **人脸识别签到**:通过人脸识别设备自动签到 -- **NFC签到**:会员卡或手机NFC感应签到 -- **教练代签**:教练手动为会员签到 - -### 1.2 模块边界 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 签到模块边界 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 签到模块内部 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 签到网关 • 签到验证 • 签到记录 • 签到统计 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 外部依赖 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 会员模块 (查询会员信息、验证会员状态) │ │ -│ │ • 权益模块 (验证权益有效性、扣减权益) │ │ -│ │ • 预约模块 (查询预约信息、验证签到资格) │ │ -│ │ • 设备模块 (人脸识别设备、NFC读卡器) │ │ -│ │ • 消息模块 (发送签到通知) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 被依赖 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 财务模块 (签到消费记录) │ │ -│ │ • 数据模块 (签到数据分析、会员活跃度统计) │ │ -│ │ • 考勤模块 (教练考勤统计) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 1.3 签到类型 - -| 签到类型 | 说明 | 触发条件 | 验证规则 | -| ------------ | ---------------- | ------------- | ---------------------- | -| **入场签到** | 会员进入健身房 | 扫码/人脸/NFC | 验证会员卡有效性 | -| **课程签到** | 会员参加预约课程 | 扫码/教练代签 | 验证预约记录、时间窗口 | -| **私教签到** | 会员上私教课 | 教练代签 | 验证私教预约、教练身份 | -| **活动签到** | 会员参加活动 | 扫码 | 验证活动报名 | - ---- - -## 二、数据模型设计 - -### 2.1 实体关系图 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 实体关系图 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────┐ ┌──────────────────┐ ┌──────────────┐ │ -│ │ member │ │ booking_record │ │ device │ │ -│ │ (会员) │ │ (预约记录) │ │ (设备) │ │ -│ └──────┬───────┘ └────────┬─────────┘ └──────┬───────┘ │ -│ │ 1:N │ 1:N │ 1:N │ -│ │ │ │ │ -│ └───────────────────┴─────────────────────┘ │ -│ │ 1:N │ -│ ▼ │ -│ ┌──────────────────┐ │ -│ │ checkin_record │ │ -│ │ (签到记录) │ │ -│ └──────────────────┘ │ -│ │ -│ ┌──────────────┐ │ -│ │ member │ │ -│ │ (会员) │ │ -│ └──────┬───────┘ │ -│ │ 1:N │ -│ ▼ │ -│ ┌──────────────────┐ │ -│ │ member_face │ │ -│ │ (会员人脸) │ │ -│ └──────────────────┘ │ -│ │ -│ 关系说明: │ -│ • member (1) ─── (N) checkin_record : 一个会员有多个签到记录 │ -│ • booking_record (1) ─── (N) checkin_record : 一个预约有多个签到记录 │ -│ • device (1) ─── (N) checkin_record : 一个设备有多个签到记录 │ -│ • member (1) ─── (N) member_face : 一个会员有多个人脸特征 │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 2.2 数据表设计 - -#### 2.2.1 签到记录表 (checkin_record) - -```sql -CREATE TABLE checkin_record ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - store_id BIGINT NOT NULL, - member_id BIGINT NOT NULL, - booking_id BIGINT, -- 关联预约记录ID - type SMALLINT NOT NULL, -- 1:入场 2:课程 3:私教 4:活动 - method SMALLINT NOT NULL, -- 1:二维码 2:人脸 3:NFC 4:教练代签 - device_id BIGINT, -- 签到设备ID - device_name VARCHAR(64), -- 设备名称 - operator_id BIGINT, -- 操作人ID(教练代签时) - operator_name VARCHAR(64), -- 操作人姓名 - status SMALLINT DEFAULT 1, -- 1:成功 2:失败 3:已取消 - checkin_at TIMESTAMP NOT NULL, -- 签到时间 - checkin_date DATE NOT NULL, -- 签到日期(便于统计) - location VARCHAR(128), -- 签到位置 - latitude DECIMAL(10,7), -- 纬度 - longitude DECIMAL(10,7), -- 经度 - fail_reason VARCHAR(256), -- 失败原因 - benefit_id BIGINT, -- 扣减的权益ID - benefit_type SMALLINT, -- 权益类型 - benefit_value DECIMAL(10,2), -- 扣减值 - extra_data JSONB, -- 扩展数据 - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - deleted_at TIMESTAMP DEFAULT NULL, - - CONSTRAINT fk_checkin_member FOREIGN KEY (member_id) REFERENCES member(id), - CONSTRAINT fk_checkin_booking FOREIGN KEY (booking_id) REFERENCES booking_record(id) -); - -CREATE INDEX idx_checkin_tenant ON checkin_record(tenant_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_checkin_store ON checkin_record(store_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_checkin_member ON checkin_record(member_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_checkin_date ON checkin_record(checkin_date) WHERE deleted_at IS NULL; -CREATE INDEX idx_checkin_type ON checkin_record(type) WHERE deleted_at IS NULL; -CREATE INDEX idx_checkin_status ON checkin_record(status) WHERE deleted_at IS NULL; -CREATE INDEX idx_checkin_time ON checkin_record(checkin_at) WHERE deleted_at IS NULL; -``` - -#### 2.2.2 会员人脸信息表 (member_face) - -```sql -CREATE TABLE member_face ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - member_id BIGINT NOT NULL, - face_feature BYTEA NOT NULL, -- 人脸特征值(加密存储) - face_image VARCHAR(512), -- 人脸照片URL - feature_version VARCHAR(32), -- 特征版本 - quality_score DECIMAL(5,2), -- 质量分数 - status SMALLINT DEFAULT 1, -- 1:正常 2:待更新 3:已禁用 - last_match_at TIMESTAMP, -- 最后匹配时间 - match_count INT DEFAULT 0, -- 匹配次数 - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - deleted_at TIMESTAMP DEFAULT NULL, - - CONSTRAINT fk_face_member FOREIGN KEY (member_id) REFERENCES member(id), - CONSTRAINT uk_face_member UNIQUE (member_id) -); - -CREATE INDEX idx_face_tenant ON member_face(tenant_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_face_status ON member_face(status) WHERE deleted_at IS NULL; -``` - -#### 2.2.3 签到设备表 (checkin_device) - -```sql -CREATE TABLE checkin_device ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - store_id BIGINT NOT NULL, - name VARCHAR(64) NOT NULL, -- 设备名称 - code VARCHAR(32) NOT NULL, -- 设备编码 - type SMALLINT NOT NULL, -- 1:人脸识别机 2:NFC读卡器 3:扫码枪 4:一体机 - sn VARCHAR(64), -- 序列号 - location VARCHAR(128), -- 安装位置 - ip_address VARCHAR(64), -- IP地址 - mac_address VARCHAR(32), -- MAC地址 - status SMALLINT DEFAULT 1, -- 1:在线 2:离线 3:维护中 - last_heartbeat TIMESTAMP, -- 最后心跳时间 - config JSONB, -- 设备配置 - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - deleted_at TIMESTAMP DEFAULT NULL, - - CONSTRAINT fk_device_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id), - CONSTRAINT fk_device_store FOREIGN KEY (store_id) REFERENCES store(id), - CONSTRAINT uk_device_code UNIQUE (tenant_id, code) -); - -CREATE INDEX idx_device_tenant ON checkin_device(tenant_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_device_store ON checkin_device(store_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_device_status ON checkin_device(status) WHERE deleted_at IS NULL; -``` - -#### 2.2.4 签到统计表 (checkin_statistics) - -```sql -CREATE TABLE checkin_statistics ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - store_id BIGINT NOT NULL, - stat_date DATE NOT NULL, -- 统计日期 - stat_type SMALLINT NOT NULL, -- 1:日统计 2:周统计 3:月统计 - total_count INT DEFAULT 0, -- 总签到次数 - entry_count INT DEFAULT 0, -- 入场签到次数 - course_count INT DEFAULT 0, -- 课程签到次数 - private_count INT DEFAULT 0, -- 私教签到次数 - activity_count INT DEFAULT 0, -- 活动签到次数 - new_member_count INT DEFAULT 0, -- 新会员签到数 - active_member_count INT DEFAULT 0, -- 活跃会员数 - peak_hour SMALLINT, -- 高峰时段 - peak_count INT, -- 高峰人数 - avg_duration INT, -- 平均停留时长(分钟) - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - deleted_at TIMESTAMP DEFAULT NULL, - - CONSTRAINT uk_stat_date UNIQUE (tenant_id, store_id, stat_date, stat_type) -); - -CREATE INDEX idx_stat_tenant ON checkin_statistics(tenant_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_stat_store ON checkin_statistics(store_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_stat_date ON checkin_statistics(stat_date) WHERE deleted_at IS NULL; -``` - -#### 2.2.5 签到规则表 (checkin_rule) - -```sql -CREATE TABLE checkin_rule ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - store_id BIGINT, -- NULL表示全局规则 - rule_type SMALLINT NOT NULL, -- 1:入场规则 2:课程规则 3:私教规则 - name VARCHAR(64) NOT NULL, -- 规则名称 - description VARCHAR(256), -- 规则描述 - time_before INT DEFAULT 30, -- 提前签到时间(分钟) - time_after INT DEFAULT 15, -- 迟到允许时间(分钟) - late_penalty DECIMAL(3,2) DEFAULT 0.00, -- 迟到扣款比例 - absent_penalty DECIMAL(3,2) DEFAULT 1.00, -- 缺席扣款比例 - allow_late BOOLEAN DEFAULT TRUE, -- 是否允许迟到签到 - allow_absent BOOLEAN DEFAULT FALSE, -- 是否允许缺席 - max_daily_entry INT DEFAULT 1, -- 每日最大入场次数 - interval_minutes INT DEFAULT 0, -- 签到间隔(分钟) - status SMALLINT DEFAULT 1, -- 1:启用 2:禁用 - priority INT DEFAULT 0, -- 优先级 - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - created_by BIGINT, - updated_by BIGINT, - deleted_at TIMESTAMP DEFAULT NULL, - - CONSTRAINT fk_rule_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id) -); - -CREATE INDEX idx_rule_tenant ON checkin_rule(tenant_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_rule_store ON checkin_rule(store_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_rule_type ON checkin_rule(rule_type) WHERE deleted_at IS NULL; -``` - ---- - -## 三、领域模型设计 - -### 3.1 聚合设计 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 签到聚合设计 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌───────────────────────────────────────────────────────────────────┐ │ -│ │ CheckinRecord (聚合根) │ │ -│ ├───────────────────────────────────────────────────────────────────┤ │ -│ │ - id: Long │ │ -│ │ - tenantId: Long │ │ -│ │ - storeId: Long │ │ -│ │ - memberId: Long │ │ -│ │ - bookingId: Long? │ │ -│ │ - type: CheckinType │ │ -│ │ - method: CheckinMethod │ │ -│ │ - device: DeviceInfo? │ │ -│ │ - operator: OperatorInfo? │ │ -│ │ - status: CheckinStatus │ │ -│ │ - checkinAt: LocalDateTime │ │ -│ │ - benefit: BenefitDeduction? │ │ -│ │ │ │ -│ │ 行为: │ │ -│ │ + checkin(): void │ │ -│ │ + cancel(reason: String): void │ │ -│ │ + isLate(): Boolean │ │ -│ │ + getDuration(): Duration │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌───────────────────────┐ ┌───────────────────────┐ │ -│ │ CheckinGateway │ │ CheckinValidator │ │ -│ │ (签到网关) │ │ (签到验证器) │ │ -│ ├───────────────────────┤ ├───────────────────────┤ │ -│ │ + processQRCode() │ │ + validateMember() │ │ -│ │ + processFace() │ │ + validateBooking() │ │ -│ │ + processNFC() │ │ + validateBenefit() │ │ -│ │ + processManual() │ │ + validateRule() │ │ -│ └───────────────────────┘ └───────────────────────┘ │ -│ │ -│ ┌───────────────────────┐ ┌───────────────────────┐ │ -│ │ CheckinStatistics │ │ FaceRecognition │ │ -│ │ (签到统计) │ │ (人脸识别) │ │ -│ ├───────────────────────┤ ├───────────────────────┤ │ -│ │ + dailyStats() │ │ + register() │ │ -│ │ + weeklyStats() │ │ + match() │ │ -│ │ + monthlyStats() │ │ + update() │ │ -│ │ + memberStats() │ │ + delete() │ │ -│ └───────────────────────┘ └───────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 3.2 值对象设计 - -```java -public enum CheckinType { - ENTRY(1, "入场签到"), - COURSE(2, "课程签到"), - PRIVATE(3, "私教签到"), - ACTIVITY(4, "活动签到"); - - private final int code; - private final String desc; -} - -public enum CheckinMethod { - QRCODE(1, "二维码"), - FACE(2, "人脸识别"), - NFC(3, "NFC"), - MANUAL(4, "教练代签"); - - private final int code; - private final String desc; -} - -public enum CheckinStatus { - SUCCESS(1, "成功"), - FAILED(2, "失败"), - CANCELLED(3, "已取消"); - - private final int code; - private final String desc; -} - -public record DeviceInfo( - Long deviceId, - String deviceName, - String location -) {} - -public record OperatorInfo( - Long operatorId, - String operatorName, - String operatorRole -) {} - -public record BenefitDeduction( - Long benefitId, - Integer benefitType, - BigDecimal benefitValue -) {} - -public record CheckinResult( - boolean success, - String message, - CheckinRecord record, - List warnings -) {} -``` - -### 3.3 领域服务设计 - -```java -public interface CheckinDomainService { - - Mono processCheckin(CheckinRequest request); - - Mono cancelCheckin(Long checkinId, String reason); - - Mono validateCheckinEligibility(Long memberId, CheckinType type, Long bookingId); - - Mono getCheckinRecord(Long checkinId); - - Flux getMemberCheckinHistory(Long memberId, LocalDate startDate, LocalDate endDate); -} - -public interface FaceRecognitionService { - - Mono registerFace(Long memberId, byte[] faceImage); - - Mono matchFace(byte[] faceFeature, Long tenantId); - - Mono updateFace(Long memberId, byte[] faceImage); - - Mono deleteFace(Long memberId); -} - -public interface CheckinStatisticsService { - - Mono generateDailyStatistics(Long tenantId, Long storeId, LocalDate date); - - Mono getDailyStatistics(Long tenantId, Long storeId, LocalDate date); - - Mono> getMemberCheckinStats(Long memberId, LocalDate startDate, LocalDate endDate); -} -``` - ---- - -## 四、业务流程设计 - -### 4.1 入场签到流程 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 入场签到流程 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ │ -│ │ 会员 │ │ 签到 │ │ 签到 │ │ 权益 │ │ 签到 │ │ -│ │ │ │ 网关 │ │ 验证 │ │ 服务 │ │ 记录 │ │ -│ └───┬───┘ └───┬───┘ └───┬───┘ └───┬───┘ └───┬───┘ │ -│ │ │ │ │ │ │ -│ │ 出示二维码 │ │ │ │ │ -│ │────────────▶│ │ │ │ │ -│ │ │ │ │ │ │ -│ │ │ 解析二维码 │ │ │ │ -│ │ │────────────▶│ │ │ │ -│ │ │ │ │ │ │ -│ │ │ │ 查询会员 │ │ │ -│ │ │ │────────────▶│ │ │ -│ │ │ │ │ │ │ -│ │ │ │ 会员信息 │ │ │ -│ │ │ │◀────────────│ │ │ -│ │ │ │ │ │ │ -│ │ │ │ 验证会员卡 │ │ │ -│ │ │ │────────────▶│ │ │ -│ │ │ │ │ │ │ -│ │ │ │ 权益状态 │ │ │ -│ │ │ │◀────────────│ │ │ -│ │ │ │ │ │ │ -│ │ │ │ 检查签到规则│ │ │ -│ │ │ │─────────────┼────────────▶│ │ -│ │ │ │ │ │ │ -│ │ │ │ 规则验证结果│ │ │ -│ │ │ │◀────────────┼─────────────│ │ -│ │ │ │ │ │ │ -│ │ │ 验证结果 │ │ │ │ -│ │ │◀────────────│ │ │ │ -│ │ │ │ │ │ │ -│ │ │ 创建签到记录│ │ │ │ -│ │ │─────────────┼─────────────┼────────────▶│ │ -│ │ │ │ │ │ │ -│ │ │ 签到成功 │ │ │ │ -│ │ │◀────────────┼─────────────┼─────────────│ │ -│ │ │ │ │ │ │ -│ │ 签到成功 │ │ │ │ │ -│ │◀────────────│ │ │ │ │ -│ │ │ │ │ │ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 4.2 课程签到流程 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 课程签到流程 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ │ -│ │ 会员 │ │ 签到 │ │ 预约 │ │ 权益 │ │ 签到 │ │ -│ │ │ │ 网关 │ │ 服务 │ │ 服务 │ │ 记录 │ │ -│ └───┬───┘ └───┬───┘ └───┬───┘ └───┬───┘ └───┬───┘ │ -│ │ │ │ │ │ │ -│ │ 扫码签到 │ │ │ │ │ -│ │────────────▶│ │ │ │ │ -│ │ │ │ │ │ │ -│ │ │ 查询预约 │ │ │ │ -│ │ │────────────▶│ │ │ │ -│ │ │ │ │ │ │ -│ │ │ 预约信息 │ │ │ │ -│ │ │◀────────────│ │ │ │ -│ │ │ │ │ │ │ -│ │ │ 验证签到时间窗口 │ │ │ -│ │ │─────────────┼────────────▶│ │ │ -│ │ │ │ │ │ │ -│ │ │ 时间窗口验证结果 │ │ │ -│ │ │◀────────────┼─────────────│ │ │ -│ │ │ │ │ │ │ -│ │ │ 验证权益 │ │ │ │ -│ │ │─────────────┼────────────▶│ │ │ -│ │ │ │ │ │ │ -│ │ │ 权益状态 │ │ │ │ -│ │ │◀────────────┼─────────────│ │ │ -│ │ │ │ │ │ │ -│ │ │ 扣减权益 │ │ │ │ -│ │ │─────────────┼────────────▶│ │ │ -│ │ │ │ │ │ │ -│ │ │ 扣减结果 │ │ │ │ -│ │ │◀────────────┼─────────────│ │ │ -│ │ │ │ │ │ │ -│ │ │ 创建签到记录│ │ │ │ -│ │ │─────────────┼─────────────┼────────────▶│ │ -│ │ │ │ │ │ │ -│ │ │ 更新预约签到状态 │ │ │ -│ │ │────────────▶│ │ │ │ -│ │ │ │ │ │ │ -│ │ 签到成功 │ │ │ │ │ -│ │◀────────────│ │ │ │ │ -│ │ │ │ │ │ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 4.3 人脸识别签到流程 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 人脸识别签到流程 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ │ -│ │ 会员 │ │ 人脸 │ │ 人脸 │ │ 签到 │ │ 签到 │ │ -│ │ │ │ 设备 │ │ 服务 │ │ 验证 │ │ 记录 │ │ -│ └───┬───┘ └───┬───┘ └───┬───┘ └───┬───┘ └───┬───┘ │ -│ │ │ │ │ │ │ -│ │ 人脸识别 │ │ │ │ │ -│ │────────────▶│ │ │ │ │ -│ │ │ │ │ │ │ -│ │ │ 提取特征值 │ │ │ │ -│ │ │────────────▶│ │ │ │ -│ │ │ │ │ │ │ -│ │ │ │ 匹配会员 │ │ │ -│ │ │ │─────────────┼────────────▶│ │ -│ │ │ │ │ │ │ -│ │ │ │ 匹配结果 │ │ │ -│ │ │ │◀────────────┼─────────────│ │ -│ │ │ │ │ │ │ -│ │ │ 会员ID │ │ │ │ -│ │ │◀────────────│ │ │ │ -│ │ │ │ │ │ │ -│ │ │ 执行签到流程│ │ │ │ -│ │ │─────────────┼────────────▶│ │ │ -│ │ │ │ │ │ │ -│ │ │ 签到结果 │ │ │ │ -│ │ │◀────────────┼─────────────┼─────────────│ │ -│ │ │ │ │ │ │ -│ │ 签到成功 │ │ │ │ │ -│ │◀────────────│ │ │ │ │ -│ │ │ │ │ │ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 4.4 教练代签流程 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 教练代签流程 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ │ -│ │ 教练 │ │ 签到 │ │ 预约 │ │ 权益 │ │ 签到 │ │ -│ │ │ │ 网关 │ │ 服务 │ │ 服务 │ │ 记录 │ │ -│ └───┬───┘ └───┬───┘ └───┬───┘ └───┬───┘ └───┬───┘ │ -│ │ │ │ │ │ │ -│ │ 选择会员 │ │ │ │ │ -│ │────────────▶│ │ │ │ │ -│ │ │ │ │ │ │ -│ │ │ 验证教练身份│ │ │ │ -│ │ │─────────────┼─────────────┼────────────▶│ │ -│ │ │ │ │ │ │ -│ │ │ 身份验证结果│ │ │ │ -│ │ │◀────────────┼─────────────┼─────────────│ │ -│ │ │ │ │ │ │ -│ │ │ 查询会员预约│ │ │ │ -│ │ │────────────▶│ │ │ │ -│ │ │ │ │ │ │ -│ │ │ 预约列表 │ │ │ │ -│ │ │◀────────────│ │ │ │ -│ │ │ │ │ │ │ -│ │ 选择预约 │ │ │ │ │ -│ │────────────▶│ │ │ │ │ -│ │ │ │ │ │ │ -│ │ │ 验证签到资格│ │ │ │ -│ │ │─────────────┼────────────▶│ │ │ -│ │ │ │ │ │ │ -│ │ │ 扣减权益 │ │ │ │ -│ │ │─────────────┼────────────▶│ │ │ -│ │ │ │ │ │ │ -│ │ │ 创建签到记录│ │ │ │ -│ │ │─────────────┼─────────────┼────────────▶│ │ -│ │ │ │ │ │ │ -│ │ 代签成功 │ │ │ │ │ -│ │◀────────────│ │ │ │ │ -│ │ │ │ │ │ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 五、接口设计 - -### 5.1 签到网关接口 - -#### 5.1.1 二维码签到 - -``` -POST /api/v1/checkin/qrcode -Content-Type: application/json - -Request: -{ - "tenantId": 1, - "storeId": 1, - "qrcode": "MEMBER_123456_TIMESTAMP", - "deviceId": 1, - "type": 1, // 1:入场 2:课程 - "bookingId": null // 课程签到时必填 -} - -Response: -{ - "code": 0, - "message": "签到成功", - "data": { - "checkinId": 1001, - "memberId": 123456, - "memberName": "张三", - "memberPhone": "138****8888", - "memberLevel": "VIP", - "checkinType": "入场签到", - "checkinTime": "2026-02-28 10:30:00", - "benefitDeducted": { - "type": "时长权益", - "value": "年卡有效期至2026-12-31" - }, - "warnings": [] - } -} -``` - -#### 5.1.2 人脸识别签到 - -``` -POST /api/v1/checkin/face -Content-Type: application/json - -Request: -{ - "tenantId": 1, - "storeId": 1, - "faceFeature": "base64_encoded_feature", - "deviceId": 1, - "type": 1, - "bookingId": null -} - -Response: -{ - "code": 0, - "message": "签到成功", - "data": { - "checkinId": 1002, - "memberId": 123456, - "memberName": "张三", - "memberPhone": "138****8888", - "memberLevel": "VIP", - "checkinType": "入场签到", - "checkinTime": "2026-02-28 10:31:00", - "benefitDeducted": { - "type": "时长权益", - "value": "年卡有效期至2026-12-31" - }, - "warnings": [] - } -} -``` - -#### 5.1.3 NFC签到 - -``` -POST /api/v1/checkin/nfc -Content-Type: application/json - -Request: -{ - "tenantId": 1, - "storeId": 1, - "nfcId": "NFC_CARD_123456", - "deviceId": 1, - "type": 1, - "bookingId": null -} - -Response: -{ - "code": 0, - "message": "签到成功", - "data": { - "checkinId": 1003, - "memberId": 123456, - "memberName": "张三", - "memberPhone": "138****8888", - "memberLevel": "VIP", - "checkinType": "入场签到", - "checkinTime": "2026-02-28 10:32:00", - "benefitDeducted": { - "type": "时长权益", - "value": "年卡有效期至2026-12-31" - }, - "warnings": [] - } -} -``` - -#### 5.1.4 教练代签 - -``` -POST /api/v1/checkin/manual -Content-Type: application/json - -Request: -{ - "tenantId": 1, - "storeId": 1, - "memberId": 123456, - "bookingId": 2001, - "operatorId": 100, // 教练ID - "operatorName": "李教练", - "remark": "会员已到场" -} - -Response: -{ - "code": 0, - "message": "代签成功", - "data": { - "checkinId": 1004, - "memberId": 123456, - "memberName": "张三", - "checkinType": "私教签到", - "checkinTime": "2026-02-28 10:33:00", - "operatorName": "李教练" - } -} -``` - -### 5.2 人脸管理接口 - -#### 5.2.1 注册人脸 - -``` -POST /api/v1/face/register -Content-Type: multipart/form-data - -Request: -{ - "memberId": 123456, - "faceImage": -} - -Response: -{ - "code": 0, - "message": "人脸注册成功", - "data": { - "faceId": 1, - "qualityScore": 95.5, - "status": "正常" - } -} -``` - -#### 5.2.2 更新人脸 - -``` -PUT /api/v1/face/{memberId} -Content-Type: multipart/form-data - -Request: -{ - "faceImage": -} - -Response: -{ - "code": 0, - "message": "人脸更新成功", - "data": { - "faceId": 1, - "qualityScore": 96.2, - "status": "正常" - } -} -``` - -#### 5.2.3 删除人脸 - -``` -DELETE /api/v1/face/{memberId} - -Response: -{ - "code": 0, - "message": "人脸删除成功" -} -``` - -### 5.3 签到记录接口 - -#### 5.3.1 查询签到记录 - -``` -GET /api/v1/checkin/records?memberId=123456&startDate=2026-02-01&endDate=2026-02-28&page=1&size=20 - -Response: -{ - "code": 0, - "message": "success", - "data": { - "total": 25, - "list": [ - { - "checkinId": 1001, - "type": "入场签到", - "method": "二维码", - "checkinTime": "2026-02-28 10:30:00", - "storeName": "中关村店", - "status": "成功" - }, - { - "checkinId": 1002, - "type": "课程签到", - "method": "人脸识别", - "checkinTime": "2026-02-27 19:00:00", - "courseName": "瑜伽课", - "coachName": "王教练", - "status": "成功" - } - ] - } -} -``` - -#### 5.3.2 查询签到统计 - -``` -GET /api/v1/checkin/statistics?tenantId=1&storeId=1&startDate=2026-02-01&endDate=2026-02-28 - -Response: -{ - "code": 0, - "message": "success", - "data": { - "totalCount": 1500, - "entryCount": 800, - "courseCount": 500, - "privateCount": 150, - "activityCount": 50, - "activeMemberCount": 350, - "newMemberCount": 25, - "peakHour": 19, - "peakCount": 120, - "avgDuration": 90, - "dailyTrend": [ - {"date": "2026-02-01", "count": 50}, - {"date": "2026-02-02", "count": 55} - ] - } -} -``` - -### 5.4 设备管理接口 - -#### 5.4.1 设备心跳 - -``` -POST /api/v1/device/heartbeat -Content-Type: application/json - -Request: -{ - "deviceId": 1, - "deviceCode": "DEVICE_001", - "status": 1, - "timestamp": "2026-02-28T10:30:00" -} - -Response: -{ - "code": 0, - "message": "success" -} -``` - -#### 5.4.2 设备列表 - -``` -GET /api/v1/device/list?tenantId=1&storeId=1 - -Response: -{ - "code": 0, - "message": "success", - "data": [ - { - "deviceId": 1, - "name": "前台人脸机", - "code": "DEVICE_001", - "type": "人脸识别机", - "location": "前台入口", - "status": "在线", - "lastHeartbeat": "2026-02-28 10:30:00" - } - ] -} -``` - ---- - -## 六、核心代码设计 - -### 6.1 签到领域服务实现 - -```java -@Slf4j -@Service -@RequiredArgsConstructor -public class CheckinDomainServiceImpl implements CheckinDomainService { - - private final CheckinRecordRepository checkinRepository; - private final MemberRepository memberRepository; - private final BookingRecordRepository bookingRepository; - private final BenefitDomainService benefitService; - private final CheckinRuleRepository ruleRepository; - private final TransactionalOperator rxtx; - private final ApplicationEventPublisher eventPublisher; - - @Override - public Mono processCheckin(CheckinRequest request) { - return Mono.defer(() -> - validateMember(request.getTenantId(), request.getMemberId()) - .flatMap(member -> validateCheckinRule(member, request)) - .flatMap(member -> processCheckinByType(member, request)) - ).as(rxtx::transactional); - } - - private Mono validateMember(Long tenantId, Long memberId) { - return memberRepository.findByIdAndTenantId(memberId, tenantId) - .switchIfEmpty(Mono.error(new CheckinException(CheckinException.MEMBER_NOT_FOUND))) - .flatMap(member -> { - if (member.getStatus() != MemberStatus.ACTIVE) { - return Mono.error(new CheckinException(CheckinException.MEMBER_INACTIVE)); - } - return Mono.just(member); - }); - } - - private Mono validateCheckinRule(Member member, CheckinRequest request) { - return ruleRepository.findByTenantIdAndRuleType( - member.getTenantId(), - request.getType() - ) - .flatMap(rule -> { - if (request.getType() == CheckinType.ENTRY) { - return validateEntryRule(member, rule, request); - } - return Mono.just(member); - }) - .switchIfEmpty(Mono.just(member)); - } - - private Mono validateEntryRule(Member member, CheckinRule rule, CheckinRequest request) { - LocalDateTime todayStart = LocalDate.now().atStartOfDay(); - LocalDateTime todayEnd = todayStart.plusDays(1); - - return checkinRepository.countByMemberIdAndTypeAndCheckinAtBetween( - member.getId(), - CheckinType.ENTRY, - todayStart, - todayEnd - ).flatMap(count -> { - if (count >= rule.getMaxDailyEntry()) { - return Mono.error(new CheckinException( - CheckinException.DAILY_LIMIT_EXCEEDED, - "今日入场次数已达上限" - )); - } - - if (rule.getIntervalMinutes() > 0) { - return validateCheckinInterval(member, rule); - } - - return Mono.just(member); - }); - } - - private Mono validateCheckinInterval(Member member, CheckinRule rule) { - return checkinRepository.findFirstByMemberIdOrderByCheckinAtDesc(member.getId()) - .flatMap(lastCheckin -> { - long minutes = Duration.between( - lastCheckin.getCheckinAt(), - LocalDateTime.now() - ).toMinutes(); - - if (minutes < rule.getIntervalMinutes()) { - return Mono.error(new CheckinException( - CheckinException.INTERVAL_NOT_MET, - "签到间隔不足" + rule.getIntervalMinutes() + "分钟" - )); - } - return Mono.just(member); - }) - .switchIfEmpty(Mono.just(member)); - } - - private Mono processCheckinByType(Member member, CheckinRequest request) { - return switch (request.getType()) { - case ENTRY -> processEntryCheckin(member, request); - case COURSE -> processCourseCheckin(member, request); - case PRIVATE -> processPrivateCheckin(member, request); - case ACTIVITY -> processActivityCheckin(member, request); - }; - } - - private Mono processEntryCheckin(Member member, CheckinRequest request) { - return benefitService.validateAndDeduct( - member.getId(), - BenefitType.TIME, - null, - "入场签到" - ).flatMap(benefitDeduction -> { - CheckinRecord record = buildCheckinRecord(member, request, benefitDeduction); - record.setType(CheckinType.ENTRY); - - return checkinRepository.save(record) - .doOnNext(saved -> eventPublisher.publishEvent( - new CheckinSuccessEvent(saved) - )) - .map(saved -> CheckinResult.success(saved)); - }).onErrorResume(e -> { - if (e instanceof BenefitException) { - return Mono.just(CheckinResult.failure("权益不足,请充值或续费")); - } - return Mono.error(e); - }); - } - - private Mono processCourseCheckin(Member member, CheckinRequest request) { - return bookingRepository.findById(request.getBookingId()) - .switchIfEmpty(Mono.error(new CheckinException(CheckinException.BOOKING_NOT_FOUND))) - .flatMap(booking -> validateBookingForCheckin(booking, member)) - .flatMap(booking -> { - LocalDateTime now = LocalDateTime.now(); - LocalDateTime courseStart = booking.getSlot().getStartTime(); - long minutesBefore = Duration.between(now, courseStart).toMinutes(); - - CheckinRecord record = buildCheckinRecord(member, request, null); - record.setType(CheckinType.COURSE); - record.setBookingId(booking.getId()); - - if (minutesBefore < 0) { - record.setLate(true); - record.setLateMinutes((int) Math.abs(minutesBefore)); - } - - return checkinRepository.save(record) - .flatMap(saved -> updateBookingCheckinStatus(booking, saved)) - .doOnNext(saved -> eventPublisher.publishEvent( - new CheckinSuccessEvent(saved) - )) - .map(saved -> CheckinResult.success(saved)); - }); - } - - private Mono validateBookingForCheckin(BookingRecord booking, Member member) { - if (!booking.getMemberId().equals(member.getId())) { - return Mono.error(new CheckinException(CheckinException.BOOKING_NOT_MATCH)); - } - - if (booking.getStatus() != BookingStatus.CONFIRMED) { - return Mono.error(new CheckinException(CheckinException.BOOKING_NOT_CONFIRMED)); - } - - if (booking.getCheckinStatus() == CheckinStatus.CHECKED) { - return Mono.error(new CheckinException(CheckinException.ALREADY_CHECKED)); - } - - LocalDateTime now = LocalDateTime.now(); - LocalDateTime courseStart = booking.getSlot().getStartTime(); - LocalDateTime courseEnd = booking.getSlot().getEndTime(); - - if (now.isAfter(courseEnd)) { - return Mono.error(new CheckinException(CheckinException.COURSE_ENDED)); - } - - return Mono.just(booking); - } - - private Mono updateBookingCheckinStatus(BookingRecord booking, CheckinRecord checkin) { - booking.setCheckinStatus(checkin.isLate() ? CheckinStatus.LATE : CheckinStatus.CHECKED); - booking.setCheckinAt(checkin.getCheckinAt()); - booking.setCheckinBy(checkin.getOperatorId()); - - return bookingRepository.save(booking).thenReturn(checkin); - } - - private CheckinRecord buildCheckinRecord(Member member, CheckinRequest request, - BenefitDeduction deduction) { - CheckinRecord record = new CheckinRecord(); - record.setTenantId(member.getTenantId()); - record.setStoreId(request.getStoreId()); - record.setMemberId(member.getId()); - record.setMethod(request.getMethod()); - record.setDeviceId(request.getDeviceId()); - record.setOperatorId(request.getOperatorId()); - record.setOperatorName(request.getOperatorName()); - record.setStatus(CheckinStatus.SUCCESS); - record.setCheckinAt(LocalDateTime.now()); - record.setCheckinDate(LocalDate.now()); - - if (deduction != null) { - record.setBenefitId(deduction.benefitId()); - record.setBenefitType(deduction.benefitType()); - record.setBenefitValue(deduction.benefitValue()); - } - - return record; - } - - @Override - public Mono cancelCheckin(Long checkinId, String reason) { - return checkinRepository.findById(checkinId) - .switchIfEmpty(Mono.error(new CheckinException(CheckinException.CHECKIN_NOT_FOUND))) - .flatMap(record -> { - if (record.getStatus() == CheckinStatus.CANCELLED) { - return Mono.error(new CheckinException(CheckinException.ALREADY_CANCELLED)); - } - - record.setStatus(CheckinStatus.CANCELLED); - record.setFailReason(reason); - - return checkinRepository.save(record) - .flatMap(saved -> { - if (saved.getBenefitId() != null) { - return benefitService.refund( - saved.getBenefitId(), - saved.getBenefitValue(), - "取消签到退款" - ); - } - return Mono.empty(); - }); - }) - .then(); - } - - @Override - public Mono validateCheckinEligibility(Long memberId, CheckinType type, Long bookingId) { - return memberRepository.findById(memberId) - .flatMap(member -> { - if (member.getStatus() != MemberStatus.ACTIVE) { - return Mono.just(false); - } - - if (type == CheckinType.ENTRY) { - return benefitService.hasValidBenefit(memberId, BenefitType.TIME); - } - - if (bookingId != null) { - return bookingRepository.findById(bookingId) - .map(booking -> booking.getStatus() == BookingStatus.CONFIRMED - && booking.getCheckinStatus() == CheckinStatus.NOT_CHECKED); - } - - return Mono.just(true); - }) - .switchIfEmpty(Mono.just(false)); - } -} -``` - -### 6.2 人脸识别服务实现 - -```java -@Slf4j -@Service -@RequiredArgsConstructor -public class FaceRecognitionServiceImpl implements FaceRecognitionService { - - private final MemberFaceRepository faceRepository; - private final MemberRepository memberRepository; - private final FaceFeatureExtractor featureExtractor; - private final Cache faceFeatureCache; - private final TransactionalOperator rxtx; - - private static final float MATCH_THRESHOLD = 0.85f; - private static final float QUALITY_THRESHOLD = 60.0f; - - @Override - public Mono registerFace(Long memberId, byte[] faceImage) { - return Mono.fromCallable(() -> featureExtractor.extractFeature(faceImage)) - .subscribeOn(Schedulers.boundedElastic()) - .flatMap(featureResult -> { - if (featureResult.qualityScore() < QUALITY_THRESHOLD) { - return Mono.error(new FaceException( - FaceException.QUALITY_TOO_LOW, - "人脸质量分数过低: " + featureResult.qualityScore() - )); - } - - return faceRepository.existsByMemberId(memberId) - .flatMap(exists -> { - if (exists) { - return Mono.error(new FaceException( - FaceException.FACE_ALREADY_REGISTERED - )); - } - - MemberFace face = new MemberFace(); - face.setMemberId(memberId); - face.setFaceFeature(featureResult.feature()); - face.setQualityScore(featureResult.qualityScore()); - face.setFeatureVersion("v1.0"); - face.setStatus(FaceStatus.ACTIVE); - - return faceRepository.save(face) - .doOnNext(saved -> faceFeatureCache.put(memberId, saved.getFaceFeature())) - .thenReturn(true); - }); - }) - .as(rxtx::transactional); - } - - @Override - public Mono matchFace(byte[] faceFeature, Long tenantId) { - return Mono.fromCallable(() -> { - List faces = faceRepository.findAllByTenantIdAndStatus( - tenantId, - FaceStatus.ACTIVE - ); - - float maxSimilarity = 0; - Long matchedMemberId = null; - - for (MemberFace face : faces) { - byte[] cachedFeature = faceFeatureCache.getIfPresent(face.getMemberId()); - byte[] targetFeature = cachedFeature != null ? cachedFeature : face.getFaceFeature(); - - float similarity = featureExtractor.compareFeature(faceFeature, targetFeature); - - if (similarity > maxSimilarity && similarity >= MATCH_THRESHOLD) { - maxSimilarity = similarity; - matchedMemberId = face.getMemberId(); - } - } - - return matchedMemberId; - }) - .subscribeOn(Schedulers.boundedElastic()) - .flatMap(memberId -> { - if (memberId == null) { - return Mono.error(new FaceException(FaceException.FACE_NOT_MATCHED)); - } - - return faceRepository.updateMatchInfo(memberId, LocalDateTime.now()) - .thenReturn(memberId); - }); - } - - @Override - public Mono updateFace(Long memberId, byte[] faceImage) { - return Mono.fromCallable(() -> featureExtractor.extractFeature(faceImage)) - .subscribeOn(Schedulers.boundedElastic()) - .flatMap(featureResult -> { - if (featureResult.qualityScore() < QUALITY_THRESHOLD) { - return Mono.error(new FaceException( - FaceException.QUALITY_TOO_LOW, - "人脸质量分数过低: " + featureResult.qualityScore() - )); - } - - return faceRepository.findByMemberId(memberId) - .switchIfEmpty(Mono.error(new FaceException(FaceException.FACE_NOT_FOUND))) - .flatMap(face -> { - face.setFaceFeature(featureResult.feature()); - face.setQualityScore(featureResult.qualityScore()); - face.setStatus(FaceStatus.ACTIVE); - - return faceRepository.save(face) - .doOnNext(saved -> faceFeatureCache.put(memberId, saved.getFaceFeature())) - .thenReturn(true); - }); - }) - .as(rxtx::transactional); - } - - @Override - public Mono deleteFace(Long memberId) { - return faceRepository.deleteByMemberId(memberId) - .doOnSuccess(v -> faceFeatureCache.invalidate(memberId)) - .then(); - } -} -``` - -### 6.3 签到网关实现 - -```java -@Slf4j -@Service -@RequiredArgsConstructor -public class CheckinGateway { - - private final CheckinDomainService checkinService; - private final MemberRepository memberRepository; - private final QRCodeValidator qrCodeValidator; - private final NFCService nfcService; - - public Mono processQRCode(CheckinQRCodeRequest request) { - return Mono.defer(() -> { - QRCodeInfo qrInfo = qrCodeValidator.parseAndValidate(request.getQrcode()); - - if (qrInfo.isExpired()) { - return Mono.just(CheckinResult.failure("二维码已过期,请刷新")); - } - - CheckinRequest checkinRequest = new CheckinRequest(); - checkinRequest.setTenantId(request.getTenantId()); - checkinRequest.setStoreId(request.getStoreId()); - checkinRequest.setMemberId(qrInfo.getMemberId()); - checkinRequest.setMethod(CheckinMethod.QRCODE); - checkinRequest.setDeviceId(request.getDeviceId()); - checkinRequest.setType(CheckinType.fromCode(request.getType())); - checkinRequest.setBookingId(request.getBookingId()); - - return checkinService.processCheckin(checkinRequest); - }); - } - - public Mono processNFC(CheckinNFCRequest request) { - return nfcService.getMemberByNFC(request.getNfcId()) - .flatMap(member -> { - CheckinRequest checkinRequest = new CheckinRequest(); - checkinRequest.setTenantId(request.getTenantId()); - checkinRequest.setStoreId(request.getStoreId()); - checkinRequest.setMemberId(member.getId()); - checkinRequest.setMethod(CheckinMethod.NFC); - checkinRequest.setDeviceId(request.getDeviceId()); - checkinRequest.setType(CheckinType.fromCode(request.getType())); - checkinRequest.setBookingId(request.getBookingId()); - - return checkinService.processCheckin(checkinRequest); - }) - .onErrorResume(e -> { - if (e instanceof NFCException) { - return Mono.just(CheckinResult.failure("NFC卡未绑定会员")); - } - return Mono.error(e); - }); - } - - public Mono processManual(CheckinManualRequest request) { - return Mono.defer(() -> { - CheckinRequest checkinRequest = new CheckinRequest(); - checkinRequest.setTenantId(request.getTenantId()); - checkinRequest.setStoreId(request.getStoreId()); - checkinRequest.setMemberId(request.getMemberId()); - checkinRequest.setMethod(CheckinMethod.MANUAL); - checkinRequest.setType(CheckinType.PRIVATE); - checkinRequest.setBookingId(request.getBookingId()); - checkinRequest.setOperatorId(request.getOperatorId()); - checkinRequest.setOperatorName(request.getOperatorName()); - - return checkinService.processCheckin(checkinRequest); - }); - } -} -``` - -### 6.4 签到统计服务实现 - -```java -@Slf4j -@Service -@RequiredArgsConstructor -public class CheckinStatisticsServiceImpl implements CheckinStatisticsService { - - private final CheckinRecordRepository checkinRepository; - private final CheckinStatisticsRepository statisticsRepository; - private final MemberRepository memberRepository; - - @Override - public Mono generateDailyStatistics(Long tenantId, Long storeId, LocalDate date) { - return Mono.defer(() -> { - LocalDateTime startOfDay = date.atStartOfDay(); - LocalDateTime endOfDay = startOfDay.plusDays(1); - - Mono totalCount = checkinRepository.countByTenantIdAndStoreIdAndCheckinAtBetween( - tenantId, storeId, startOfDay, endOfDay - ); - - Mono entryCount = checkinRepository.countByTenantIdAndStoreIdAndTypeAndCheckinAtBetween( - tenantId, storeId, CheckinType.ENTRY, startOfDay, endOfDay - ); - - Mono courseCount = checkinRepository.countByTenantIdAndStoreIdAndTypeAndCheckinAtBetween( - tenantId, storeId, CheckinType.COURSE, startOfDay, endOfDay - ); - - Mono privateCount = checkinRepository.countByTenantIdAndStoreIdAndTypeAndCheckinAtBetween( - tenantId, storeId, CheckinType.PRIVATE, startOfDay, endOfDay - ); - - Mono activityCount = checkinRepository.countByTenantIdAndStoreIdAndTypeAndCheckinAtBetween( - tenantId, storeId, CheckinType.ACTIVITY, startOfDay, endOfDay - ); - - Mono activeMemberCount = checkinRepository.countDistinctMemberByTenantIdAndStoreIdAndCheckinAtBetween( - tenantId, storeId, startOfDay, endOfDay - ); - - Mono> hourlyDistribution = checkinRepository - .findHourlyDistribution(tenantId, storeId, startOfDay, endOfDay) - .collectMap(CheckinHourlyStats::getHour, CheckinHourlyStats::getCount); - - return Mono.zip(totalCount, entryCount, courseCount, privateCount, - activityCount, activeMemberCount, hourlyDistribution) - .flatMap(tuple -> { - CheckinStatistics stats = new CheckinStatistics(); - stats.setTenantId(tenantId); - stats.setStoreId(storeId); - stats.setStatDate(date); - stats.setStatType(StatType.DAILY); - stats.setTotalCount(tuple.getT1().intValue()); - stats.setEntryCount(tuple.getT2().intValue()); - stats.setCourseCount(tuple.getT3().intValue()); - stats.setPrivateCount(tuple.getT4().intValue()); - stats.setActivityCount(tuple.getT5().intValue()); - stats.setActiveMemberCount(tuple.getT6()); - - Map hourly = tuple.getT7(); - if (!hourly.isEmpty()) { - stats.setPeakHour( - hourly.entrySet().stream() - .max(Map.Entry.comparingByValue()) - .map(Map.Entry::getKey) - .orElse(null) - ); - stats.setPeakCount( - hourly.values().stream() - .max(Long::compare) - .map(Long::intValue) - .orElse(0) - ); - } - - return statisticsRepository.save(stats); - }) - .then(); - }); - } - - @Override - public Mono getDailyStatistics(Long tenantId, Long storeId, LocalDate date) { - return statisticsRepository.findByTenantIdAndStoreIdAndStatDateAndStatType( - tenantId, storeId, date, StatType.DAILY - ); - } - - @Override - public Mono> getMemberCheckinStats(Long memberId, - LocalDate startDate, LocalDate endDate) { - LocalDateTime start = startDate.atStartOfDay(); - LocalDateTime end = endDate.plusDays(1).atStartOfDay(); - - Mono totalCount = checkinRepository.countByMemberIdAndCheckinAtBetween( - memberId, start, end - ); - - Mono> typeDistribution = checkinRepository - .countByMemberIdGroupByType(memberId, start, end) - .collectMap(CheckinTypeStats::getType, CheckinTypeStats::getCount); - - Mono> checkinDates = checkinRepository - .findDistinctCheckinDatesByMemberId(memberId, start, end) - .collectList(); - - return Mono.zip(totalCount, typeDistribution, checkinDates) - .map(tuple -> { - Map result = new HashMap<>(); - result.put("totalCount", tuple.getT1()); - result.put("typeDistribution", tuple.getT2()); - result.put("checkinDays", tuple.getT3().size()); - result.put("checkinDates", tuple.getT3()); - return result; - }); - } -} -``` - ---- - -## 七、高并发处理 - -### 7.1 签到并发场景分析 - -| 场景 | 并发特点 | 处理策略 | -| ------------ | -------------------- | ----------------- | -| 早高峰入场 | 短时间内大量签到请求 | 本地缓存+异步处理 | -| 课程签到窗口 | 集中签到时段 | 预加载+限流 | -| 人脸识别匹配 | 计算密集型 | 特征缓存+批量匹配 | -| 统计计算 | 数据量大 | 异步任务+增量计算 | - -### 7.2 签到限流设计 - -```java -@Slf4j -@Component -@RequiredArgsConstructor -public class CheckinRateLimiter { - - private final Cache rateLimitCache = Caffeine.newBuilder() - .expireAfterWrite(Duration.ofSeconds(1)) - .build(); - - private static final int MAX_REQUESTS_PER_SECOND = 100; - - public Mono allowRequest(Long tenantId, Long storeId) { - String key = tenantId + ":" + storeId + ":" + System.currentTimeMillis() / 1000; - - return Mono.fromCallable(() -> { - AtomicInteger counter = rateLimitCache.get(key, k -> new AtomicInteger(0)); - return counter.incrementAndGet() <= MAX_REQUESTS_PER_SECOND; - }); - } - - public Mono withRateLimit(Long tenantId, Long storeId, Mono action) { - return allowRequest(tenantId, storeId) - .flatMap(allowed -> { - if (allowed) { - return action; - } - return Mono.error(new CheckinException( - CheckinException.RATE_LIMIT_EXCEEDED, - "签到请求过于频繁,请稍后重试" - )); - }); - } -} -``` - -### 7.3 人脸特征缓存设计 - -```java -@Slf4j -@Component -public class FaceFeatureCacheManager { - - private final Cache featureCache = Caffeine.newBuilder() - .maximumSize(10000) - .expireAfterAccess(Duration.ofHours(24)) - .recordStats() - .build(); - - private final MemberFaceRepository faceRepository; - - @Scheduled(fixedRate = 300000) - public void preloadFeatures() { - log.info("开始预加载人脸特征..."); - - faceRepository.findAllByStatus(FaceStatus.ACTIVE) - .doOnNext(face -> featureCache.put(face.getMemberId(), face.getFaceFeature())) - .then() - .subscribe( - v -> log.info("人脸特征预加载完成"), - e -> log.error("人脸特征预加载失败", e) - ); - } - - public Optional getFeature(Long memberId) { - return Optional.ofNullable(featureCache.getIfPresent(memberId)); - } - - public void putFeature(Long memberId, byte[] feature) { - featureCache.put(memberId, feature); - } - - public void invalidate(Long memberId) { - featureCache.invalidate(memberId); - } - - public CacheStats getStats() { - return featureCache.stats(); - } -} -``` - -### 7.4 异步签到处理 - -```java -@Slf4j -@Service -@RequiredArgsConstructor -public class AsyncCheckinProcessor { - - private final CheckinRecordRepository checkinRepository; - private final ApplicationEventPublisher eventPublisher; - private final Sinks.Many checkinSink; - - @PostConstruct - public void init() { - checkinSink.asFlux() - .flatMap(this::processAsync, 10) - .subscribe( - v -> {}, - e -> log.error("异步签到处理错误", e) - ); - } - - public Mono submitAsync(CheckinTask task) { - return Mono.fromCallable(() -> { - checkinSink.tryEmitNext(task); - return task.getTaskId(); - }); - } - - private Mono processAsync(CheckinTask task) { - return processCheckin(task) - .flatMap(record -> { - eventPublisher.publishEvent(new CheckinSuccessEvent(record)); - return Mono.empty(); - }) - .onErrorResume(e -> { - log.error("异步签到处理失败: taskId={}", task.getTaskId(), e); - return saveFailedRecord(task, e); - }) - .then(); - } - - private Mono processCheckin(CheckinTask task) { - // 签到处理逻辑 - } - - private Mono saveFailedRecord(CheckinTask task, Throwable e) { - CheckinRecord record = new CheckinRecord(); - record.setStatus(CheckinStatus.FAILED); - record.setFailReason(e.getMessage()); - // 设置其他字段... - - return checkinRepository.save(record).then(); - } -} -``` - ---- - -## 八、缓存设计 - -### 8.1 缓存策略 - -| 数据类型 | 缓存位置 | 过期时间 | 更新策略 | -| -------- | -------- | -------- | -------- | -| 会员信息 | 本地缓存 | 30分钟 | 写时更新 | -| 人脸特征 | 本地缓存 | 24小时 | 定时刷新 | -| 签到规则 | 本地缓存 | 1小时 | 写时更新 | -| 签到统计 | 本地缓存 | 5分钟 | 定时计算 | -| 设备状态 | 本地缓存 | 1分钟 | 心跳更新 | - -### 8.2 缓存配置 - -````java -@Configuration -public class CheckinCacheConfig { - - @Bean - public Cache memberCache() { - return Caffeine.newBuilder() - .maximumSize(5000) - .expireAfterWrite(Duration.ofMinutes(30)) - .recordStats() - .build(); - } - - @Bean - public Cache faceFeatureCache() { - return Caffeine.newBuilder() - .maximumSize(10000) - .expireAfterAccess(Duration.ofHours(24)) - .recordStats() - .build(); - } - - @Bean - public Cache ruleCache() { - return Caffeine.newBuilder() - .maximumSize(100) - .expireAfterWrite(Duration.ofHours(1)) - .build(); - } -} - ---- - -## 九、定时任务设计 - -### 9.1 统计任务 - -```java -@Slf4j -@Component -@RequiredArgsConstructor -public class CheckinStatisticsScheduler { - - private final CheckinStatisticsService statisticsService; - - @Scheduled(cron = "0 5 0 * * ?") - public void generateYesterdayStatistics() { - LocalDate yesterday = LocalDate.now().minusDays(1); - statisticsService.generateDailyStatistics(null, null, yesterday) - .subscribe( - v -> log.info("昨日签到统计生成完成: {}", yesterday), - e -> log.error("昨日签到统计生成失败", e) - ); - } - - @Scheduled(cron = "0 0 */1 * * ?") - public void generateTodayStatistics() { - LocalDate today = LocalDate.now(); - statisticsService.generateDailyStatistics(null, null, today) - .subscribe( - v -> log.info("今日签到统计更新完成: {}", today), - e -> log.error("今日签到统计更新失败", e) - ); - } -} -```` - -### 9.2 设备心跳检测 - -```java -@Slf4j -@Component -@RequiredArgsConstructor -public class DeviceHeartbeatScheduler { - - private final CheckinDeviceRepository deviceRepository; - - @Scheduled(fixedRate = 60000) - public void checkDeviceStatus() { - LocalDateTime threshold = LocalDateTime.now().minusMinutes(5); - - deviceRepository.findAllByStatus(DeviceStatus.ONLINE) - .filter(device -> device.getLastHeartbeat().isBefore(threshold)) - .flatMap(device -> { - device.setStatus(DeviceStatus.OFFLINE); - return deviceRepository.save(device); - }) - .subscribe( - device -> log.warn("设备离线: {}", device.getName()), - e -> log.error("设备状态检测失败", e) - ); - } -} -``` - ---- - -## 十、异常处理 - -### 10.1 异常定义 - -```java -public class CheckinException extends RuntimeException { - - public static final String MEMBER_NOT_FOUND = "MEMBER_NOT_FOUND"; - public static final String MEMBER_INACTIVE = "MEMBER_INACTIVE"; - public static final String BOOKING_NOT_FOUND = "BOOKING_NOT_FOUND"; - public static final String BOOKING_NOT_MATCH = "BOOKING_NOT_MATCH"; - public static final String BOOKING_NOT_CONFIRMED = "BOOKING_NOT_CONFIRMED"; - public static final String ALREADY_CHECKED = "ALREADY_CHECKED"; - public static final String COURSE_ENDED = "COURSE_ENDED"; - public static final String DAILY_LIMIT_EXCEEDED = "DAILY_LIMIT_EXCEEDED"; - public static final String INTERVAL_NOT_MET = "INTERVAL_NOT_MET"; - public static final String RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED"; - public static final String CHECKIN_NOT_FOUND = "CHECKIN_NOT_FOUND"; - public static final String ALREADY_CANCELLED = "ALREADY_CANCELLED"; - - private final String code; - - public CheckinException(String code) { - super(getMessage(code)); - this.code = code; - } - - public CheckinException(String code, String message) { - super(message); - this.code = code; - } - - private static String getMessage(String code) { - return switch (code) { - case MEMBER_NOT_FOUND -> "会员不存在"; - case MEMBER_INACTIVE -> "会员状态异常"; - case BOOKING_NOT_FOUND -> "预约记录不存在"; - case BOOKING_NOT_MATCH -> "预约信息不匹配"; - case BOOKING_NOT_CONFIRMED -> "预约未确认"; - case ALREADY_CHECKED -> "已签到"; - case COURSE_ENDED -> "课程已结束"; - case DAILY_LIMIT_EXCEEDED -> "签到次数超限"; - case INTERVAL_NOT_MET -> "签到间隔不足"; - case RATE_LIMIT_EXCEEDED -> "请求过于频繁"; - case CHECKIN_NOT_FOUND -> "签到记录不存在"; - case ALREADY_CANCELLED -> "签到已取消"; - default -> "签到异常"; - }; - } -} - -public class FaceException extends RuntimeException { - - public static final String QUALITY_TOO_LOW = "QUALITY_TOO_LOW"; - public static final String FACE_ALREADY_REGISTERED = "FACE_ALREADY_REGISTERED"; - public static final String FACE_NOT_FOUND = "FACE_NOT_FOUND"; - public static final String FACE_NOT_MATCHED = "FACE_NOT_MATCHED"; - - private final String code; - - public FaceException(String code) { - super(getMessage(code)); - this.code = code; - } - - public FaceException(String code, String message) { - super(message); - this.code = code; - } - - private static String getMessage(String code) { - return switch (code) { - case QUALITY_TOO_LOW -> "人脸质量分数过低"; - case FACE_ALREADY_REGISTERED -> "人脸已注册"; - case FACE_NOT_FOUND -> "人脸信息不存在"; - case FACE_NOT_MATCHED -> "人脸匹配失败"; - default -> "人脸识别异常"; - }; - } -} -``` - -### 10.2 全局异常处理 - -```java -@Slf4j -@RestControllerAdvice -public class CheckinExceptionHandler { - - @ExceptionHandler(CheckinException.class) - public ResponseEntity> handleCheckinException(CheckinException e) { - log.warn("签到异常: {}", e.getMessage()); - return ResponseEntity.badRequest() - .body(ApiResponse.error(e.getCode(), e.getMessage())); - } - - @ExceptionHandler(FaceException.class) - public ResponseEntity> handleFaceException(FaceException e) { - log.warn("人脸识别异常: {}", e.getMessage()); - return ResponseEntity.badRequest() - .body(ApiResponse.error(e.getCode(), e.getMessage())); - } -} -``` - ---- - -## 十一、附录 - -### 11.1 枚举定义 - -| 枚举类型 | 值 | 说明 | -| ------------- | --- | ---------- | -| CheckinType | 1 | 入场签到 | -| CheckinType | 2 | 课程签到 | -| CheckinType | 3 | 私教签到 | -| CheckinType | 4 | 活动签到 | -| CheckinMethod | 1 | 二维码 | -| CheckinMethod | 2 | 人脸识别 | -| CheckinMethod | 3 | NFC | -| CheckinMethod | 4 | 教练代签 | -| CheckinStatus | 1 | 成功 | -| CheckinStatus | 2 | 失败 | -| CheckinStatus | 3 | 已取消 | -| DeviceType | 1 | 人脸识别机 | -| DeviceType | 2 | NFC读卡器 | -| DeviceType | 3 | 扫码枪 | -| DeviceType | 4 | 一体机 | -| DeviceStatus | 1 | 在线 | -| DeviceStatus | 2 | 离线 | -| DeviceStatus | 3 | 维护中 | -| FaceStatus | 1 | 正常 | -| FaceStatus | 2 | 待更新 | -| FaceStatus | 3 | 已禁用 | - -### 11.2 错误码定义 - -| 错误码 | 说明 | 处理建议 | -| --------------------- | ------------ | ---------------------- | -| MEMBER_NOT_FOUND | 会员不存在 | 检查会员ID | -| MEMBER_INACTIVE | 会员状态异常 | 联系工作人员 | -| BOOKING_NOT_FOUND | 预约不存在 | 检查预约ID | -| BOOKING_NOT_MATCH | 预约不匹配 | 确认预约信息 | -| BOOKING_NOT_CONFIRMED | 预约未确认 | 等待确认 | -| ALREADY_CHECKED | 已签到 | 无需重复签到 | -| COURSE_ENDED | 课程已结束 | 无法签到 | -| DAILY_LIMIT_EXCEEDED | 签到次数超限 | 明日再来 | -| INTERVAL_NOT_MET | 签到间隔不足 | 稍后重试 | -| RATE_LIMIT_EXCEEDED | 请求过于频繁 | 稍后重试 | -| QUALITY_TOO_LOW | 人脸质量低 | 重新拍照 | -| FACE_NOT_MATCHED | 人脸匹配失败 | 重新注册或使用其他方式 | - ---- - -## 十二、版本历史 - -| 版本 | 日期 | 作者 | 变更内容 | -| ---- | ---------- | ---- | -------- | -| v1.0 | 2026-02-28 | 张翔 | 初稿 | diff --git a/docs/design/modules/LLD-预约模块详细设计.md b/docs/design/modules/LLD-预约模块详细设计.md deleted file mode 100644 index 3974adb..0000000 --- a/docs/design/modules/LLD-预约模块详细设计.md +++ /dev/null @@ -1,1237 +0,0 @@ -# 健身房管理系统详细设计文档 - 预约模块(LLD) - -> 文档编号: GYM-LLD-002 -> 版本: v1.0 -> 日期: 2026-02-28 -> 作者: 张翔 -> 状态: 初稿 -> **归属版本**: 基础版 - -**说明**:本文档为健身房管理系统**基础版**的预约模块详细设计文档,描述团课预约模块的数据库设计、API设计、业务逻辑实现等技术细节。 - ---- - -## 文档修订历史 - -| 版本 | 日期 | 作者 | 修订内容 | -|------|------|------|---------| -| v1.0 | 2026-02-28 | 张翔 | 初稿 | - ---- - -## 参考文档 - -- 《健身房管理系统产品设计文档》 GYM-PRD-001 -- 《健身房管理系统业务概要设计文档》 GYM-HLD-001 -- 《健身房管理系统详细设计文档》 GYM-LLD-000 -- Spring Boot 3 官方文档 -- R2DBC 规范文档 -- PostgreSQL 官方文档 - ---- - -## 一、模块概述 - -### 1.1 模块定位 - -预约模块是健身房管理系统的核心业务模块,负责管理各类资源的预约,包括: - -- 团课预约:会员预约团体课程 -- 私教预约:会员预约私教课程 -- 场地预约:会员预约运动场地 -- 线上课程预约:会员预约线上直播课程 - -### 1.2 模块边界 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 预约模块边界 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 预约模块内部 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 课程管理 • 时段管理 • 预约管理 • 库存管理 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 外部依赖 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 会员模块 (查询会员权益、扣减权益) │ │ -│ │ • 教练模块 (查询教练信息、排班) │ │ -│ │ • 场地模块 (查询场地信息、可用性) │ │ -│ │ • 消息模块 (发送预约通知) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 被依赖 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 签到模块 (查询预约信息、验证签到资格) │ │ -│ │ • 财务模块 (查询预约消费记录) │ │ -│ │ • 数据模块 (预约数据分析) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 二、数据模型设计 - -### 2.1 实体关系图 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 实体关系图 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ coach │ │ course │ │ venue │ │ -│ │ (教练) │ │ (课程) │ │ (场地) │ │ -│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ -│ │ 1:N │ 1:N │ 1:N │ -│ │ │ │ │ -│ └──────────────┴──────────────┘ │ -│ │ 1:N │ -│ ▼ │ -│ ┌──────────────────┐ │ -│ │ booking_slot │ │ -│ │ (预约时段) │ │ -│ └────────┬─────────┘ │ -│ │ 1:N │ -│ ▼ │ -│ ┌──────────────────┐ │ -│ │ booking_record │ │ -│ │ (预约记录) │ │ -│ └──────────────────┘ │ -│ │ -│ 关系说明: │ -│ • coach (1) ─── (N) booking_slot : 一个教练有多个时段 │ -│ • course (1) ─── (N) booking_slot : 一个课程有多个时段 │ -│ • venue (1) ─── (N) booking_slot : 一个场地有多个时段 │ -│ • booking_slot (1) ─── (N) booking_record : 一个时段有多个预约记录 │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 2.2 数据表设计 - -#### 2.2.1 课程表 (course) - -```sql -CREATE TABLE course ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - name VARCHAR(128) NOT NULL, - code VARCHAR(32), - type SMALLINT NOT NULL, -- 1:团课 2:私教 3:线上 - category VARCHAR(64), -- 课程分类 - description TEXT, - cover_image VARCHAR(512), - duration INT NOT NULL, -- 课程时长(分钟) - capacity INT DEFAULT 20, -- 最大人数 - min_capacity INT DEFAULT 1, -- 最少开课人数 - difficulty SMALLINT DEFAULT 1, -- 1:入门 2:初级 3:中级 4:高级 - calories INT, -- 消耗卡路里 - equipment VARCHAR(256), -- 所需器材 - benefits JSONB, -- 课程收益 - price DECIMAL(10,2), -- 单次价格 - price_type SMALLINT DEFAULT 1, -- 1:扣次 2:扣时长 3:扣金额 - price_value DECIMAL(10,2), -- 扣减值 - advance_days INT DEFAULT 7, -- 可提前预约天数 - cancel_hours INT DEFAULT 2, -- 可取消小时数 - cancel_penalty DECIMAL(3,2) DEFAULT 0.00, -- 取消扣款比例 - status SMALLINT DEFAULT 1, -- 1:上架 2:下架 - sort_order INT DEFAULT 0, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - created_by BIGINT, - updated_by BIGINT, - deleted_at TIMESTAMP DEFAULT NULL, - - CONSTRAINT fk_course_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id) -); - -CREATE INDEX idx_course_tenant ON course(tenant_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_course_type ON course(type) WHERE deleted_at IS NULL; -CREATE INDEX idx_course_status ON course(status) WHERE deleted_at IS NULL; -``` - -#### 2.2.2 场地表 (venue) - -```sql -CREATE TABLE venue ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - store_id BIGINT NOT NULL, - name VARCHAR(128) NOT NULL, - code VARCHAR(32), - type SMALLINT NOT NULL, -- 1:瑜伽室 2:动感单车 3:力量区 4:游泳池 - area DECIMAL(8,2), -- 面积(平方米) - capacity INT NOT NULL, -- 最大容量 - facilities JSONB, -- 设施配置 - open_time TIME, -- 开放时间 - close_time TIME, -- 关闭时间 - price_per_hour DECIMAL(10,2), -- 每小时价格 - status SMALLINT DEFAULT 1, -- 1:可用 2:维护中 3:已停用 - images JSONB, -- 场地图片 - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - created_by BIGINT, - updated_by BIGINT, - deleted_at TIMESTAMP DEFAULT NULL, - - CONSTRAINT fk_venue_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id), - CONSTRAINT fk_venue_store FOREIGN KEY (store_id) REFERENCES store(id) -); - -CREATE INDEX idx_venue_tenant ON venue(tenant_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_venue_store ON venue(store_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_venue_status ON venue(status) WHERE deleted_at IS NULL; -``` - -#### 2.2.3 预约时段表 (booking_slot) - -```sql -CREATE TABLE booking_slot ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - store_id BIGINT NOT NULL, - resource_type SMALLINT NOT NULL, -- 1:团课 2:私教 3:场地 4:线上 - resource_id BIGINT NOT NULL, -- 课程ID或场地ID - coach_id BIGINT, -- 教练ID(私教必填) - venue_id BIGINT, -- 场地ID - title VARCHAR(128), -- 时段标题 - start_time TIMESTAMP NOT NULL, -- 开始时间 - end_time TIMESTAMP NOT NULL, -- 结束时间 - capacity INT NOT NULL, -- 容量 - booked_count INT DEFAULT 0, -- 已预约人数 - waitlist_count INT DEFAULT 0, -- 候补人数 - min_capacity INT DEFAULT 1, -- 最少开课人数 - status SMALLINT DEFAULT 1, -- 1:可预约 2:已满 3:已取消 4:已结束 - price DECIMAL(10,2), -- 价格 - price_type SMALLINT DEFAULT 1, -- 1:扣次 2:扣时长 3:扣金额 - price_value DECIMAL(10,2), -- 扣减值 - booking_start TIMESTAMP, -- 开放预约时间 - booking_end TIMESTAMP, -- 截止预约时间 - cancel_deadline TIMESTAMP, -- 取消截止时间 - version INT DEFAULT 0, -- 乐观锁版本号 - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - created_by BIGINT, - deleted_at TIMESTAMP DEFAULT NULL, - - CONSTRAINT fk_slot_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id), - CONSTRAINT fk_slot_store FOREIGN KEY (store_id) REFERENCES store(id) -); - -CREATE INDEX idx_slot_tenant ON booking_slot(tenant_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_slot_store ON booking_slot(store_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_slot_resource ON booking_slot(resource_type, resource_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_slot_coach ON booking_slot(coach_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_slot_time ON booking_slot(start_time, end_time) WHERE deleted_at IS NULL; -CREATE INDEX idx_slot_status ON booking_slot(status) WHERE deleted_at IS NULL; -``` - -#### 2.2.4 预约记录表 (booking_record) - -```sql -CREATE TABLE booking_record ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - store_id BIGINT NOT NULL, - member_id BIGINT NOT NULL, - slot_id BIGINT NOT NULL, - resource_type SMALLINT NOT NULL, - resource_id BIGINT NOT NULL, - coach_id BIGINT, - booking_no VARCHAR(32) NOT NULL, -- 预约编号 - status SMALLINT DEFAULT 1, -- 1:已预约 2:已取消 3:已完成 4:已过期 - price DECIMAL(10,2), -- 价格 - price_type SMALLINT, -- 价格类型 - price_value DECIMAL(10,2), -- 扣减值 - benefit_id BIGINT, -- 扣减的权益ID - source VARCHAR(32), -- 来源: app/miniprogram/staff - cancel_reason VARCHAR(256), -- 取消原因 - cancel_by BIGINT, -- 取消人 - cancel_at TIMESTAMP, -- 取消时间 - checkin_status SMALLINT DEFAULT 0, -- 0:未签到 1:已签到 2:迟到 3:缺席 - checkin_at TIMESTAMP, -- 签到时间 - checkin_by BIGINT, -- 签到操作人 - rating SMALLINT, -- 评分 1-5 - comment TEXT, -- 评价内容 - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - deleted_at TIMESTAMP DEFAULT NULL, - - CONSTRAINT uk_booking_no UNIQUE (tenant_id, booking_no), - CONSTRAINT fk_booking_member FOREIGN KEY (member_id) REFERENCES member(id), - CONSTRAINT fk_booking_slot FOREIGN KEY (slot_id) REFERENCES booking_slot(id) -); - -CREATE INDEX idx_booking_member ON booking_record(member_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_booking_slot ON booking_record(slot_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_booking_coach ON booking_record(coach_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_booking_status ON booking_record(status) WHERE deleted_at IS NULL; -CREATE INDEX idx_booking_time ON booking_record(created_at) WHERE deleted_at IS NULL; -CREATE INDEX idx_booking_checkin ON booking_record(checkin_status) WHERE deleted_at IS NULL; -``` - -#### 2.2.5 预约候补表 (booking_waitlist) - -```sql -CREATE TABLE booking_waitlist ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - member_id BIGINT NOT NULL, - slot_id BIGINT NOT NULL, - queue_no INT NOT NULL, -- 排队序号 - status SMALLINT DEFAULT 1, -- 1:排队中 2:已转正 3:已取消 - expire_at TIMESTAMP, -- 转正过期时间 - notified_at TIMESTAMP, -- 通知时间 - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - deleted_at TIMESTAMP DEFAULT NULL, - - CONSTRAINT fk_waitlist_member FOREIGN KEY (member_id) REFERENCES member(id), - CONSTRAINT fk_waitlist_slot FOREIGN KEY (slot_id) REFERENCES booking_slot(id) -); - -CREATE INDEX idx_waitlist_slot ON booking_waitlist(slot_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_waitlist_member ON booking_waitlist(member_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_waitlist_status ON booking_waitlist(status) WHERE deleted_at IS NULL; -``` - -#### 2.2.6 教练排班表 (coach_schedule) - -```sql -CREATE TABLE coach_schedule ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - store_id BIGINT NOT NULL, - coach_id BIGINT NOT NULL, - schedule_date DATE NOT NULL, -- 排班日期 - start_time TIME NOT NULL, -- 开始时间 - end_time TIME NOT NULL, -- 结束时间 - status SMALLINT DEFAULT 1, -- 1:上班 2:休息 3:请假 - remark VARCHAR(256), - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - deleted_at TIMESTAMP DEFAULT NULL, - - CONSTRAINT fk_schedule_coach FOREIGN KEY (coach_id) REFERENCES coach(id), - CONSTRAINT uk_schedule UNIQUE (coach_id, schedule_date, start_time) -); - -CREATE INDEX idx_schedule_coach ON coach_schedule(coach_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_schedule_date ON coach_schedule(schedule_date) WHERE deleted_at IS NULL; -``` - ---- - -## 三、领域模型设计 - -### 3.1 领域模型类图 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 预约领域模型 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌───────────────────────────────────────────────────────────────────┐ │ -│ │ <> │ │ -│ │ BookingSlot │ │ -│ ├───────────────────────────────────────────────────────────────────┤ │ -│ │ - id: Long │ │ -│ │ - tenantId: Long │ │ -│ │ - storeId: Long │ │ -│ │ - resourceType: ResourceType │ │ -│ │ - resourceId: Long │ │ -│ │ - coachId: Long │ │ -│ │ - venueId: Long │ │ -│ │ - startTime: LocalDateTime │ │ -│ │ - endTime: LocalDateTime │ │ -│ │ - capacity: Integer │ │ -│ │ - bookedCount: Integer │ │ -│ │ - waitlistCount: Integer │ │ -│ │ - status: SlotStatus │ │ -│ │ - version: Integer │ │ -│ ├───────────────────────────────────────────────────────────────────┤ │ -│ │ + hasCapacity(): Boolean │ │ -│ │ + getRemainCapacity(): Integer │ │ -│ │ + canBook(): Boolean │ │ -│ │ + book(): void │ │ -│ │ + cancel(): void │ │ -│ │ + isExpired(): Boolean │ │ -│ │ + isFull(): Boolean │ │ -│ │ + addToWaitlist(): void │ │ -│ │ + removeFromWaitlist(): void │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ │ 1:N │ -│ ▼ │ -│ ┌────────────────────────────┐ ┌────────────────────────────┐ │ -│ │ <> │ │ <> │ │ -│ │ BookingRecord │ │ BookingWaitlist │ │ -│ ├────────────────────────────┤ ├────────────────────────────┤ │ -│ │ - id: Long │ │ - id: Long │ │ -│ │ - memberId: Long │ │ - memberId: Long │ │ -│ │ - slotId: Long │ │ - slotId: Long │ │ -│ │ - bookingNo: String │ │ - queueNo: Integer │ │ -│ │ - status: BookingStatus │ │ - status: WaitlistStatus │ │ -│ │ - priceType: PriceType │ │ - expireAt: LocalDateTime │ │ -│ │ - priceValue: BigDecimal │ ├────────────────────────────┤ │ -│ │ - checkinStatus: CheckinSt │ │ + isExpired(): Boolean │ │ -│ ├────────────────────────────┤ │ + convert(): void │ │ -│ │ + canCancel(): Boolean │ └────────────────────────────┘ │ -│ │ + cancel(): void │ │ -│ │ + checkin(): void │ │ -│ │ + isCheckinable(): Boolean │ │ -│ └────────────────────────────┘ │ -│ │ -│ ┌────────────────────────────┐ ┌────────────────────────────┐ │ -│ │ <> │ │ <> │ │ -│ │ ResourceType │ │ SlotStatus │ │ -│ ├────────────────────────────┤ ├────────────────────────────┤ │ -│ │ GROUP_CLASS(1, "团课") │ │ AVAILABLE(1, "可预约") │ │ -│ │ PRIVATE(2, "私教") │ │ FULL(2, "已满") │ │ -│ │ VENUE(3, "场地") │ │ CANCELLED(3, "已取消") │ │ -│ │ ONLINE(4, "线上") │ │ ENDED(4, "已结束") │ │ -│ └────────────────────────────┘ └────────────────────────────┘ │ -│ │ -│ ┌────────────────────────────┐ ┌────────────────────────────┐ │ -│ │ <> │ │ <> │ │ -│ │ BookingStatus │ │ PriceType │ │ -│ ├────────────────────────────┤ ├────────────────────────────┤ │ -│ │ BOOKED(1, "已预约") │ │ TIMES(1, "扣次") │ │ -│ │ CANCELLED(2, "已取消") │ │ DURATION(2, "扣时长") │ │ -│ │ COMPLETED(3, "已完成") │ │ AMOUNT(3, "扣金额") │ │ -│ │ EXPIRED(4, "已过期") │ └────────────────────────────┘ │ -│ └────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 3.2 领域服务 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 领域服务设计 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌───────────────────────────────────────────────────────────────────┐ │ -│ │ <> │ │ -│ │ BookingDomainService │ │ -│ ├───────────────────────────────────────────────────────────────────┤ │ -│ │ + createBooking(command: CreateBookingCommand): BookingRecord │ │ -│ │ + cancelBooking(bookingId: Long, reason: String): void │ │ -│ │ + checkin(bookingId: Long): void │ │ -│ │ + addToWaitlist(memberId: Long, slotId: Long): void │ │ -│ │ + processWaitlist(slotId: Long): void │ │ -│ │ + validateBooking(memberId: Long, slotId: Long): void │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌───────────────────────────────────────────────────────────────────┐ │ -│ │ <> │ │ -│ │ SlotDomainService │ │ -│ ├───────────────────────────────────────────────────────────────────┤ │ -│ │ + createSlot(command: CreateSlotCommand): BookingSlot │ │ -│ │ + batchCreateSlots(command: BatchSlotCommand): List │ │ -│ │ + updateSlot(slotId: Long, command: UpdateSlotCommand): void │ │ -│ │ + cancelSlot(slotId: Long, reason: String): void │ │ -│ │ + getAvailableSlots(query: SlotQuery): List │ │ -│ │ + incrementBookedCount(slotId: Long): Boolean │ │ -│ │ + decrementBookedCount(slotId: Long): void │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌───────────────────────────────────────────────────────────────────┐ │ -│ │ <> │ │ -│ │ InventoryDomainService │ │ -│ ├───────────────────────────────────────────────────────────────────┤ │ -│ │ + checkInventory(slotId: Long): Boolean │ │ -│ │ + reserveInventory(slotId: Long, count: Integer): Boolean │ │ -│ │ + releaseInventory(slotId: Long, count: Integer): void │ │ -│ │ + preloadInventory(slotIds: List): void │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 四、业务流程设计 - -### 4.1 团课预约流程 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 团课预约流程 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ 会员端 API层 BookingService BenefitService 数据层 │ -│ │ │ │ │ │ │ -│ │ 1.选择课程 │ │ │ │ │ -│ │─────────▶│ │ │ │ │ -│ │ │ 2.查询时段 │ │ │ │ -│ │ │─────────────▶│ │ │ │ -│ │ │ │ 3.查询可预约时段 │ │ │ -│ │ │ │───────────────────────────────▶│ │ -│ │ │ │◀───────────────────────────────│ │ -│ │◀─────────│ 返回时段列表 │ │ │ │ -│ │ │ │ │ │ │ -│ │ 4.提交预约 │ │ │ │ │ -│ │─────────▶│ │ │ │ │ -│ │ │ 5.创建预约 │ │ │ │ -│ │ │─────────────▶│ │ │ │ -│ │ │ │ 6.校验时段 │ │ │ -│ │ │ │───────────────────────────────▶│ │ -│ │ │ │◀───────────────────────────────│ │ -│ │ │ │ │ │ │ -│ │ │ │ 7.校验会员状态 │ │ │ -│ │ │ │───────────────────────────────▶│ │ -│ │ │ │◀───────────────────────────────│ │ -│ │ │ │ │ │ │ -│ │ │ │ 8.检查库存(原子)│ │ │ -│ │ │ │───────────────────────────────▶│ │ -│ │ │ │◀───────────────────────────────│ │ -│ │ │ │ │ │ │ -│ │ │ │ 9.扣减权益 │ │ │ -│ │ │ │────────────────▶│ │ │ -│ │ │ │ │─────────────▶│ │ -│ │ │ │ │◀─────────────│ │ -│ │ │ │◀────────────────│ │ │ -│ │ │ │ │ │ │ -│ │ │ │ 10.创建预约记录 │ │ │ -│ │ │ │───────────────────────────────▶│ │ -│ │ │ │ │ │ │ -│ │ │ │ 11.增加预约人数 │ │ │ -│ │ │ │───────────────────────────────▶│ │ -│ │ │ │◀───────────────────────────────│ │ -│ │ │ │ │ │ │ -│ │ │ │ 12.发送预约通知 │ │ │ -│ │◀─────────│◀─────────────│ │ │ │ -│ │ 返回预约成功│ │ │ │ │ -│ │ │ │ │ │ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 4.2 取消预约流程 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 取消预约流程 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ 会员端 API层 BookingService BenefitService 数据层 │ -│ │ │ │ │ │ │ -│ │ 1.请求取消 │ │ │ │ │ -│ │─────────▶│ │ │ │ │ -│ │ │ 2.查询预约 │ │ │ │ -│ │ │─────────────▶│ │ │ │ -│ │ │ │───────────────────────────────▶│ │ -│ │ │ │◀───────────────────────────────│ │ -│ │ │ │ │ │ │ -│ │ │ │ 3.校验可取消 │ │ │ -│ │ │ │ - 状态检查 │ │ │ -│ │ │ │ - 时间检查 │ │ │ -│ │ │ │ │ │ │ -│ │ │ │ 4.计算退款金额 │ │ │ -│ │ │ │ │ │ │ -│ │ │ │ 5.退还权益 │ │ │ -│ │ │ │────────────────▶│ │ │ -│ │ │ │ │─────────────▶│ │ -│ │ │ │ │◀─────────────│ │ -│ │ │ │◀────────────────│ │ │ -│ │ │ │ │ │ │ -│ │ │ │ 6.更新预约状态 │ │ │ -│ │ │ │───────────────────────────────▶│ │ -│ │ │ │ │ │ │ -│ │ │ │ 7.减少预约人数 │ │ │ -│ │ │ │───────────────────────────────▶│ │ -│ │ │ │ │ │ │ -│ │ │ │ 8.处理候补队列 │ │ │ -│ │ │ │───────────────────────────────▶│ │ -│ │ │ │◀───────────────────────────────│ │ -│ │ │ │ │ │ │ -│ │ │ │ 9.发送取消通知 │ │ │ -│ │◀─────────│◀─────────────│ │ │ │ -│ │ 返回取消成功│ │ │ │ │ -│ │ │ │ │ │ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 五、接口设计 - -### 5.1 课程接口 - -#### 5.1.1 获取课程列表 - -``` -GET /v1/courses?storeId=1&type=1&status=1 - -Response: -{ - "code": 0, - "message": "success", - "data": { - "list": [ - { - "id": 1, - "name": "瑜伽基础课", - "type": 1, - "typeName": "团课", - "category": "瑜伽", - "coverImage": "https://xxx.com/yoga.jpg", - "duration": 60, - "capacity": 20, - "difficulty": 1, - "difficultyName": "入门", - "calories": 200, - "priceType": 1, - "priceValue": 1, - "priceTypeName": "扣1次" - } - ], - "total": 10 - } -} -``` - -### 5.2 预约接口 - -#### 5.2.1 创建预约 - -``` -POST /v1/bookings - -Request: -{ - "slotId": 1, - "memberId": 10001, - "source": "miniprogram" -} - -Response: -{ - "code": 0, - "message": "success", - "data": { - "bookingId": 1, - "bookingNo": "B202602280001", - "courseName": "瑜伽基础课", - "coachName": "王教练", - "venueName": "瑜伽室A", - "startTime": "2026-03-01T09:00:00", - "endTime": "2026-03-01T10:00:00", - "priceType": 1, - "priceValue": 1, - "status": 1, - "statusName": "已预约", - "createdAt": "2026-02-28T10:00:00" - } -} -``` - -#### 5.2.2 取消预约 - -``` -POST /v1/bookings/{id}/cancel - -Request: -{ - "reason": "临时有事" -} - -Response: -{ - "code": 0, - "message": "success", - "data": { - "bookingId": 1, - "status": 2, - "statusName": "已取消", - "refundAmount": 1, - "refundTypeName": "退还1次" - } -} -``` - -#### 5.2.3 获取我的预约 - -``` -GET /v1/bookings/my?memberId=10001&status=1&page=1&pageSize=20 - -Response: -{ - "code": 0, - "message": "success", - "data": { - "list": [ - { - "id": 1, - "bookingNo": "B202602280001", - "resourceType": 1, - "resourceTypeName": "团课", - "courseName": "瑜伽基础课", - "coachName": "王教练", - "coachAvatar": "https://xxx.com/coach.jpg", - "venueName": "瑜伽室A", - "startTime": "2026-03-01T09:00:00", - "endTime": "2026-03-01T10:00:00", - "status": 1, - "statusName": "已预约", - "checkinStatus": 0, - "checkinStatusName": "未签到", - "canCancel": true, - "cancelDeadline": "2026-03-01T07:00:00" - } - ], - "total": 5, - "page": 1, - "pageSize": 20 - } -} -``` - ---- - -## 六、核心代码设计 - -### 6.1 预约时段实体 - -```java -package com.gym.domain.model.booking; - -import com.gym.domain.model.base.BaseEntity; -import com.gym.domain.model.base.AggregateRoot; -import lombok.Getter; -import lombok.Setter; -import java.time.LocalDateTime; - -@Getter -@Setter -public class BookingSlot extends BaseEntity implements AggregateRoot { - - private Long tenantId; - private Long storeId; - private ResourceType resourceType; - private Long resourceId; - private Long coachId; - private Long venueId; - private String title; - private LocalDateTime startTime; - private LocalDateTime endTime; - private Integer capacity; - private Integer bookedCount; - private Integer waitlistCount; - private Integer minCapacity; - private SlotStatus status; - private BigDecimal price; - private PriceType priceType; - private BigDecimal priceValue; - private LocalDateTime bookingStart; - private LocalDateTime bookingEnd; - private LocalDateTime cancelDeadline; - private Integer version; - - public boolean hasCapacity() { - return bookedCount < capacity; - } - - public Integer getRemainCapacity() { - return Math.max(0, capacity - bookedCount); - } - - public boolean canBook() { - if (!SlotStatus.AVAILABLE.equals(status)) { - return false; - } - if (!hasCapacity()) { - return false; - } - LocalDateTime now = LocalDateTime.now(); - if (bookingStart != null && now.isBefore(bookingStart)) { - return false; - } - if (bookingEnd != null && now.isAfter(bookingEnd)) { - return false; - } - return true; - } - - public boolean isExpired() { - return LocalDateTime.now().isAfter(endTime); - } - - public boolean isFull() { - return bookedCount >= capacity; - } - - public void book() { - if (!canBook()) { - throw new BookingException(BookingException.SLOT_NOT_AVAILABLE); - } - this.bookedCount++; - this.version++; - this.updatedAt = LocalDateTime.now(); - - if (isFull()) { - this.status = SlotStatus.FULL; - } - } - - public void cancel() { - if (this.bookedCount > 0) { - this.bookedCount--; - } - this.version++; - this.updatedAt = LocalDateTime.now(); - - if (SlotStatus.FULL.equals(status) && !isFull()) { - this.status = SlotStatus.AVAILABLE; - } - } -} -``` - -### 6.2 预约服务 - -```java -package com.gym.domain.service; - -import com.gym.domain.model.booking.*; -import com.gym.domain.repository.*; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.reactive.TransactionalOperator; -import reactor.core.publisher.Mono; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; - -@Service -@RequiredArgsConstructor -public class BookingDomainService { - - private final BookingSlotRepository slotRepository; - private final BookingRecordRepository recordRepository; - private final BookingWaitlistRepository waitlistRepository; - private final BenefitDomainService benefitService; - private final TransactionalOperator rxtx; - - public Mono createBooking(Long memberId, Long slotId, String source) { - return Mono.defer(() -> - slotRepository.findById(slotId) - .switchIfEmpty(Mono.error(new BookingException(BookingException.SLOT_NOT_FOUND))) - .flatMap(slot -> { - if (!slot.canBook()) { - return Mono.error(new BookingException(BookingException.SLOT_NOT_AVAILABLE)); - } - - return recordRepository.existsByMemberIdAndSlotIdAndStatus( - memberId, slotId, BookingStatus.BOOKED - ).flatMap(exists -> { - if (exists) { - return Mono.error(new BookingException(BookingException.ALREADY_BOOKED)); - } - - BookingRecord record = new BookingRecord(); - record.setTenantId(slot.getTenantId()); - record.setStoreId(slot.getStoreId()); - record.setMemberId(memberId); - record.setSlotId(slotId); - record.setResourceType(slot.getResourceType()); - record.setResourceId(slot.getResourceId()); - record.setCoachId(slot.getCoachId()); - record.setBookingNo(generateBookingNo(slot.getTenantId())); - record.setStatus(BookingStatus.BOOKED); - record.setPrice(slot.getPrice()); - record.setPriceType(slot.getPriceType()); - record.setPriceValue(slot.getPriceValue()); - record.setSource(source); - record.setCheckinStatus(CheckinStatus.NOT_CHECKED); - - return deductBenefit(memberId, slot) - .flatMap(benefitId -> { - record.setBenefitId(benefitId); - slot.book(); - - return Mono.when( - recordRepository.save(record), - slotRepository.save(slot) - ).thenReturn(record); - }); - }); - }) - ).as(rxtx::transactional); - } - - public Mono cancelBooking(Long bookingId, String reason, Long operatorId) { - return Mono.defer(() -> - recordRepository.findById(bookingId) - .switchIfEmpty(Mono.error(new BookingException(BookingException.BOOKING_NOT_FOUND))) - .flatMap(record -> { - if (!record.canCancel()) { - return Mono.error(new BookingException(BookingException.CANNOT_CANCEL)); - } - - return slotRepository.findById(record.getSlotId()) - .flatMap(slot -> refundBenefit(record) - .flatMap(v -> { - record.cancel(reason, operatorId); - slot.cancel(); - - return Mono.when( - recordRepository.save(record), - slotRepository.save(slot) - ).then(processWaitlist(slot.getId())); - })); - }) - ).as(rxtx::transactional); - } - - private Mono deductBenefit(Long memberId, BookingSlot slot) { - if (slot.getPriceType() == null || slot.getPriceValue() == null) { - return Mono.just(null); - } - - return benefitService.deductBenefit( - memberId, - mapPriceTypeToBenefitType(slot.getPriceType()), - null, - slot.getPriceValue(), - "booking", - slot.getId(), - "预约: " + slot.getTitle() - ).then(Mono.just(1L)); - } - - private Mono refundBenefit(BookingRecord record) { - if (record.getPriceType() == null || record.getPriceValue() == null) { - return Mono.empty(); - } - - return benefitService.addBenefit( - record.getMemberId(), - record.getBenefitId(), - mapPriceTypeToBenefitType(record.getPriceType()), - null, - "取消预约退还", - record.getPriceValue(), - null, - null, - "refund", - record.getId() - ).then(); - } - - private Mono processWaitlist(Long slotId) { - return waitlistRepository.findFirstBySlotIdOrderByQueueNo(slotId) - .flatMap(waitlist -> { - waitlist.convert(); - return waitlistRepository.save(waitlist) - .then(createBooking(waitlist.getMemberId(), slotId, "waitlist")) - .then(); - }) - .switchIfEmpty(Mono.empty()); - } - - private String generateBookingNo(Long tenantId) { - String prefix = "B" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); - return prefix + String.format("%04d", (int)(Math.random() * 10000)); - } - - private BenefitType mapPriceTypeToBenefitType(PriceType priceType) { - return switch (priceType) { - case TIMES -> BenefitType.TIMES; - case DURATION -> BenefitType.DURATION; - case AMOUNT -> BenefitType.STORED_VALUE; - }; - } -} -``` - -### 6.3 库存服务 - -```java -package com.gym.domain.service; - -import com.github.benmanes.caffeine.cache.Cache; -import com.gym.domain.repository.BookingSlotRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.r2dbc.core.DatabaseClient; -import org.springframework.stereotype.Service; -import reactor.core.publisher.Mono; - -@Slf4j -@Service -@RequiredArgsConstructor -public class InventoryDomainService { - - private final BookingSlotRepository slotRepository; - private final DatabaseClient databaseClient; - private final Cache inventoryCache; - - public Mono checkInventory(Long slotId) { - return getRemainCapacity(slotId) - .map(remain -> remain > 0); - } - - public Mono reserveInventory(Long slotId) { - return databaseClient.sql(""" - UPDATE booking_slot - SET booked_count = booked_count + 1, - version = version + 1, - updated_at = NOW() - WHERE id = :slotId - AND deleted_at IS NULL - AND booked_count < capacity - """) - .bind("slotId", slotId) - .fetch() - .rowsUpdated() - .map(rows -> rows > 0) - .doOnNext(success -> { - if (success) { - invalidateCache(slotId); - } - }); - } - - public Mono releaseInventory(Long slotId) { - return databaseClient.sql(""" - UPDATE booking_slot - SET booked_count = GREATEST(booked_count - 1, 0), - version = version + 1, - updated_at = NOW() - WHERE id = :slotId - AND deleted_at IS NULL - """) - .bind("slotId", slotId) - .fetch() - .rowsUpdated() - .then() - .doOnSuccess(v -> invalidateCache(slotId)); - } - - public void preloadInventory(Long slotId) { - slotRepository.findById(slotId) - .subscribe(slot -> { - int remain = slot.getCapacity() - slot.getBookedCount(); - inventoryCache.put(slotId, remain); - log.debug("预加载库存: slotId={}, remain={}", slotId, remain); - }); - } - - private Mono getRemainCapacity(Long slotId) { - Integer cached = inventoryCache.getIfPresent(slotId); - if (cached != null) { - return Mono.just(cached); - } - - return slotRepository.findById(slotId) - .map(slot -> { - int remain = slot.getCapacity() - slot.getBookedCount(); - inventoryCache.put(slotId, remain); - return remain; - }); - } - - private void invalidateCache(Long slotId) { - inventoryCache.invalidate(slotId); - } -} -``` - ---- - -## 七、高并发处理 - -### 7.1 预约并发控制 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 高并发预约处理方案 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ 1. 库存预热 │ -│ ├── 热门课程开抢前5分钟预热库存到Caffeine缓存 │ -│ ├── 定时任务扫描即将开始的课程时段 │ -│ └── 预热数据: slotId -> remainCapacity │ -│ │ -│ 2. 原子操作 │ -│ └── 使用PostgreSQL原子更新保证库存一致性: │ -│ UPDATE booking_slot │ -│ SET booked_count = booked_count + 1 │ -│ WHERE id = ? AND booked_count < capacity │ -│ RETURNING booked_count │ -│ │ -│ 3. 乐观锁 │ -│ └── 使用version字段防止并发更新冲突: │ -│ UPDATE booking_slot │ -│ SET booked_count = ?, version = version + 1 │ -│ WHERE id = ? AND version = ? │ -│ │ -│ 4. 请求排队 │ -│ ├── 使用信号量控制并发请求数 │ -│ ├── 超出限制的请求进入等待队列 │ -│ └── 避免数据库连接池耗尽 │ -│ │ -│ 5. 限流保护 │ -│ ├── 接口限流: 单用户10次/秒 │ -│ ├── 热门课程限流: 令牌桶算法 │ -│ └── 超出限流返回"系统繁忙,请稍后重试" │ -│ │ -│ 6. 降级策略 │ -│ ├── 库存查询降级: 优先返回缓存数据 │ -│ ├── 预约失败降级: 返回排队页面 │ -│ └── 数据库压力过大时: 暂停预约功能 │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 八、缓存设计 - -### 8.1 缓存策略 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 预约模块缓存策略 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ 1. 时段库存缓存 │ -│ ├── Key: slot:inventory:{slotId} │ -│ ├── Value: Integer (剩余容量) │ -│ ├── TTL: 10分钟 │ -│ ├── 更新策略: 预约/取消时更新 │ -│ └── 预热: 热门课程开抢前预热 │ -│ │ -│ 2. 时段详情缓存 │ -│ ├── Key: slot:detail:{slotId} │ -│ ├── Value: BookingSlot JSON │ -│ ├── TTL: 30分钟 │ -│ └── 更新策略: 时段变更时删除 │ -│ │ -│ 3. 课程信息缓存 │ -│ ├── Key: course:info:{courseId} │ -│ ├── Value: Course JSON │ -│ ├── TTL: 1小时 │ -│ └── 更新策略: 课程变更时删除 │ -│ │ -│ 4. 教练排班缓存 │ -│ ├── Key: coach:schedule:{coachId}:{date} │ -│ ├── Value: List JSON │ -│ ├── TTL: 1天 │ -│ └── 更新策略: 排班变更时删除 │ -│ │ -│ 5. 用户预约锁 │ -│ ├── Key: booking:lock:{memberId}:{slotId} │ -│ ├── Value: 1 │ -│ ├── TTL: 5秒 │ -│ └── 用途: 防止重复预约 │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 九、定时任务 - -### 9.1 定时任务列表 - -| 任务名称 | 执行频率 | 功能描述 | -|---------|---------|---------| -| SlotStatusTask | 每分钟 | 更新已结束时段状态 | -| WaitlistExpireTask | 每分钟 | 处理候补转正超时 | -| BookingRemindTask | 每小时 | 发送预约提醒通知 | -| SlotEndTask | 每小时 | 标记缺席会员 | -| InventoryPreloadTask | 开抢前5分钟 | 预热热门课程库存 | - ---- - -## 十、附录 - -### 10.1 枚举定义 - -```java -public enum ResourceType { - GROUP_CLASS(1, "团课"), - PRIVATE(2, "私教"), - VENUE(3, "场地"), - ONLINE(4, "线上"); -} - -public enum SlotStatus { - AVAILABLE(1, "可预约"), - FULL(2, "已满"), - CANCELLED(3, "已取消"), - ENDED(4, "已结束"); -} - -public enum BookingStatus { - BOOKED(1, "已预约"), - CANCELLED(2, "已取消"), - COMPLETED(3, "已完成"), - EXPIRED(4, "已过期"); -} - -public enum CheckinStatus { - NOT_CHECKED(0, "未签到"), - CHECKED(1, "已签到"), - LATE(2, "迟到"), - ABSENT(3, "缺席"); -} - -public enum PriceType { - TIMES(1, "扣次"), - DURATION(2, "扣时长"), - AMOUNT(3, "扣金额"); -} -``` - -### 10.2 异常定义 - -```java -public class BookingException extends BusinessException { - - public static final BookingException SLOT_NOT_FOUND = - new BookingException(40201, "时段不存在"); - - public static final BookingException SLOT_NOT_AVAILABLE = - new BookingException(40202, "时段不可预约"); - - public static final BookingException SLOT_FULL = - new BookingException(40203, "课程已满"); - - public static final BookingException ALREADY_BOOKED = - new BookingException(40204, "已预约该课程"); - - public static final BookingException BOOKING_NOT_FOUND = - new BookingException(40205, "预约记录不存在"); - - public static final BookingException CANNOT_CANCEL = - new BookingException(40206, "无法取消预约"); - - public static final BookingException CANNOT_CHECKIN = - new BookingException(40207, "无法签到"); -} -``` - ---- - -## 十一、版本历史 - -| 版本 | 日期 | 作者 | 变更内容 | -|------|------|------|---------| -| v1.0 | 2026-02-28 | 张翔 | 初稿 | - ---- - -*文档结束* diff --git a/docs/design/LLD-付费订阅版系统详细设计.md b/docs/design/technical/T-ILD-付费订阅版-技术实现详细设计.md similarity index 61% rename from docs/design/LLD-付费订阅版系统详细设计.md rename to docs/design/technical/T-ILD-付费订阅版-技术实现详细设计.md index 03be989..125027f 100644 --- a/docs/design/LLD-付费订阅版系统详细设计.md +++ b/docs/design/technical/T-ILD-付费订阅版-技术实现详细设计.md @@ -1,27 +1,26 @@ -# 健身房管理系统付费订阅版详细设计文档(LLD) +# 健身房管理系统付费订阅版技术实现详细设计文档(T-ILD) -> 文档编号: GYM-LLD-SUBSCRIPTION-001 +> 文档编号: GYM-T-ILD-SUBSCRIPTION-001 > 版本: v1.0 -> 日期: 2026-03-04 +> 日期: 2026-03-08 > 作者: 张翔 -> 状态: 初稿 +> 状态: 已发布 --- ## 文档修订历史 -| 版本 | 日期 | 作者 | 修订内容 | -| ---- | ---------- | ---- | -------- | -| v1.0 | 2026-03-04 | 张翔 | 创建付费订阅版详细设计 | +| 版本 | 日期 | 作者 | 修订内容 | +| ---- | ---------- | ---- | ---------------------- | +| v1.0 | 2026-03-08 | 张翔 | 创建付费订阅版技术实现详细设计文档 | --- ## 参考文档 - 《健身房管理系统付费订阅版产品设计文档》 GYM-PRD-SUBSCRIPTION-001 -- 《健身房管理系统付费订阅版业务概要设计文档》 GYM-HLD-SUBSCRIPTION-001 -- 《健身房管理系统详细设计文档》 GYM-LLD-000 -- 《订阅与配置模块详细设计文档》 GYM-LLD-004 +- 《健身房管理系统付费订阅版业务概要设计文档》 GYM-B-HLD-SUBSCRIPTION-001 +- 《健身房管理系统付费订阅版业务详细设计文档》 GYM-B-LLD-SUBSCRIPTION-001 - Spring Boot 3 官方文档 - R2DBC 规范文档 - PostgreSQL 官方文档 @@ -30,63 +29,75 @@ ## 一、系统架构设计 +### 1.0 付费订阅版性能与架构特点 + +#### 1.0.1 性能目标 + +| 指标类型 | 指标项 | 目标值 | 说明 | +|---------|--------|--------|------| +| **应用指标** | 并发数 | ≤ 500 | 付费订阅版支持500并发用户 | +| **应用指标** | API响应时间 | ≤ 500ms | 95%请求响应时间 | +| **应用指标** | 系统可用性 | ≥ 99.9% | 年度目标 | +| **数据库指标** | 连接池大小 | 100 | R2DBC连接池 | +| **数据库指标** | 查询响应时间 | ≤ 200ms | 95%查询响应时间 | +| **缓存指标** | 缓存命中率 | ≥ 85% | Redis缓存 | +| **缓存指标** | 缓存响应时间 | ≤ 10ms | Redis缓存 | + +#### 1.0.2 架构特点 + +付费订阅版采用增强型架构设计,满足中大型健身房和连锁品牌的需求: + +**1. 单体应用架构(可扩展为分布式)** +- 支持多门店管理 +- 支持跨店数据同步 +- 数据隔离通过租户ID和门店ID实现 + +**2. 增强资源配置** +- 数据库连接池:100个连接 +- Redis缓存:4GB内存 +- RabbitMQ队列:多队列集群模式 +- Elasticsearch:3节点集群部署 + +**3. 扩展性增强** +- 支持多门店管理 +- 支持分布式部署(可选) +- 支持高可用集群(可选) +- 并发用户数提升至500 + +#### 1.0.3 与基础版的差异 + +| 维度 | 基础版 | 付费订阅版 | +|------|--------|-----------| +| **并发用户数** | 100 | 500 | +| **数据库连接池** | 20 | 100 | +| **Redis内存** | 1GB | 4GB | +| **RabbitMQ队列** | 单队列 | 多队列集群 | +| **Elasticsearch** | 单节点 | 3节点集群 | +| **多门店支持** | 不支持 | 支持 | +| **分布式部署** | 不支持 | 支持 | +| **高可用集群** | 不支持 | 支持 | + +--- + ### 1.1 总体架构 采用分层架构 + 模块化设计的单体应用: -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 付费订阅版单体应用架构 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 客户端层 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 会员小程序 (uniapp+Vue3) │ │ -│ │ • 教练端App (uniapp+Vue3) │ │ -│ │ • 管理后台PC (Vue3+Vite) │ │ -│ │ • 硬件设备 (人脸/NFC) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ Presentation Layer (WebFlux) │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • Controller • Router • Filter • Validator │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ Application Layer (业务编排) │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • Service • Facade • Orchestrator • 事务管理 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ Domain Layer (领域模型) │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • Entity • Value Object • Domain Service • Repository │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ Infrastructure Layer (基础设施) │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • Repository (R2DBC) • Cache (Redis) │ │ -│ │ • Message (RabbitMQ) • Search (Elasticsearch) │ │ -│ │ • File (OSS) • Distributed Lock │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 外部服务层 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • PostgreSQL • Redis • RabbitMQ • Elasticsearch │ │ -│ │ • 微信开放平台 • 短信服务 • 支付服务 • OSS存储 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ +```mermaid +flowchart TB + subgraph 付费订阅版单体应用架构 + A[客户端层
• 会员小程序 uniapp+Vue3
• 教练端App uniapp+Vue3
• 管理后台PC Vue3+Vite
• 硬件设备 人脸/NFC] + B[Presentation Layer WebFlux
• Controller
• Router
• Filter
• Validator] + C[Application Layer 业务编排
• Service
• Facade
• Orchestrator
• 事务管理] + D[Domain Layer 领域模型
• Entity
• Value Object
• Domain Service
• Repository] + E[Infrastructure Layer 基础设施
• Repository R2DBC
• Cache Redis
• Message RabbitMQ
• Search Elasticsearch
• File OSS
• Distributed Lock] + F[外部服务层
• PostgreSQL
• Redis
• RabbitMQ
• Elasticsearch
• 微信开放平台
• 短信服务
• 支付服务
• OSS存储] + A --> B + B --> C + C --> D + D --> E + E --> F + end ``` --- @@ -119,7 +130,7 @@ CREATE TABLE tenant_module_config ( created_by BIGINT, updated_by BIGINT, deleted_at TIMESTAMP DEFAULT NULL, - + CONSTRAINT uk_tenant_module UNIQUE (tenant_id, module_code), CONSTRAINT fk_tenant_module_config FOREIGN KEY (tenant_id) REFERENCES tenant(id) ); @@ -142,7 +153,7 @@ CREATE TABLE store_module_config ( created_by BIGINT, updated_by BIGINT, deleted_at TIMESTAMP DEFAULT NULL, - + CONSTRAINT uk_store_module UNIQUE (store_id, module_code), CONSTRAINT fk_store_module_config FOREIGN KEY (store_id) REFERENCES store(id) ); @@ -168,7 +179,7 @@ CREATE TABLE subscription_record ( created_by BIGINT, updated_by BIGINT, deleted_at TIMESTAMP DEFAULT NULL, - + CONSTRAINT uk_subscription_no UNIQUE (subscription_no), CONSTRAINT fk_subscription_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id) ); @@ -183,32 +194,32 @@ CREATE TABLE subscription_record ( @Slf4j @RequiredArgsConstructor public class ConfigQueryService { - + private final TenantModuleConfigRepository tenantModuleConfigRepository; private final StoreModuleConfigRepository storeModuleConfigRepository; private final ConfigMerger configMerger; private final ReactiveRedisTemplate redisTemplate; - + private static final String CACHE_PREFIX = "config:"; private static final Duration CACHE_TTL = Duration.ofMinutes(30); - + /** * 获取模块配置(门店 → 租户 → 默认) */ public Mono getModuleConfig(Long tenantId, Long storeId, String moduleCode) { String cacheKey = buildCacheKey(tenantId, storeId, moduleCode); - + return redisTemplate.opsForValue().get(cacheKey) .switchIfEmpty(Mono.defer(() -> loadModuleConfig(tenantId, storeId, moduleCode)) .flatMap(config -> redisTemplate.opsForValue() .set(cacheKey, config, CACHE_TTL) .thenReturn(config))) - .doOnSuccess(config -> log.debug("获取模块配置成功: tenantId={}, storeId={}, moduleCode={}", + .doOnSuccess(config -> log.debug("获取模块配置成功: tenantId={}, storeId={}, moduleCode={}", tenantId, storeId, moduleCode)) - .doOnError(e -> log.error("获取模块配置失败: tenantId={}, storeId={}, moduleCode={}", + .doOnError(e -> log.error("获取模块配置失败: tenantId={}, storeId={}, moduleCode={}", tenantId, storeId, moduleCode, e)); } - + /** * 加载模块配置 */ @@ -221,14 +232,14 @@ public class ConfigQueryService { .map(storeConfig -> configMerger.mergeConfig(tenantConfig, storeConfig)) .defaultIfEmpty(tenantConfig)); } - + /** * 构建缓存Key */ private String buildCacheKey(Long tenantId, Long storeId, String moduleCode) { return String.format("%s%d:%d:%s", CACHE_PREFIX, tenantId, storeId, moduleCode); } - + /** * 获取默认模块配置 */ @@ -247,7 +258,7 @@ public class ConfigQueryService { ```java @Service public class ConfigMerger { - + /** * 合并配置 */ @@ -255,18 +266,18 @@ public class ConfigMerger { if (storeConfig.getInheritMode() == 1) { return tenantConfig; } - + if (storeConfig.getInheritMode() == 2) { Map mergedData = new HashMap<>(tenantConfig.getConfigData()); mergedData.putAll(storeConfig.getConfigData()); - + return ModuleConfig.builder() .moduleCode(tenantConfig.getModuleCode()) .enabled(storeConfig.isEnabled()) .configData(mergedData) .build(); } - + return ModuleConfig.builder() .moduleCode(tenantConfig.getModuleCode()) .enabled(storeConfig.isEnabled()) @@ -283,12 +294,12 @@ public class ConfigMerger { @Slf4j @RequiredArgsConstructor public class SubscriptionService { - + private final SubscriptionRecordRepository subscriptionRecordRepository; private final TenantModuleConfigRepository tenantModuleConfigRepository; private final PaymentService paymentService; private final MessageService messageService; - + /** * 订阅模块 */ @@ -304,7 +315,7 @@ public class SubscriptionService { .doOnSuccess(record -> log.info("订阅成功: subscriptionNo={}", record.getSubscriptionNo())) .doOnError(e -> log.error("订阅失败: {}", e.getMessage())); } - + /** * 验证订阅 */ @@ -318,7 +329,7 @@ public class SubscriptionService { return Mono.empty(); }); } - + /** * 创建订阅记录 */ @@ -335,10 +346,10 @@ public class SubscriptionService { .endDate(calculateEndDate(request.getBillingCycle())) .status(1) .build(); - + return subscriptionRecordRepository.save(record); } - + /** * 计算结束日期 */ @@ -356,7 +367,7 @@ public class SubscriptionService { return LocalDate.now().plusMonths(1); } } - + /** * 启用模块 */ @@ -370,12 +381,12 @@ public class SubscriptionService { config.setEnabled(true); config.setConfigData(new HashMap<>()); config.setVersion(config.getVersion() + 1); - + return tenantModuleConfigRepository.save(config); }) .then(); } - + /** * 生成订阅号 */ @@ -416,7 +427,7 @@ CREATE TABLE private_class ( created_by BIGINT, updated_by BIGINT, deleted_at TIMESTAMP DEFAULT NULL, - + CONSTRAINT fk_private_class_coach FOREIGN KEY (coach_id) REFERENCES coach(id), CONSTRAINT fk_private_class_member FOREIGN KEY (member_id) REFERENCES member(id) ); @@ -431,26 +442,26 @@ CREATE TABLE private_class ( @Slf4j @RequiredArgsConstructor public class PrivateClassBookingService { - + private final PrivateClassRepository privateClassRepository; private final CoachRepository coachRepository; private final BenefitService benefitService; private final ReactiveRedisTemplate redisTemplate; private final DistributedLockService distributedLockService; - + private static final String LOCK_PREFIX = "lock:private_class:"; private static final Duration LOCK_TTL = Duration.ofSeconds(30); - + /** * 预约私教 */ @Transactional public Mono book(PrivateClassBookingRequest request) { String lockKey = buildLockKey(request.getCoachId(), request.getClassDate(), request.getStartTime()); - + return distributedLockService.acquireLock(lockKey, LOCK_TTL) .flatMap(lock -> validateBookingTime(request.getClassDate(), request.getStartTime()) - .flatMap(v -> validateCoachAvailability(request.getCoachId(), request.getClassDate(), + .flatMap(v -> validateCoachAvailability(request.getCoachId(), request.getClassDate(), request.getStartTime(), request.getEndTime())) .flatMap(v -> validateMemberBenefit(request.getMemberId())) .flatMap(v -> coachRepository.findById(request.getCoachId()) @@ -462,7 +473,7 @@ public class PrivateClassBookingService { .doOnSuccess(privateClass -> log.info("私教预约成功: privateClassId={}", privateClass.getId())) .doOnError(e -> log.error("私教预约失败: {}", e.getMessage())); } - + /** * 验证预约时间 */ @@ -473,11 +484,11 @@ public class PrivateClassBookingService { } return Mono.empty(); } - + /** * 验证教练可用性 */ - private Mono validateCoachAvailability(Long coachId, LocalDate classDate, + private Mono validateCoachAvailability(Long coachId, LocalDate classDate, LocalTime startTime, LocalTime endTime) { return privateClassRepository .isCoachAvailable(coachId, classDate, startTime, endTime) @@ -488,7 +499,7 @@ public class PrivateClassBookingService { return Mono.empty(); }); } - + /** * 验证会员权益 */ @@ -501,7 +512,7 @@ public class PrivateClassBookingService { return Mono.empty(); }); } - + /** * 创建私教课程 */ @@ -518,10 +529,10 @@ public class PrivateClassBookingService { .price(request.getPrice()) .status(1) .build(); - + return privateClassRepository.save(privateClass); } - + /** * 构建锁Key */ @@ -554,7 +565,7 @@ CREATE TABLE venue ( created_by BIGINT, updated_by BIGINT, deleted_at TIMESTAMP DEFAULT NULL, - + CONSTRAINT fk_venue_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id), CONSTRAINT fk_venue_store FOREIGN KEY (store_id) REFERENCES store(id) ); @@ -580,7 +591,7 @@ CREATE TABLE venue_booking ( created_by BIGINT, updated_by BIGINT, deleted_at TIMESTAMP DEFAULT NULL, - + CONSTRAINT uk_venue_booking_no UNIQUE (booking_no), CONSTRAINT fk_venue_booking_venue FOREIGN KEY (venue_id) REFERENCES venue(id), CONSTRAINT fk_venue_booking_member FOREIGN KEY (member_id) REFERENCES member(id) @@ -615,7 +626,7 @@ CREATE TABLE online_course ( created_by BIGINT, updated_by BIGINT, deleted_at TIMESTAMP DEFAULT NULL, - + CONSTRAINT fk_online_course_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id) ); ``` @@ -647,7 +658,7 @@ CREATE TABLE marketing_activity ( created_by BIGINT, updated_by BIGINT, deleted_at TIMESTAMP DEFAULT NULL, - + CONSTRAINT fk_marketing_activity_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id) ); ``` @@ -667,7 +678,7 @@ CREATE TABLE marketing_activity_participant ( created_by BIGINT, updated_by BIGINT, deleted_at TIMESTAMP DEFAULT NULL, - + CONSTRAINT uk_activity_participant UNIQUE (activity_id, member_id), CONSTRAINT fk_activity_participant_activity FOREIGN KEY (activity_id) REFERENCES marketing_activity(id), CONSTRAINT fk_activity_participant_member FOREIGN KEY (member_id) REFERENCES member(id) @@ -681,23 +692,23 @@ CREATE TABLE marketing_activity_participant ( ```java @Service public class MarketingActivityService { - + @Autowired private MarketingActivityRepository marketingActivityRepository; - + @Autowired private MarketingActivityParticipantRepository participantRepository; - + @Autowired private BenefitService benefitService; - + /** * 创建营销活动 */ @Transactional public MarketingActivity createActivity(MarketingActivityCreateRequest request) { validateActivityTime(request.getStartDate(), request.getEndDate()); - + MarketingActivity activity = new MarketingActivity(); activity.setTenantId(request.getTenantId()); activity.setName(request.getName()); @@ -708,10 +719,10 @@ public class MarketingActivityService { activity.setRules(request.getRules()); activity.setRewards(request.getRewards()); activity.setStatus(1); - + return marketingActivityRepository.save(activity); } - + /** * 参与营销活动 */ @@ -719,25 +730,25 @@ public class MarketingActivityService { public MarketingActivityParticipant participate(Long activityId, Long memberId) { MarketingActivity activity = marketingActivityRepository.findById(activityId) .orElseThrow(() -> new BusinessException("活动不存在")); - + validateActivityStatus(activity); validateActivityTime(activity); validateParticipant(activityId, memberId); - + MarketingActivityParticipant participant = new MarketingActivityParticipant(); participant.setTenantId(activity.getTenantId()); participant.setActivityId(activityId); participant.setMemberId(memberId); participant.setParticipatedAt(LocalDateTime.now()); participant.setRewardStatus(1); - + participant = participantRepository.save(participant); - + grantReward(activity, memberId); - + return participant; } - + /** * 验证活动时间 */ @@ -746,7 +757,7 @@ public class MarketingActivityService { throw new BusinessException("活动开始时间不能晚于结束时间"); } } - + /** * 验证活动状态 */ @@ -755,7 +766,7 @@ public class MarketingActivityService { throw new BusinessException("活动未开始或已结束"); } } - + /** * 验证活动时间 */ @@ -765,7 +776,7 @@ public class MarketingActivityService { throw new BusinessException("活动未开始或已结束"); } } - + /** * 验证参与者 */ @@ -774,7 +785,7 @@ public class MarketingActivityService { throw new BusinessException("已参与过该活动"); } } - + /** * 发放奖励 */ @@ -782,7 +793,7 @@ public class MarketingActivityService { Map rewards = activity.getRewards(); String rewardType = (String) rewards.get("type"); Object rewardValue = rewards.get("value"); - + switch (rewardType) { case "card": benefitService.grantCard(memberId, (String) rewardValue); @@ -802,6 +813,162 @@ public class MarketingActivityService { --- +### 4.2 智能获客工具模块 + +#### 4.2.1 数据模型设计 + +**获客活动表 (customer_acquisition)** + +```sql +CREATE TABLE customer_acquisition ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + name VARCHAR(128) NOT NULL, + type SMALLINT NOT NULL, + description TEXT, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + config JSONB NOT NULL, + status SMALLINT DEFAULT 1, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + created_by BIGINT, + updated_by BIGINT, + deleted_at TIMESTAMP DEFAULT NULL, + + CONSTRAINT fk_customer_acquisition_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id) +); +``` + +**推荐记录表 (referral_record)** + +```sql +CREATE TABLE referral_record ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + activity_id BIGINT, + referrer_id BIGINT NOT NULL, + referee_id BIGINT, + referral_code VARCHAR(32) NOT NULL, + status SMALLINT DEFAULT 1, + reward_status SMALLINT DEFAULT 1, + reward_amount DECIMAL(10, 2), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + deleted_at TIMESTAMP DEFAULT NULL, + + CONSTRAINT fk_referral_record_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id), + CONSTRAINT fk_referral_record_activity FOREIGN KEY (activity_id) REFERENCES customer_acquisition(id), + CONSTRAINT fk_referral_record_referrer FOREIGN KEY (referrer_id) REFERENCES member(id), + CONSTRAINT fk_referral_record_referee FOREIGN KEY (referee_id) REFERENCES member(id) +); +``` + +#### 4.2.2 核心服务设计 + +**智能获客服务** + +```java +@Service +public class CustomerAcquisitionService { + + @Autowired + private CustomerAcquisitionRepository customerAcquisitionRepository; + + @Autowired + private ReferralRecordRepository referralRecordRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private BenefitService benefitService; + + /** + * 创建获客活动 + */ + public CustomerAcquisition createAcquisitionActivity(Long tenantId, CustomerAcquisitionDTO dto) { + CustomerAcquisition activity = new CustomerAcquisition(); + activity.setTenantId(tenantId); + activity.setName(dto.getName()); + activity.setType(dto.getType()); + activity.setDescription(dto.getDescription()); + activity.setStartDate(dto.getStartDate()); + activity.setEndDate(dto.getEndDate()); + activity.setConfig(dto.getConfig()); + activity.setStatus(1); + + return customerAcquisitionRepository.save(activity); + } + + /** + * 生成推荐码 + */ + public String generateReferralCode(Long memberId, Long activityId) { + String referralCode = generateUniqueCode(); + + ReferralRecord record = new ReferralRecord(); + record.setTenantId(getTenantIdByMemberId(memberId)); + record.setActivityId(activityId); + record.setReferrerId(memberId); + record.setReferralCode(referralCode); + record.setStatus(1); + record.setRewardStatus(1); + + referralRecordRepository.save(record); + + return referralCode; + } + + /** + * 处理推荐关系 + */ + public void processReferral(Long memberId, String referralCode) { + ReferralRecord record = referralRecordRepository.findByReferralCodeAndStatus(referralCode, 1); + + if (record == null) { + throw new BusinessException("推荐码无效"); + } + + record.setRefereeId(memberId); + record.setStatus(2); + + referralRecordRepository.save(record); + + CustomerAcquisition activity = customerAcquisitionRepository.findById(record.getActivityId()).orElse(null); + if (activity != null && activity.getConfig() != null) { + JSONObject config = activity.getConfig(); + if (config.containsKey("rewardAmount")) { + BigDecimal rewardAmount = config.getBigDecimal("rewardAmount"); + record.setRewardAmount(rewardAmount); + record.setRewardStatus(2); + + benefitService.grantPoints(record.getReferrerId(), rewardAmount.intValue()); + } + } + + referralRecordRepository.save(record); + } + + /** + * 生成唯一推荐码 + */ + private String generateUniqueCode() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase(); + } + + /** + * 根据会员ID获取租户ID + */ + private Long getTenantIdByMemberId(Long memberId) { + Member member = memberRepository.findById(memberId).orElse(null); + return member != null ? member.getTenantId() : null; + } +} +``` + +--- + ## 五、数据智能类模块设计 ### 5.1 高级数据分析模块 @@ -813,59 +980,59 @@ public class MarketingActivityService { ```java @Service public class AdvancedDataAnalysisService { - + @Autowired private MemberRepository memberRepository; - + @Autowired private BookingRecordRepository bookingRecordRepository; - + @Autowired private CheckInRecordRepository checkInRecordRepository; - + /** * 会员留存分析 */ public MemberRetentionAnalysis analyzeMemberRetention(Long tenantId, Long storeId, LocalDate startDate, LocalDate endDate) { List members = memberRepository.findByTenantIdAndStoreIdAndCreatedAtBetween( tenantId, storeId, startDate.atStartOfDay(), endDate.atTime(23, 59, 59)); - + Map retentionData = new HashMap<>(); - + for (Member member : members) { int retentionDays = calculateRetentionDays(member.getCreatedAt(), LocalDate.now()); retentionData.put(retentionDays, retentionData.getOrDefault(retentionDays, 0L) + 1); } - + MemberRetentionAnalysis analysis = new MemberRetentionAnalysis(); analysis.setTotalMembers(members.size()); analysis.setRetentionData(retentionData); - + return analysis; } - + /** * 计算留存天数 */ private int calculateRetentionDays(LocalDateTime createdAt, LocalDate now) { return (int) ChronoUnit.DAYS.between(createdAt.toLocalDate(), now); } - + /** * 预约转化率分析 */ public BookingConversionAnalysis analyzeBookingConversion(Long tenantId, Long storeId, LocalDate startDate, LocalDate endDate) { long totalBookings = bookingRecordRepository.countByTenantIdAndStoreIdAndBookedAtBetween( tenantId, storeId, startDate.atStartOfDay(), endDate.atTime(23, 59, 59)); - + long totalCheckIns = checkInRecordRepository.countByTenantIdAndStoreIdAndCheckInAtBetween( tenantId, storeId, startDate.atStartOfDay(), endDate.atTime(23, 59, 59)); - + BookingConversionAnalysis analysis = new BookingConversionAnalysis(); analysis.setTotalBookings(totalBookings); analysis.setTotalCheckIns(totalCheckIns); analysis.setConversionRate(totalBookings > 0 ? (double) totalCheckIns / totalBookings : 0.0); - + return analysis; } } @@ -873,15 +1040,262 @@ public class AdvancedDataAnalysisService { --- +### 5.2 智能体测数据联动模块 + +#### 5.2.1 数据模型设计 + +**体测数据表 (body_composition)** + +```sql +CREATE TABLE body_composition ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + store_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + device_type VARCHAR(32) NOT NULL, + device_id VARCHAR(64), + test_date TIMESTAMP NOT NULL, + height DECIMAL(5, 2), + weight DECIMAL(5, 2), + body_fat_rate DECIMAL(5, 2), + muscle_mass DECIMAL(5, 2), + water_content DECIMAL(5, 2), + bone_mass DECIMAL(5, 2), + bmi DECIMAL(5, 2), + raw_data JSONB, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + deleted_at TIMESTAMP DEFAULT NULL, + + CONSTRAINT fk_body_composition_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id), + CONSTRAINT fk_body_composition_store FOREIGN KEY (store_id) REFERENCES store(id), + CONSTRAINT fk_body_composition_member FOREIGN KEY (member_id) REFERENCES member(id) +); +``` + +**体测报告表 (body_test_report)** + +```sql +CREATE TABLE body_test_report ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + store_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + test_data_id BIGINT NOT NULL, + report_no VARCHAR(32) NOT NULL, + report_content JSONB NOT NULL, + analysis_result JSONB NOT NULL, + suggestions TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + deleted_at TIMESTAMP DEFAULT NULL, + + CONSTRAINT fk_body_test_report_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id), + CONSTRAINT fk_body_test_report_store FOREIGN KEY (store_id) REFERENCES store(id), + CONSTRAINT fk_body_test_report_member FOREIGN KEY (member_id) REFERENCES member(id), + CONSTRAINT fk_body_test_report_test_data FOREIGN KEY (test_data_id) REFERENCES body_composition(id) +); +``` + +#### 5.2.2 核心服务设计 + +**体测数据服务** + +```java +@Service +public class BodyCompositionService { + + @Autowired + private BodyCompositionRepository bodyCompositionRepository; + + @Autowired + private BodyTestReportRepository bodyTestReportRepository; + + @Autowired + private MemberRepository memberRepository; + + /** + * 接收体测数据 + */ + @Transactional + public BodyComposition receiveBodyData(BodyDataDTO dto) { + BodyComposition bodyData = new BodyComposition(); + bodyData.setTenantId(dto.getTenantId()); + bodyData.setStoreId(dto.getStoreId()); + bodyData.setMemberId(dto.getMemberId()); + bodyData.setDeviceType(dto.getDeviceType()); + bodyData.setDeviceId(dto.getDeviceId()); + bodyData.setTestDate(dto.getTestDate()); + bodyData.setHeight(dto.getHeight()); + bodyData.setWeight(dto.getWeight()); + bodyData.setBodyFatRate(dto.getBodyFatRate()); + bodyData.setMuscleMass(dto.getMuscleMass()); + bodyData.setWaterContent(dto.getWaterContent()); + bodyData.setBoneMass(dto.getBoneMass()); + bodyData.setBmi(calculateBMI(dto.getHeight(), dto.getWeight())); + bodyData.setRawData(dto.getRawData()); + + return bodyCompositionRepository.save(bodyData); + } + + /** + * 生成体测报告 + */ + @Transactional + public BodyTestReport generateReport(Long testDataId) { + BodyComposition bodyData = bodyCompositionRepository.findById(testDataId).orElse(null); + + if (bodyData == null) { + throw new BusinessException("体测数据不存在"); + } + + BodyTestReport report = new BodyTestReport(); + report.setTenantId(bodyData.getTenantId()); + report.setStoreId(bodyData.getStoreId()); + report.setMemberId(bodyData.getMemberId()); + report.setTestDataId(testDataId); + report.setReportNo(generateReportNo(bodyData.getTenantId())); + + JSONObject reportContent = generateReportContent(bodyData); + report.setReportContent(reportContent); + + JSONObject analysisResult = analyzeBodyData(bodyData); + report.setAnalysisResult(analysisResult); + + String suggestions = generateSuggestions(analysisResult); + report.setSuggestions(suggestions); + + return bodyTestReportRepository.save(report); + } + + /** + * 查询会员体测历史 + */ + public List getMemberBodyHistory(Long memberId, LocalDate startDate, LocalDate endDate) { + return bodyCompositionRepository.findByMemberIdAndTestDateBetweenOrderByTestDateDesc( + memberId, + startDate.atStartOfDay(), + endDate.atTime(23, 59, 59) + ); + } + + /** + * 计算BMI + */ + private BigDecimal calculateBMI(BigDecimal height, BigDecimal weight) { + if (height == null || weight == null || height.compareTo(BigDecimal.ZERO) == 0) { + return null; + } + BigDecimal heightInMeters = height.divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP); + return weight.divide(heightInMeters.multiply(heightInMeters), 2, RoundingMode.HALF_UP); + } + + /** + * 生成报告内容 + */ + private JSONObject generateReportContent(BodyComposition bodyData) { + JSONObject content = new JSONObject(); + content.put("height", bodyData.getHeight()); + content.put("weight", bodyData.getWeight()); + content.put("bodyFatRate", bodyData.getBodyFatRate()); + content.put("muscleMass", bodyData.getMuscleMass()); + content.put("waterContent", bodyData.getWaterContent()); + content.put("boneMass", bodyData.getBoneMass()); + content.put("bmi", bodyData.getBmi()); + return content; + } + + /** + * 分析体测数据 + */ + private JSONObject analyzeBodyData(BodyComposition bodyData) { + JSONObject analysis = new JSONObject(); + + if (bodyData.getBmi() != null) { + String bmiStatus = analyzeBMI(bodyData.getBmi()); + analysis.put("bmiStatus", bmiStatus); + } + + if (bodyData.getBodyFatRate() != null) { + String bodyFatStatus = analyzeBodyFatRate(bodyData.getBodyFatRate()); + analysis.put("bodyFatStatus", bodyFatStatus); + } + + return analysis; + } + + /** + * 分析BMI + */ + private String analyzeBMI(BigDecimal bmi) { + if (bmi.compareTo(new BigDecimal("18.5")) < 0) { + return "偏瘦"; + } else if (bmi.compareTo(new BigDecimal("24")) < 0) { + return "正常"; + } else if (bmi.compareTo(new BigDecimal("28")) < 0) { + return "偏胖"; + } else { + return "肥胖"; + } + } + + /** + * 分析体脂率 + */ + private String analyzeBodyFatRate(BigDecimal bodyFatRate) { + if (bodyFatRate.compareTo(new BigDecimal("10")) < 0) { + return "偏低"; + } else if (bodyFatRate.compareTo(new BigDecimal("20")) < 0) { + return "正常"; + } else if (bodyFatRate.compareTo(new BigDecimal("25")) < 0) { + return "偏高"; + } else { + return "过高"; + } + } + + /** + * 生成建议 + */ + private String generateSuggestions(JSONObject analysis) { + StringBuilder suggestions = new StringBuilder(); + + String bmiStatus = analysis.getString("bmiStatus"); + if ("偏瘦".equals(bmiStatus)) { + suggestions.append("建议增加营养摄入,适当进行力量训练。"); + } else if ("偏胖".equals(bmiStatus) || "肥胖".equals(bmiStatus)) { + suggestions.append("建议控制饮食,增加有氧运动。"); + } + + String bodyFatStatus = analysis.getString("bodyFatStatus"); + if ("偏高".equals(bodyFatStatus) || "过高".equals(bodyFatStatus)) { + suggestions.append("建议进行减脂训练,注意饮食控制。"); + } + + return suggestions.toString(); + } + + /** + * 生成报告编号 + */ + private String generateReportNo(Long tenantId) { + String timestamp = String.valueOf(System.currentTimeMillis()); + return "R" + tenantId + timestamp; + } +} +``` + +--- + ## 六、营销分析与预测模块设计 ### 6.1 模块概述 营销分析与预测模块是付费订阅版的高级功能模块,负责: -- 营销精算模型预测促销策略 +- 基于机器学习算法的营销精算模型预测促销策略 - 多维度自定义促销活动 -- 促销活动效果预测 +- 基于深度学习的促销活动效果预测 ### 6.2 数据模型设计 @@ -902,7 +1316,7 @@ CREATE TABLE marketing_prediction ( created_by BIGINT, updated_by BIGINT, deleted_at TIMESTAMP DEFAULT NULL, - + CONSTRAINT uk_marketing_prediction_no UNIQUE (prediction_no), CONSTRAINT fk_marketing_prediction_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id) ); @@ -930,7 +1344,7 @@ CREATE TABLE promotion_activity ( created_by BIGINT, updated_by BIGINT, deleted_at TIMESTAMP DEFAULT NULL, - + CONSTRAINT fk_promotion_activity_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id) ); ``` @@ -942,28 +1356,32 @@ CREATE TABLE promotion_activity ( ```java @Service public class MarketingActuarialModelService { - + @Autowired private MarketingPredictionRepository marketingPredictionRepository; - + @Autowired private MemberRepository memberRepository; - + @Autowired private BookingRecordRepository bookingRecordRepository; - + @Autowired private CheckInRecordRepository checkInRecordRepository; - + + @Autowired + private MachineLearningModelService machineLearningModelService; + /** * 预测促销策略 + * 基于机器学习算法进行促销策略预测 */ @Transactional public MarketingPrediction predictPromotionStrategy(PromotionPredictionRequest request) { Map historicalData = collectHistoricalData(request.getTenantId(), request.getStoreId()); - - Map predictedData = runPredictionModel(historicalData, request.getParameters()); - + + Map predictedData = machineLearningModelService.runPredictionModel(historicalData, request.getParameters()); + MarketingPrediction prediction = new MarketingPrediction(); prediction.setTenantId(request.getTenantId()); prediction.setPredictionNo(generatePredictionNo(request.getTenantId())); @@ -972,67 +1390,42 @@ public class MarketingActuarialModelService { prediction.setPredictedData(predictedData); prediction.setAccuracy(calculateAccuracy(historicalData, predictedData)); prediction.setModelVersion("v1.0"); - + return marketingPredictionRepository.save(prediction); } - + /** * 收集历史数据 */ private Map collectHistoricalData(Long tenantId, Long storeId) { Map historicalData = new HashMap<>(); - + LocalDate endDate = LocalDate.now(); LocalDate startDate = endDate.minusMonths(6); - + long totalMembers = memberRepository.countByTenantIdAndStoreIdAndCreatedAtBetween( tenantId, storeId, startDate.atStartOfDay(), endDate.atTime(23, 59, 59)); - + long totalBookings = bookingRecordRepository.countByTenantIdAndStoreIdAndBookedAtBetween( tenantId, storeId, startDate.atStartOfDay(), endDate.atTime(23, 59, 59)); - + long totalCheckIns = checkInRecordRepository.countByTenantIdAndStoreIdAndCheckInAtBetween( tenantId, storeId, startDate.atStartOfDay(), endDate.atTime(23, 59, 59)); - + historicalData.put("totalMembers", totalMembers); historicalData.put("totalBookings", totalBookings); historicalData.put("totalCheckIns", totalCheckIns); - + return historicalData; } - - /** - * 运行预测模型 - */ - private Map runPredictionModel(Map historicalData, Map parameters) { - Map predictedData = new HashMap<>(); - - Long totalMembers = (Long) historicalData.get("totalMembers"); - Long totalBookings = (Long) historicalData.get("totalBookings"); - Long totalCheckIns = (Long) historicalData.get("totalCheckIns"); - - Double discountRate = (Double) parameters.get("discountRate"); - Integer durationDays = (Integer) parameters.get("durationDays"); - - double predictedNewMembers = totalMembers * 0.1 * (1 + discountRate * 2); - double predictedBookings = totalBookings * 1.2 * (1 + discountRate * 1.5); - double predictedCheckIns = totalCheckIns * 1.15 * (1 + discountRate * 1.3); - - predictedData.put("predictedNewMembers", Math.round(predictedNewMembers)); - predictedData.put("predictedBookings", Math.round(predictedBookings)); - predictedData.put("predictedCheckIns", Math.round(predictedCheckIns)); - predictedData.put("predictedRevenue", predictedBookings * 100 * (1 - discountRate)); - - return predictedData; - } - + /** * 计算准确率 */ private BigDecimal calculateAccuracy(Map historicalData, Map predictedData) { return BigDecimal.valueOf(0.85); } - + /** * 生成预测号 */ @@ -1050,13 +1443,13 @@ public class MarketingActuarialModelService { ```java @Service public class PromotionActivityService { - + @Autowired private PromotionActivityRepository promotionActivityRepository; - + @Autowired private MarketingActuarialModelService marketingActuarialModelService; - + /** * 创建促销活动 */ @@ -1073,16 +1466,16 @@ public class PromotionActivityService { activity.setDiscountRule(request.getDiscountRule()); activity.setBudget(request.getBudget()); activity.setStatus(1); - + activity = promotionActivityRepository.save(activity); - + if (request.isPredictEffect()) { predictActivityEffect(activity); } - + return activity; } - + /** * 预测活动效果 */ @@ -1091,9 +1484,9 @@ public class PromotionActivityService { predictionRequest.setTenantId(activity.getTenantId()); predictionRequest.setActivityType(activity.getType()); predictionRequest.setParameters(activity.getDiscountRule()); - + MarketingPrediction prediction = marketingActuarialModelService.predictPromotionStrategy(predictionRequest); - + activity.setPredictedData(prediction.getPredictedData()); promotionActivityRepository.save(activity); } @@ -1106,30 +1499,24 @@ public class PromotionActivityService { ### 7.1 缓存设计 -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 缓存策略 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 本地缓存 (Caffeine) │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 会员信息缓存 (TTL: 30分钟) │ │ -│ │ • 会员卡缓存 (TTL: 30分钟) │ │ -│ │ • 课程信息缓存 (TTL: 1小时) │ │ -│ │ • 配置信息缓存 (TTL: 1小时) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 分布式缓存 (Redis) │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 验证码缓存 (TTL: 5分钟) │ │ -│ │ • 令牌缓存 (TTL: 24小时) │ │ -│ │ • 限流计数器 (TTL: 1分钟) │ │ -│ │ • 预测结果缓存 (TTL: 1小时) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ +```mermaid +graph TB + subgraph LocalCache["本地缓存 (Caffeine)"] + LC1["会员信息缓存
TTL: 30分钟"] + LC2["会员卡缓存
TTL: 30分钟"] + LC3["课程信息缓存
TTL: 1小时"] + LC4["配置信息缓存
TTL: 1小时"] + end + + subgraph RedisCache["分布式缓存 (Redis)"] + RC1["验证码缓存
TTL: 5分钟"] + RC2["令牌缓存
TTL: 24小时"] + RC3["限流计数器
TTL: 1分钟"] + RC4["预测结果缓存
TTL: 1小时"] + end + + style LocalCache fill:#e1f5ff + style RedisCache fill:#fff4e1 ``` --- @@ -1223,8 +1610,170 @@ Response: "predictedRevenue": 48000.00 }, "accuracy": 0.85 +} +``` + +### 8.3 智能获客工具模块API + +#### 8.3.1 创建获客活动 + +``` +POST /api/v1/customer-acquisition/activities + +Request: +{ + "tenantId": 1, + "name": "节后健身潮获客活动", + "type": 1, + "description": "针对节后健身潮的获客活动", + "startDate": "2026-01-01", + "endDate": "2026-03-31", + "config": { + "rewardAmount": 100, + "maxReferrals": 10 } } + +Response: +{ + "code": 200, + "message": "创建成功", + "data": { + "id": 1, + "name": "节后健身潮获客活动", + "type": 1, + "status": 1 + } +} +``` + +#### 8.3.2 生成推荐码 + +``` +POST /api/v1/customer-acquisition/referral-codes + +Request: +{ + "memberId": 1, + "activityId": 1 +} + +Response: +{ + "code": 200, + "message": "生成成功", + "data": { + "referralCode": "ABC123DEF456" + } +} +``` + +#### 8.3.3 处理推荐关系 + +``` +POST /api/v1/customer-acquisition/referrals + +Request: +{ + "memberId": 2, + "referralCode": "ABC123DEF456" +} + +Response: +{ + "code": 200, + "message": "处理成功", + "data": { + "referrerId": 1, + "refereeId": 2, + "rewardAmount": 100 + } +} +``` + +### 8.4 智能体测数据联动模块API + +#### 8.4.1 接收体测数据 + +``` +POST /api/v1/body-composition/data + +Request: +{ + "tenantId": 1, + "storeId": 1, + "memberId": 1, + "deviceType": "InBody", + "deviceId": "IB001", + "testDate": "2026-03-07T10:00:00", + "height": 175.5, + "weight": 70.2, + "bodyFatRate": 15.5, + "muscleMass": 55.3, + "waterContent": 60.2, + "boneMass": 2.8, + "rawData": {} +} + +Response: +{ + "code": 200, + "message": "接收成功", + "data": { + "id": 1, + "bmi": 22.8 + } +} +``` + +#### 8.4.2 生成体测报告 + +``` +POST /api/v1/body-composition/reports + +Request: +{ + "testDataId": 1 +} + +Response: +{ + "code": 200, + "message": "生成成功", + "data": { + "id": 1, + "reportNo": "R11000000000000001", + "reportContent": {}, + "analysisResult": { + "bmiStatus": "正常", + "bodyFatStatus": "正常" + }, + "suggestions": "建议保持当前运动和饮食习惯。" + } +} +``` + +#### 8.4.3 查询会员体测历史 + +``` +GET /api/v1/body-composition/history?memberId=1&startDate=2026-01-01&endDate=2026-03-07 + +Response: +{ + "code": 200, + "message": "查询成功", + "data": [ + { + "id": 1, + "testDate": "2026-03-07T10:00:00", + "height": 175.5, + "weight": 70.2, + "bodyFatRate": 15.5, + "muscleMass": 55.3, + "bmi": 22.8 + } + ] +} ``` --- @@ -1235,29 +1784,29 @@ Response: #### 9.1.1 订阅模块测试 -| 测试用例 | 输入 | 预期输出 | -|---------|------|---------| -| 正常订阅 | 租户ID、模块代码、计费周期 | 订阅成功 | -| 重复订阅 | 已订阅的模块 | 提示该模块已订阅 | -| 支付失败 | 支付失败 | 提示支付失败 | +| 测试用例 | 输入 | 预期输出 | +| -------- | -------------------------- | ---------------- | +| 正常订阅 | 租户ID、模块代码、计费周期 | 订阅成功 | +| 重复订阅 | 已订阅的模块 | 提示该模块已订阅 | +| 支付失败 | 支付失败 | 提示支付失败 | #### 9.1.2 配置查询测试 -| 测试用例 | 输入 | 预期输出 | -|---------|------|---------| -| 查询租户配置 | 租户ID、模块代码 | 返回租户配置 | +| 测试用例 | 输入 | 预期输出 | +| ------------ | ------------------------ | ---------------- | +| 查询租户配置 | 租户ID、模块代码 | 返回租户配置 | | 查询门店配置 | 租户ID、门店ID、模块代码 | 返回合并后的配置 | -| 查询默认配置 | 不存在的配置 | 返回默认配置 | +| 查询默认配置 | 不存在的配置 | 返回默认配置 | ### 9.2 营销分析与预测模块测试用例 #### 9.2.1 预测促销策略测试 -| 测试用例 | 输入 | 预期输出 | -|---------|------|---------| -| 正常预测 | 租户ID、活动类型、参数 | 预测成功 | -| 历史数据不足 | 新租户 | 提示历史数据不足 | -| 参数无效 | 无效的参数 | 提示参数无效 | +| 测试用例 | 输入 | 预期输出 | +| ------------ | ---------------------- | ---------------- | +| 正常预测 | 租户ID、活动类型、参数 | 预测成功 | +| 历史数据不足 | 新租户 | 提示历史数据不足 | +| 参数无效 | 无效的参数 | 提示参数无效 | --- @@ -1265,58 +1814,60 @@ Response: ### 10.1 部署架构 -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 部署架构 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 负载均衡 (Nginx) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 应用服务器 (Kubernetes) │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • Pod 1 • Pod 2 • Pod 3 • Pod 4 • Pod 5 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 数据库 (PostgreSQL) │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 主库 • 从库 • 从库 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 缓存 (Redis) │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 主节点 • 从节点 • 从节点 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 搜索引擎 (Elasticsearch) │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • 节点1 • 节点2 • 节点3 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ +```mermaid +flowchart TB + LB["负载均衡
(Nginx)"] + + subgraph K8S["应用服务器
(Kubernetes)"] + P1["Pod 1"] + P2["Pod 2"] + P3["Pod 3"] + P4["Pod 4"] + P5["Pod 5"] + end + + subgraph PG["数据库
(PostgreSQL)"] + PG1["主库"] + PG2["从库"] + PG3["从库"] + end + + subgraph Redis["缓存
(Redis)"] + R1["主节点"] + R2["从节点"] + R3["从节点"] + end + + subgraph ES["搜索引擎
(Elasticsearch)"] + ES1["节点1"] + ES2["节点2"] + ES3["节点3"] + end + + LB --> K8S + K8S --> PG + PG --> Redis + Redis --> ES + + style LB fill:#e1f5ff + style K8S fill:#fff4e1 + style PG fill:#f0e1ff + style Redis fill:#e1ffe1 + style ES fill:#ffe1e1 ``` ### 10.2 监控指标 -| 指标类型 | 指标名称 | 阈值 | -|---------|---------|------| -| 系统指标 | CPU使用率 | ≤ 80% | -| 系统指标 | 内存使用率 | ≤ 80% | -| 系统指标 | 磁盘使用率 | ≤ 80% | +| 指标类型 | 指标名称 | 阈值 | +| -------- | ----------- | ------- | +| 系统指标 | CPU使用率 | ≤ 80% | +| 系统指标 | 内存使用率 | ≤ 80% | +| 系统指标 | 磁盘使用率 | ≤ 80% | | 应用指标 | API响应时间 | ≤ 500ms | -| 应用指标 | 错误率 | ≤ 1% | -| 应用指标 | 并发数 | ≤ 500 | -| 业务指标 | 订阅成功率 | ≥ 98% | -| 业务指标 | 预测准确率 | ≥ 75% | +| 应用指标 | 错误率 | ≤ 1% | +| 应用指标 | 并发数 | ≤ 500 | +| 业务指标 | 订阅成功率 | ≥ 98% | +| 业务指标 | 预测准确率 | ≥ 75% | --- @@ -1324,21 +1875,20 @@ Response: ### 11.1 术语定义 -| 术语 | 定义 | -|------|------| -| 订阅模块 | 按需订阅的增值功能模块 | -| 配置继承 | 门店配置继承租户配置的机制 | -| 私教管理 | 私教课程管理、私教预约、私教签到等功能 | -| 营销活动 | 吸引新会员和提升会员活跃度的活动 | -| 营销精算模型 | 基于历史数据预测促销策略的模型 | -| 促销活动效果预测 | 基于历史数据预测促销活动效果 | +| 术语 | 定义 | +| ---------------- | -------------------------------------- | +| 订阅模块 | 按需订阅的增值功能模块 | +| 配置继承 | 门店配置继承租户配置的机制 | +| 私教管理 | 私教课程管理、私教预约、私教签到等功能 | +| 营销活动 | 吸引新会员和提升会员活跃度的活动 | +| 营销精算模型 | 基于历史数据预测促销策略的模型 | +| 促销活动效果预测 | 基于历史数据预测促销活动效果 | ### 11.2 参考文档 - 《健身房管理系统付费订阅版产品设计文档》 GYM-PRD-SUBSCRIPTION-001 -- 《健身房管理系统付费订阅版业务概要设计文档》 GYM-HLD-SUBSCRIPTION-001 -- 《健身房管理系统详细设计文档》 GYM-LLD-000 -- 《订阅与配置模块详细设计文档》 GYM-LLD-004 +- 《健身房管理系统付费订阅版业务概要设计文档》 GYM-B-HLD-SUBSCRIPTION-001 +- 《健身房管理系统付费订阅版业务详细设计文档》 GYM-B-LLD-SUBSCRIPTION-001 - Spring Boot 3 官方文档 - R2DBC 规范文档 - PostgreSQL 官方文档 diff --git a/docs/design/technical/T-ILD-基础版-技术实现详细设计.md b/docs/design/technical/T-ILD-基础版-技术实现详细设计.md new file mode 100644 index 0000000..a3996a6 --- /dev/null +++ b/docs/design/technical/T-ILD-基础版-技术实现详细设计.md @@ -0,0 +1,1009 @@ +# 健身房管理系统基础版技术实现详细设计文档(T-ILD) + +> 文档编号: GYM-T-ILD-BASIC-001 +> 版本: v1.0 +> 日期: 2026-03-08 +> 作者: 张翔 +> 状态: 已发布 + +--- + +## 文档修订历史 + +| 版本 | 日期 | 作者 | 修订内容 | +| ---- | ---------- | ---- | ---------------------- | +| v1.0 | 2026-03-08 | 张翔 | 创建基础版技术实现详细设计文档,整合技术架构和实现细节 | + +--- + +## 一、引言 + +### 1.1 编写目的 + +本文档为健身房管理系统基础版的技术实现详细设计文档(Technical Implementation Level Design),旨在: + +1. 从技术层面描述基础版的系统架构、技术选型、实现细节 +2. 为开发人员提供技术实现指导 +3. 作为架构师、开发工程师的技术参考 + +### 1.2 项目背景 + +健身房管理系统基础版是面向小型工作室、个人教练等场景的核心版本,采用响应式编程架构,保证高并发、低延迟、高可用性。 + +### 1.3 参考文档 + +- 《健身房管理系统基础版产品设计文档》 GYM-PRD-BASIC-001 +- 《健身房管理系统基础版业务概要设计文档》 GYM-B-HLD-BASIC-001 +- 《健身房管理系统基础版业务详细设计文档》 GYM-B-LLD-BASIC-001 +- Spring Boot 3 官方文档 +- Spring WebFlux 官方文档 +- R2DBC 规范文档 +- PostgreSQL 官方文档 + +--- + +## 二、架构决策 + +### 2.1 架构选型 + +经过深入评估,本系统采用以下架构决策: + +| 决策项 | 选择方案 | 理由 | +|-------|---------|------| +| **应用架构** | 单体应用 | 适合当前规模(基础版100并发用户,付费订阅版500并发用户),开发效率高,部署简单,成本低 | +| **编程模型** | 响应式编程(WebFlux + R2DBC) | 高并发能力(10x 提升),低延迟(50% 降低),资源利用率高(75% 降低) | +| **部署方式** | Docker Compose | 一键部署,环境一致性好,回滚快速 | +| **数据库** | PostgreSQL | 金融级数据库,支持 ACID 事务,JSONB 支持灵活配置 | +| **缓存** | Redis | 高性能缓存,支持分布式锁 | +| **消息队列** | RabbitMQ | 成熟稳定,支持延迟消息 | +| **搜索引擎** | Elasticsearch | 全文搜索,适合复杂查询 | +| **监控** | Prometheus + Grafana | 完善的监控体系,可视化好 | + +### 2.2 技术栈 + +#### 核心技术栈 + +| 技术组件 | 版本 | 用途 | +|---------|------|------| +| **Spring Boot** | 3.2.x | 应用框架 | +| **Spring WebFlux** | 3.2.x | 响应式 Web 框架 | +| **Spring Data R2DBC** | 3.2.x | 响应式数据访问 | +| **PostgreSQL R2DBC** | 1.0.0.RELEASE | PostgreSQL 响应式驱动 | +| **Spring Security** | 6.2.x | 安全框架 | +| **Redis Reactive** | 3.2.x | 响应式缓存 | +| **RabbitMQ** | 3.12.x | 消息队列 | +| **Elasticsearch** | 8.11.x | 搜索引擎 | +| **Prometheus** | Latest | 监控指标采集 | +| **Grafana** | Latest | 监控可视化 | +| **Docker** | 24.x | 容器化部署 | +| **Docker Compose** | 2.20.x | 容器编排 | + +#### 开发工具 + +| 工具 | 版本 | 用途 | +|------|------|------| +| **JDK** | 17+ | 运行环境 | +| **Maven** | 3.9.x | 项目构建 | +| **Lombok** | 1.18.x | 代码简化 | +| **MapStruct** | 1.5.x | 对象映射 | +| **Micrometer** | 1.12.x | 监控指标 | +| **SpringDoc OpenAPI** | 2.3.x | API 文档 | + +--- + +### 2.3 基础版性能与架构特点 + +#### 2.3.1 性能目标 + +| 指标类型 | 指标项 | 目标值 | 说明 | +|---------|--------|--------|------| +| **应用指标** | 并发数 | ≤ 100 | 基础版支持100并发用户 | +| **应用指标** | API响应时间 | ≤ 500ms | 95%请求响应时间 | +| **应用指标** | 系统可用性 | ≥ 99.5% | 年度目标 | +| **数据库指标** | 连接池大小 | 20 | R2DBC连接池 | +| **数据库指标** | 查询响应时间 | ≤ 200ms | 95%查询响应时间 | +| **缓存指标** | 缓存命中率 | ≥ 80% | Redis缓存 | +| **缓存指标** | 缓存响应时间 | ≤ 10ms | Redis缓存 | + +#### 2.3.2 架构特点 + +基础版采用轻量级架构设计,满足小型工作室和个人教练的需求: + +**1. 单体应用架构** +- 部署简单,维护成本低 +- 适合单门店场景 +- 数据隔离通过租户ID实现 + +**2. 资源优化配置** +- 数据库连接池:20个连接 +- Redis缓存:1GB内存 +- RabbitMQ队列:单队列模式 +- Elasticsearch:单节点部署 + +**3. 扩展性限制** +- 不支持多门店管理 +- 不支持分布式部署 +- 不支持高可用集群 +- 并发用户数限制100 + +#### 2.3.3 与付费订阅版的差异 + +| 维度 | 基础版 | 付费订阅版 | +|------|--------|-----------| +| **并发用户数** | 100 | 500 | +| **数据库连接池** | 20 | 100 | +| **Redis内存** | 1GB | 4GB | +| **RabbitMQ队列** | 单队列 | 多队列集群 | +| **Elasticsearch** | 单节点 | 3节点集群 | +| **多门店支持** | 不支持 | 支持 | +| **分布式部署** | 不支持 | 支持 | +| **高可用集群** | 不支持 | 支持 | + +--- + +## 三、系统架构设计 + +### 3.1 总体架构 + +采用分层架构 + 模块化设计的单体应用: + +```mermaid +flowchart TB + subgraph 单体应用总体架构 + A[客户端层
• 会员小程序 uniapp+Vue3
• 教练端App uniapp+Vue3
• 管理后台PC Vue3+Vite
• 硬件设备 人脸/NFC] + B[Nginx 反向代理
• 负载均衡
• SSL 终止
• 静态资源
• 限流] + C[Presentation Layer WebFlux
• Controller
• Router
• Filter
• Validator] + D[Application Layer 业务编排
• Service
• Facade
• Orchestrator
• 事务管理] + E[Domain Layer 领域模型
• Entity
• Value Object
• Domain Service
• Repository] + F[Infrastructure Layer 基础设施
• Repository R2DBC
• Cache Redis
• Message RabbitMQ
• Search Elasticsearch
• File OSS
• Distributed Lock] + G[外部服务层
• PostgreSQL
• Redis
• RabbitMQ
• Elasticsearch
• 微信开放平台
• 短信服务
• 支付服务
• OSS存储] + H[监控与运维层
• Prometheus
• Grafana
• 日志收集
• 告警] + A --> B + B --> C + C --> D + D --> E + E --> F + F --> G + G --> H + end +``` + +### 3.2 分层架构详解 + +#### 3.2.1 Presentation Layer(表现层) + +**职责**: +- 接收 HTTP 请求 +- 参数验证 +- 路由转发 +- 响应封装 +- 异常处理 + +**技术实现**: +- Spring WebFlux Router +- Spring Validation +- Spring Security Reactive +- Global Exception Handler + +#### 3.2.2 Application Layer(应用层) + +**职责**: +- 业务逻辑编排 +- 事务管理 +- 跨模块协调 +- 权限校验 + +**技术实现**: +- Service 类 +- @Transactional 注解 +- 分布式锁 +- Saga 模式(跨服务事务) + +#### 3.2.3 Domain Layer(领域层) + +**职责**: +- 领域模型定义 +- 业务规则封装 +- 领域服务 +- 仓储接口定义 + +**技术实现**: +- Entity 类 +- Value Object 类 +- Domain Service 类 +- Repository 接口 + +#### 3.2.4 Infrastructure Layer(基础设施层) + +**职责**: +- 数据访问实现 +- 缓存管理 +- 消息队列 +- 文件存储 +- 外部服务调用 + +**技术实现**: +- R2DBC Repository +- Redis Reactive +- RabbitMQ Reactive +- Elasticsearch Reactive +- OSS SDK + +### 3.3 模块化设计 + +单体应用内部采用模块化设计,为未来拆分微服务做准备: + +``` +gym-manage/ +├── gym-manage-api/ # API 层 +│ ├── controller/ +│ │ ├── member/ # 会员模块 API +│ │ ├── booking/ # 预约模块 API +│ │ ├── checkin/ # 签到模块 API +│ │ ├── benefit/ # 权益模块 API +│ │ ├── subscription/ # 订阅模块 API +│ │ ├── marketing/ # 营销模块 API +│ │ └── analytics/ # 数据分析模块 API +│ ├── dto/ +│ │ ├── request/ # 请求 DTO +│ │ └── response/ # 响应 DTO +│ └── config/ +│ ├── WebFluxConfig.java +│ ├── SecurityConfig.java +│ └── R2dbcConfig.java +│ +├── gym-manage-application/ # 应用层 +│ ├── service/ +│ │ ├── member/ +│ │ ├── booking/ +│ │ ├── checkin/ +│ │ ├── benefit/ +│ │ ├── subscription/ +│ │ ├── marketing/ +│ │ └── analytics/ +│ ├── facade/ +│ └── orchestrator/ +│ +├── gym-manage-domain/ # 领域层 +│ ├── entity/ +│ │ ├── Member.java +│ │ ├── BookingRecord.java +│ │ ├── CheckinRecord.java +│ │ ├── MemberBenefit.java +│ │ ├── SubscriptionRecord.java +│ │ └── ... +│ ├── valueobject/ +│ ├── repository/ +│ │ ├── MemberRepository.java +│ │ ├── BookingRecordRepository.java +│ │ └── ... +│ └── service/ +│ └── DomainService.java +│ +├── gym-manage-infrastructure/ # 基础设施层 +│ ├── repository/ +│ │ └── impl/ +│ │ ├── MemberRepositoryImpl.java +│ │ ├── BookingRecordRepositoryImpl.java +│ │ └── ... +│ ├── cache/ +│ │ └── RedisCacheService.java +│ ├── message/ +│ │ └── RabbitMQService.java +│ ├── search/ +│ │ └── ElasticsearchService.java +│ ├── lock/ +│ │ └── DistributedLockService.java +│ └── config/ +│ ├── R2dbcConfiguration.java +│ ├── RedisConfiguration.java +│ ├── RabbitMQConfiguration.java +│ └── ElasticsearchConfiguration.java +│ +└── gym-manage-main/ # 主启动类 + ├── GymManageApplication.java + └── resources/ + ├── application.yml + ├── application-dev.yml + └── application-prod.yml +``` + +--- + +## 四、响应式编程架构 + +### 4.1 响应式编程模型 + +本系统采用 Project Reactor 作为响应式编程库: + +| 组件 | 类型 | 说明 | +|------|------|------| +| **Mono** | 0-1 个元素 | 表示异步计算结果,返回单个对象或空 | +| **Flux** | 0-N 个元素 | 表示异步数据流,返回多个对象 | +| **Scheduler** | 线程调度器 | 控制异步操作的执行线程 | + +### 4.2 响应式编程规范 + +#### 4.2.1 基本原则 + +1. **永不阻塞** + - 禁止在响应式流中使用 `block()`、`blockFirst()`、`blockLast()` + - 所有 I/O 操作必须使用非阻塞方式 + +2. **链式调用** + - 使用 `flatMap`、`map`、`filter` 等操作符链式调用 + - 避免嵌套的 `subscribe` + +3. **错误处理** + - 使用 `onErrorResume`、`onErrorReturn` 处理错误 + - 避免使用 `try-catch` 捕获响应式异常 + +4. **背压处理** + - 使用 `onBackpressureBuffer`、`onBackpressureDrop` 处理背压 + - 避免内存溢出 + +#### 4.2.2 代码示例 + +**✅ 正确示例**: + +```java +public Mono getMember(Long id) { + return memberRepository.findById(id) + .switchIfEmpty(Mono.error(new BusinessException("会员不存在"))) + .flatMap(member -> loadMemberCards(member.getId())) + .flatMap(member -> loadMemberBenefits(member.getId())) + .doOnSuccess(member -> log.info("查询会员成功: memberId={}", member.getId())) + .doOnError(e -> log.error("查询会员失败: memberId={}", id, e)); +} +``` + +**❌ 错误示例**: + +```java +public Member getMember(Long id) { + // 错误:使用 block() 阻塞 + return memberRepository.findById(id).block(); +} + +public Mono getMember(Long id) { + return memberRepository.findById(id) + .flatMap(member -> { + // 错误:在 flatMap 中使用 block() + List cards = memberCardRepository.findByMemberId(member.getId()).collectList().block(); + return Mono.just(member); + }); +} +``` + +### 4.3 响应式事务管理 + +#### 4.3.1 本地事务 + +使用 `@Transactional` 注解管理本地事务: + +```java +@Service +public class BookingService { + + @Transactional + public Mono bookSlot(BookingRequest request) { + return validateBooking(request) + .flatMap(v -> checkSlotAvailability(request.getSlotId())) + .flatMap(slot -> deductBenefit(request.getMemberId(), slot)) + .flatMap(benefit -> createBookingRecord(request, benefit)) + .flatMap(booking -> updateSlotBookedCount(request.getSlotId())); + } +} +``` + +#### 4.3.2 分布式锁 + +使用 Redis 实现分布式锁: + +```java +@Component +public class RedisDistributedLock { + + private final ReactiveRedisTemplate redisTemplate; + private static final String LOCK_PREFIX = "lock:"; + private static final long DEFAULT_EXPIRE_TIME = 30; + + public Mono tryLock(String key, long expireTime) { + String lockKey = LOCK_PREFIX + key; + String lockValue = UUID.randomUUID().toString(); + + return redisTemplate.opsForValue() + .setIfAbsent(lockKey, lockValue, Duration.ofSeconds(expireTime)) + .flatMap(locked -> { + if (Boolean.TRUE.equals(locked)) { + log.info("获取锁成功: key={}", lockKey); + return Mono.just(true); + } else { + log.warn("获取锁失败: key={}", lockKey); + return Mono.just(false); + } + }); + } + + public Mono unlock(String key) { + String lockKey = LOCK_PREFIX + key; + return redisTemplate.delete(lockKey) + .doOnSuccess(deleted -> { + if (Boolean.TRUE.equals(deleted)) { + log.info("释放锁成功: key={}", lockKey); + } + }) + .then(); + } +} +``` + +#### 4.3.3 Saga 模式(跨模块事务) + +对于跨模块的事务,使用 Saga 模式: + +```java +@Service +public class BookingSaga { + + public Mono execute(BookingRequest request) { + return bookSlot(request) + .flatMap(booking -> sendNotification(booking)) + .flatMap(booking -> updateStatistics(booking)) + .onErrorResume(e -> compensate(request, e)); + } + + private Mono bookSlot(BookingRequest request) { + // 预约逻辑 + } + + private Mono sendNotification(BookingRecord booking) { + // 发送通知 + } + + private Mono updateStatistics(BookingRecord booking) { + // 更新统计 + } + + private Mono compensate(BookingRequest request, Throwable e) { + // 补偿逻辑 + return cancelBooking(request) + .then(Mono.error(e)); + } +} +``` + +--- + +## 五、数据库设计 + +### 5.1 数据库选型 + +本系统采用 PostgreSQL 作为主数据库,原因如下: + +1. **金融级可靠性**:支持 ACID 事务,数据一致性有保障 +2. **JSONB 支持**:灵活存储配置数据,支持复杂查询 +3. **响应式驱动**:R2DBC 提供非阻塞访问 +4. **开源免费**:降低成本 + +### 5.2 核心表设计 + +#### 5.2.1 租户表(tenant) + +| 字段名 | 类型 | 说明 | 约束 | +|-------|------|------|------| +| id | BIGINT | 主键 | PK, AUTO_INCREMENT | +| name | VARCHAR(100) | 租户名称 | NOT NULL | +| logo_url | VARCHAR(500) | Logo地址 | NULL | +| brand_color | VARCHAR(20) | 品牌颜色 | NULL | +| status | TINYINT | 状态(1正常,2禁用) | NOT NULL, DEFAULT 1 | +| created_at | TIMESTAMP | 创建时间 | NOT NULL, DEFAULT CURRENT_TIMESTAMP | +| updated_at | TIMESTAMP | 更新时间 | NOT NULL, DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | + +#### 5.2.2 门店表(store) + +| 字段名 | 类型 | 说明 | 约束 | +|-------|------|------|------| +| id | BIGINT | 主键 | PK, AUTO_INCREMENT | +| tenant_id | BIGINT | 租户ID | FK, NOT NULL | +| name | VARCHAR(100) | 门店名称 | NOT NULL | +| address | VARCHAR(500) | 地址 | NULL | +| phone | VARCHAR(20) | 电话 | NULL | +| status | TINYINT | 状态(1正常,2禁用) | NOT NULL, DEFAULT 1 | +| created_at | TIMESTAMP | 创建时间 | NOT NULL, DEFAULT CURRENT_TIMESTAMP | +| updated_at | TIMESTAMP | 更新时间 | NOT NULL, DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | + +#### 5.2.3 会员表(member) + +| 字段名 | 类型 | 说明 | 约束 | +|-------|------|------|------| +| id | BIGINT | 主键 | PK, AUTO_INCREMENT | +| tenant_id | BIGINT | 租户ID | FK, NOT NULL | +| store_id | BIGINT | 门店ID | FK, NOT NULL | +| member_no | VARCHAR(20) | 会员编号 | UNIQUE, NOT NULL | +| name | VARCHAR(50) | 姓名 | NOT NULL | +| phone | VARCHAR(20) | 手机号 | UNIQUE, NOT NULL | +| gender | TINYINT | 性别(1男,2女) | NULL | +| birthday | DATE | 生日 | NULL | +| height | INT | 身高(cm) | NULL | +| weight | INT | 体重(kg) | NULL | +| fitness_goal | VARCHAR(100) | 健身目标 | NULL | +| status | TINYINT | 状态(1正常,2禁用) | NOT NULL, DEFAULT 1 | +| created_at | TIMESTAMP | 创建时间 | NOT NULL, DEFAULT CURRENT_TIMESTAMP | +| updated_at | TIMESTAMP | 更新时间 | NOT NULL, DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | + +#### 5.2.4 会员卡表(member_card) + +| 字段名 | 类型 | 说明 | 约束 | +|-------|------|------|------| +| id | BIGINT | 主键 | PK, AUTO_INCREMENT | +| member_id | BIGINT | 会员ID | FK, NOT NULL | +| card_type_id | BIGINT | 卡类型ID | FK, NOT NULL | +| card_no | VARCHAR(50) | 卡号 | UNIQUE, NOT NULL | +| start_date | DATE | 开始日期 | NOT NULL | +| end_date | DATE | 结束日期 | NULL | +| status | TINYINT | 状态(1正常,2禁用) | NOT NULL, DEFAULT 1 | +| created_at | TIMESTAMP | 创建时间 | NOT NULL, DEFAULT CURRENT_TIMESTAMP | +| updated_at | TIMESTAMP | 更新时间 | NOT NULL, DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | + +#### 5.2.5 会员权益表(member_benefit) + +| 字段名 | 类型 | 说明 | 约束 | +|-------|------|------|------| +| id | BIGINT | 主键 | PK, AUTO_INCREMENT | +| member_id | BIGINT | 会员ID | FK, NOT NULL | +| card_type_id | BIGINT | 卡类型ID | FK, NOT NULL | +| benefit_type | TINYINT | 权益类型(1时长,2次数,3储值) | NOT NULL | +| balance | INT | 余额(次数或金额) | NOT NULL, DEFAULT 0 | +| valid_days | INT | 有效天数 | NULL | +| expire_date | DATE | 到期日期 | NULL | +| created_at | TIMESTAMP | 创建时间 | NOT NULL, DEFAULT CURRENT_TIMESTAMP | +| updated_at | TIMESTAMP | 更新时间 | NOT NULL, DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | + +#### 5.2.6 团课表(group_class) + +| 字段名 | 类型 | 说明 | 约束 | +|-------|------|------|------| +| id | BIGINT | 主键 | PK, AUTO_INCREMENT | +| store_id | BIGINT | 门店ID | FK, NOT NULL | +| coach_id | BIGINT | 教练ID | FK, NOT NULL | +| name | VARCHAR(100) | 课程名称 | NOT NULL | +| description | TEXT | 课程描述 | NULL | +| max_capacity | INT | 最大容量 | NOT NULL, DEFAULT 20 | +| start_time | TIMESTAMP | 开始时间 | NOT NULL | +| end_time | TIMESTAMP | 结束时间 | NOT NULL | +| location | VARCHAR(100) | 地点 | NULL | +| status | TINYINT | 状态(1正常,2取消) | NOT NULL, DEFAULT 1 | +| created_at | TIMESTAMP | 创建时间 | NOT NULL, DEFAULT CURRENT_TIMESTAMP | +| updated_at | TIMESTAMP | 更新时间 | NOT NULL, DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | + +#### 5.2.7 预约记录表(booking_record) + +| 字段名 | 类型 | 说明 | 约束 | +|-------|------|------|------| +| id | BIGINT | 主键 | PK, AUTO_INCREMENT | +| member_id | BIGINT | 会员ID | FK, NOT NULL | +| group_class_id | BIGINT | 团课ID | FK, NOT NULL | +| booking_time | TIMESTAMP | 预约时间 | NOT NULL | +| cancel_time | TIMESTAMP | 取消时间 | NULL | +| status | TINYINT | 状态(1已预约,2已取消,3已完成) | NOT NULL, DEFAULT 1 | +| created_at | TIMESTAMP | 创建时间 | NOT NULL, DEFAULT CURRENT_TIMESTAMP | +| updated_at | TIMESTAMP | 更新时间 | NOT NULL, DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | + +#### 5.2.8 签到记录表(checkin_record) + +| 字段名 | 类型 | 说明 | 约束 | +|-------|------|------|------| +| id | BIGINT | 主键 | PK, AUTO_INCREMENT | +| member_id | BIGINT | 会员ID | FK, NOT NULL | +| store_id | BIGINT | 门店ID | FK, NOT NULL | +| booking_id | BIGINT | 预约ID | FK, NULL | +| checkin_time | TIMESTAMP | 签到时间 | NOT NULL | +| checkin_type | TINYINT | 签到类型(1自由训练,2团课) | NOT NULL | +| created_at | TIMESTAMP | 创建时间 | NOT NULL, DEFAULT CURRENT_TIMESTAMP | + +### 5.3 索引设计 + +| 表名 | 索引名 | 字段 | 类型 | 说明 | +|------|--------|------|------|------| +| member | idx_member_phone | phone | BTREE | 手机号索引 | +| member | idx_member_store | store_id | BTREE | 门店ID索引 | +| member_card | idx_card_member | member_id | BTREE | 会员ID索引 | +| member_benefit | idx_benefit_member | member_id | BTREE | 会员ID索引 | +| group_class | idx_class_store | store_id | BTREE | 门店ID索引 | +| group_class | idx_class_time | start_time | BTREE | 开始时间索引 | +| booking_record | idx_booking_member | member_id | BTREE | 会员ID索引 | +| booking_record | idx_booking_class | group_class_id | BTREE | 团课ID索引 | +| checkin_record | idx_checkin_member | member_id | BTREE | 会员ID索引 | +| checkin_record | idx_checkin_time | checkin_time | BTREE | 签到时间索引 | + +--- + +## 六、API接口设计 + +### 6.1 API设计原则 + +1. **RESTful风格**:遵循REST架构风格 +2. **统一响应格式**:所有接口返回统一格式 +3. **版本控制**:通过URL路径进行版本控制 +4. **错误处理**:统一的错误码和错误信息 +5. **幂等性**:关键操作支持幂等性 + +### 6.2 统一响应格式 + +```json +{ + "code": 200, + "message": "success", + "data": {}, + "timestamp": 1678234567890 +} +``` + +### 6.3 核心API接口 + +#### 6.3.1 会员模块API + +| 接口 | 方法 | 路径 | 说明 | +|------|------|------|------| +| 会员注册 | POST | /api/v1/members/register | 注册新会员 | +| 会员登录 | POST | /api/v1/members/login | 会员登录 | +| 获取会员信息 | GET | /api/v1/members/{id} | 获取会员详情 | +| 更新会员信息 | PUT | /api/v1/members/{id} | 更新会员信息 | +| 获取会员列表 | GET | /api/v1/members | 获取会员列表 | + +#### 6.3.2 预约模块API + +| 接口 | 方法 | 路径 | 说明 | +|------|------|------|------| +| 预约团课 | POST | /api/v1/bookings | 预约团课 | +| 取消预约 | DELETE | /api/v1/bookings/{id} | 取消预约 | +| 获取预约记录 | GET | /api/v1/bookings | 获取预约记录 | +| 创建团课 | POST | /api/v1/group-classes | 创建团课 | +| 获取团课列表 | GET | /api/v1/group-classes | 获取团课列表 | + +#### 6.3.3 签到模块API + +| 接口 | 方法 | 路径 | 说明 | +|------|------|------|------| +| 会员签到 | POST | /api/v1/checkins | 会员签到 | +| 获取签到记录 | GET | /api/v1/checkins | 获取签到记录 | + +#### 6.3.4 会员卡模块API + +| 接口 | 方法 | 路径 | 说明 | +|------|------|------|------| +| 购买会员卡 | POST | /api/v1/member-cards | 购买会员卡 | +| 获取会员卡列表 | GET | /api/v1/member-cards | 获取会员卡列表 | +| 续费会员卡 | POST | /api/v1/member-cards/{id}/renew | 续费会员卡 | + +### 6.4 API接口示例 + +#### 6.4.1 预约团课 + +**请求**: + +```http +POST /api/v1/bookings +Content-Type: application/json +Authorization: Bearer {token} + +{ + "memberId": 123, + "groupClassId": 456 +} +``` + +**响应**: + +```json +{ + "code": 200, + "message": "预约成功", + "data": { + "id": 789, + "memberId": 123, + "groupClassId": 456, + "bookingTime": "2026-03-08T10:00:00", + "status": 1 + }, + "timestamp": 1678234567890 +} +``` + +--- + +## 七、部署架构 + +### 7.1 部署方式 + +本系统采用 Docker Compose 进行容器化部署,实现一键部署和环境一致性。 + +### 7.2 部署架构图 + +```mermaid +flowchart TB + subgraph "部署架构" + A[用户] --> B[Nginx
反向代理] + B --> C[应用服务器
gym-manage] + C --> D[PostgreSQL
数据库] + C --> E[Redis
缓存] + C --> F[RabbitMQ
消息队列] + C --> G[Elasticsearch
搜索引擎] + C --> H[Prometheus
监控] + H --> I[Grafana
可视化] + end +``` + +### 7.3 Docker Compose配置 + +```yaml +version: '3.8' + +services: + # 应用服务 + gym-manage: + build: . + ports: + - "8080:8080" + environment: + - SPRING_PROFILES_ACTIVE=prod + - DB_HOST=postgres + - REDIS_HOST=redis + - RABBITMQ_HOST=rabbitmq + - ELASTICSEARCH_HOST=elasticsearch + depends_on: + - postgres + - redis + - rabbitmq + - elasticsearch + networks: + - gym-network + + # PostgreSQL数据库 + postgres: + image: postgres:15-alpine + environment: + - POSTGRES_DB=gym_manage + - POSTGRES_USER=gym_user + - POSTGRES_PASSWORD=gym_password + volumes: + - postgres-data:/var/lib/postgresql/data + ports: + - "5432:5432" + networks: + - gym-network + + # Redis缓存 + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis-data:/data + networks: + - gym-network + + # RabbitMQ消息队列 + rabbitmq: + image: rabbitmq:3.12-management-alpine + environment: + - RABBITMQ_DEFAULT_USER=gym_user + - RABBITMQ_DEFAULT_PASS=gym_password + ports: + - "5672:5672" + - "15672:15672" + volumes: + - rabbitmq-data:/var/lib/rabbitmq + networks: + - gym-network + + # Elasticsearch搜索引擎 + elasticsearch: + image: elasticsearch:8.11.0 + environment: + - discovery.type=single-node + - ES_JAVA_OPTS=-Xms512m -Xmx512m + ports: + - "9200:9200" + - "9300:9300" + volumes: + - elasticsearch-data:/usr/share/elasticsearch/data + networks: + - gym-network + + # Prometheus监控 + prometheus: + image: prom/prometheus:latest + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + - prometheus-data:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + networks: + - gym-network + + # Grafana可视化 + grafana: + image: grafana/grafana:latest + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + volumes: + - grafana-data:/var/lib/grafana + networks: + - gym-network + +volumes: + postgres-data: + redis-data: + rabbitmq-data: + elasticsearch-data: + prometheus-data: + grafana-data: + +networks: + gym-network: + driver: bridge +``` + +### 7.4 部署步骤 + +1. **构建镜像**: + ```bash + docker-compose build + ``` + +2. **启动服务**: + ```bash + docker-compose up -d + ``` + +3. **查看日志**: + ```bash + docker-compose logs -f gym-manage + ``` + +4. **停止服务**: + ```bash + docker-compose down + ``` + +--- + +## 八、监控与运维 + +### 8.1 监控体系 + +本系统采用 Prometheus + Grafana 构建完善的监控体系: + +| 监控类型 | 监控内容 | 告警阈值 | +|---------|---------|---------| +| **应用监控** | JVM内存、CPU、线程数 | 内存使用率 > 80% | +| **接口监控** | 请求量、响应时间、错误率 | 错误率 > 5% | +| **数据库监控** | 连接数、查询时间、慢查询 | 慢查询 > 1s | +| **缓存监控** | 命中率、内存使用 | 命中率 < 80% | +| **消息队列监控** | 队列长度、消费速率 | 队列长度 > 1000 | + +### 8.2 日志管理 + +采用结构化日志,便于查询和分析: + +```java +@Slf4j +@Service +public class BookingService { + + public Mono bookSlot(BookingRequest request) { + log.info("开始预约团课: memberId={}, groupClassId={}", + request.getMemberId(), request.getGroupClassId()); + + return bookingRepository.save(booking) + .doOnSuccess(booking -> log.info("预约成功: bookingId={}", booking.getId())) + .doOnError(e -> log.error("预约失败: memberId={}, groupClassId={}", + request.getMemberId(), request.getGroupClassId(), e)); + } +} +``` + +### 8.3 性能优化 + +#### 8.3.1 缓存策略 + +| 缓存类型 | 缓存内容 | 过期时间 | 更新策略 | +|---------|---------|---------|---------| +| **本地缓存** | 配置信息、字典数据 | 30分钟 | 定时刷新 | +| **Redis缓存** | 会员信息、团课信息 | 1小时 | 主动更新 | +| **查询缓存** | 热点查询结果 | 10分钟 | 惰性更新 | + +#### 8.3.2 数据库优化 + +1. **索引优化**:为常用查询字段添加索引 +2. **查询优化**:避免全表扫描,使用分页查询 +3. **连接池优化**:合理配置连接池大小 +4. **读写分离**:主从复制,读写分离 + +#### 8.3.3 响应式优化 + +1. **非阻塞I/O**:所有I/O操作使用非阻塞方式 +2. **背压处理**:合理处理背压,避免内存溢出 +3. **异步处理**:耗时操作异步处理 +4. **线程池优化**:合理配置线程池大小 + +--- + +## 九、安全设计 + +### 9.1 认证授权 + +采用 JWT + Spring Security 实现认证授权: + +1. **JWT Token**:用户登录后签发JWT Token +2. **Token验证**:每次请求验证Token有效性 +3. **权限控制**:基于角色的访问控制(RBAC) + +### 9.2 数据安全 + +1. **数据加密**:敏感数据加密存储 +2. **传输加密**:HTTPS加密传输 +3. **数据备份**:定期备份数据 +4. **审计日志**:记录关键操作日志 + +### 9.3 接口安全 + +1. **限流**:防止接口被恶意调用 +2. **防重放**:防止请求重放攻击 +3. **参数校验**:严格校验请求参数 +4. **SQL注入防护**:使用参数化查询 + +--- + +## 十、测试策略 + +### 10.1 测试分层 + +| 测试类型 | 测试内容 | 测试工具 | +|---------|---------|---------| +| **单元测试** | 单个方法/类的测试 | JUnit, Mockito | +| **集成测试** | 多个组件协作测试 | SpringBootTest, TestContainers | +| **接口测试** | API接口测试 | Postman, RestAssured | +| **性能测试** | 系统性能测试 | JMeter, Gatling | + +### 10.2 测试覆盖率 + +- **单元测试覆盖率**:≥ 80% +- **集成测试覆盖率**:≥ 60% +- **关键路径覆盖率**:100% + +--- + +## 十一、附录 + +### 11.1 技术术语表 + +| 术语 | 说明 | +|------|------| +| **响应式编程** | 基于异步数据流和变化传播的编程范式 | +| **R2DBC** | Reactive Relational Database Connectivity,响应式数据库连接规范 | +| **Mono** | Project Reactor中表示0-1个元素的响应式类型 | +| **Flux** | Project Reactor中表示0-N个元素的响应式类型 | +| **Saga模式** | 长时间运行事务的补偿模式 | + +### 11.2 参考文档 + +- 《健身房管理系统基础版产品设计文档》 GYM-PRD-BASIC-001 +- 《健身房管理系统基础版业务概要设计文档》 GYM-B-HLD-BASIC-001 +- 《健身房管理系统基础版业务详细设计文档》 GYM-B-LLD-BASIC-001 +- Spring Boot 3 官方文档 +- Spring WebFlux 官方文档 +- R2DBC 规范文档 +- PostgreSQL 官方文档 + +### 11.3 变更记录 + +| 版本 | 日期 | 作者 | 修订内容 | +| ---- | ---------- | ---- | ---------------------- | +| v1.0 | 2026-03-08 | 张翔 | 创建基础版技术设计文档,整合技术架构和实现细节 | diff --git a/docs/design/前端安全规范.md b/docs/design/前端安全规范.md deleted file mode 100644 index 6a6940e..0000000 --- a/docs/design/前端安全规范.md +++ /dev/null @@ -1,958 +0,0 @@ -# 健身房管理系统前端安全规范文档 - -> 文档编号: GYM-FE-SEC-001 -> 版本: v1.0 -> 日期: 2026-03-04 -> 作者: 张翔 -> 状态: 初稿 - ---- - -## 文档修订历史 - -| 版本 | 日期 | 作者 | 修订内容 | -| ---- | ---------- | ---- | -------- | -| v1.0 | 2026-03-04 | 张翔 | 创建前端安全规范 | - ---- - -## 参考文档 - -- 《健身房管理系统前端技术架构详细设计》 GYM-FE-ARCH-001 -- OWASP Top 10 Web Application Security Risks -- Content Security Policy (CSP) Level 3 -- Web Content Accessibility Guidelines (WCAG) 2.1 - ---- - -## 一、安全概述 - -### 1.1 安全目标 - -- **数据安全**:保护用户敏感数据(手机号、身份证号、银行卡号等) -- **交易安全**:确保支付和充值操作的安全性 -- **身份安全**:防止未授权访问和身份冒用 -- **隐私保护**:符合GDPR等隐私法规要求 -- **合规性**:符合金融行业安全标准和监管要求 - -### 1.2 安全原则 - -| 原则 | 描述 | 实施方式 | -|------|------|----------| -| **最小权限** | 只授予必要的权限 | 基于角色的访问控制(RBAC) | -| **纵深防御** | 多层安全防护 | 输入验证、输出转义、加密传输 | -| **默认安全** | 默认配置安全 | CSP策略、安全Headers | -| **审计追踪** | 记录关键操作 | 操作日志、异常日志 | -| **持续监控** | 实时安全监控 | 错误监控、性能监控 | - -### 1.3 安全威胁 - -| 威胁类型 | 风险等级 | 防护措施 | -|---------|---------|----------| -| **XSS(跨站脚本攻击)** | 高 | 输入过滤、输出转义、CSP | -| **CSRF(跨站请求伪造)** | 高 | Token验证、SameSite Cookie | -| **点击劫持** | 中 | X-Frame-Options、CSP | -| **中间人攻击** | 高 | HTTPS、HSTS | -| **敏感信息泄露** | 高 | 数据加密、脱敏显示 | -| **暴力破解** | 中 | 验证码、登录限制 | -| **会话劫持** | 高 | 安全Cookie、会话超时 | - ---- - -## 二、XSS防护 - -### 2.1 输入验证 - -#### 2.1.1 白名单验证 - -```typescript -// utils/validator.ts -export function sanitizeInput(input: string, allowedChars: RegExp): string { - return input.replace(allowedChars, '') -} - -export function validatePhoneNumber(phone: string): boolean { - const phoneRegex = /^1[3-9]\d{9}$/ - return phoneRegex.test(phone) -} - -export function validateIdCard(idCard: string): boolean { - const idCardRegex = /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/ - return idCardRegex.test(idCard) -} - -export function validateEmail(email: string): boolean { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - return emailRegex.test(email) -} -``` - -#### 2.1.2 输入长度限制 - -```typescript -// utils/validator.ts -export const MAX_INPUT_LENGTH = { - name: 64, - phone: 11, - idCard: 18, - address: 256, - remark: 512 -} - -export function validateLength(input: string, maxLength: number): boolean { - return input.length <= maxLength -} -``` - -### 2.2 输出转义 - -#### 2.2.1 HTML转义 - -```typescript -// utils/sanitize.ts -import DOMPurify from 'dompurify' - -export function sanitizeHtml(html: string): string { - return DOMPurify.sanitize(html, { - ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u'], - ALLOWED_ATTR: [] - }) -} - -export function escapeHtml(text: string): string { - const map: Record = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' - } - return text.replace(/[&<>"']/g, (m) => map[m]) -} -``` - -#### 2.2.2 URL转义 - -```typescript -// utils/sanitize.ts -export function sanitizeUrl(url: string): string { - try { - const parsed = new URL(url) - if (!['http:', 'https:'].includes(parsed.protocol)) { - return '#' - } - return url - } catch { - return '#' - } -} -``` - -### 2.3 CSP策略 - -#### 2.3.1 基础CSP配置 - -```html - - -``` - -#### 2.3.2 动态CSP配置 - -```typescript -// utils/csp.ts -export function generateCSP(config: CSPConfig): string { - const directives = [ - `default-src ${config.defaultSrc.join(' ')}`, - `script-src ${config.scriptSrc.join(' ')}`, - `style-src ${config.styleSrc.join(' ')}`, - `img-src ${config.imgSrc.join(' ')}`, - `connect-src ${config.connectSrc.join(' ')}` - ] - - return directives.join('; ') -} - -// 使用 -const csp = generateCSP({ - defaultSrc: ["'self'"], - scriptSrc: ["'self'", "https://cdn.jsdelivr.net"], - styleSrc: ["'self'", "'unsafe-inline'"], - imgSrc: ["'self'", "data:", "https:"], - connectSrc: ["'self'", "https://api.example.com"] -}) - -document.querySelector('meta[http-equiv="Content-Security-Policy"]') - ?.setAttribute('content', csp) -``` - ---- - -## 三、CSRF防护 - -### 3.1 Token验证 - -#### 3.1.1 Token生成与存储 - -```typescript -// utils/csrf.ts -export function generateCSRFToken(): string { - const array = new Uint8Array(32) - crypto.getRandomValues(array) - return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('') -} - -export function setCSRFToken(token: string): void { - localStorage.setItem('csrf_token', token) -} - -export function getCSRFToken(): string { - return localStorage.getItem('csrf_token') || '' -} -``` - -#### 3.1.2 Token注入请求 - -```typescript -// api/request.ts -import { getCSRFToken } from '@/utils/csrf' - -instance.interceptors.request.use((config) => { - const csrfToken = getCSRFToken() - if (csrfToken) { - config.headers['X-CSRF-Token'] = csrfToken - } - return config -}) -``` - -### 3.2 SameSite Cookie - -```typescript -// api/request.ts -instance.interceptors.request.use((config) => { - config.withCredentials = true - return config -}) -``` - -### 3.3 双重Cookie提交 - -```typescript -// utils/csrf.ts -export function setDoubleSubmitCookie(token: string): void { - document.cookie = `csrf_token=${token}; path=/; SameSite=Strict; Secure` -} - -export function getDoubleSubmitCookie(): string { - const match = document.cookie.match(/csrf_token=([^;]+)/) - return match ? match[1] : '' -} -``` - ---- - -## 四、数据安全 - -### 4.1 数据加密 - -#### 4.1.1 AES加密 - -```typescript -// utils/crypto.ts -import CryptoJS from 'crypto-js' - -const SECRET_KEY = import.meta.env.VITE_CRYPTO_SECRET_KEY - -export function encrypt(text: string): string { - const key = CryptoJS.enc.Utf8.parse(SECRET_KEY) - const iv = CryptoJS.lib.WordArray.random(16) - const encrypted = CryptoJS.AES.encrypt(text, key, { - iv: iv, - mode: CryptoJS.mode.CBC, - padding: CryptoJS.pad.Pkcs7 - }) - return iv.toString() + ':' + encrypted.toString() -} - -export function decrypt(ciphertext: string): string { - const key = CryptoJS.enc.Utf8.parse(SECRET_KEY) - const [ivHex, encrypted] = ciphertext.split(':') - const iv = CryptoJS.enc.Hex.parse(ivHex) - const decrypted = CryptoJS.AES.decrypt(encrypted, key, { - iv: iv, - mode: CryptoJS.mode.CBC, - padding: CryptoJS.pad.Pkcs7 - }) - return decrypted.toString(CryptoJS.enc.Utf8) -} -``` - -#### 4.1.2 RSA加密(用于敏感数据) - -```typescript -// utils/crypto.ts -import JSEncrypt from 'jsencrypt' - -const PUBLIC_KEY = import.meta.env.VITE_RSA_PUBLIC_KEY - -export function encryptWithRSA(text: string): string { - const encrypt = new JSEncrypt() - encrypt.setPublicKey(PUBLIC_KEY) - return encrypt.encrypt(text) || '' -} -``` - -### 4.2 数据脱敏 - -#### 4.2.1 手机号脱敏 - -```typescript -// utils/mask.ts -export function maskPhone(phone: string): string { - if (!phone || phone.length !== 11) { - return phone - } - return phone.substring(0, 3) + '****' + phone.substring(7) -} -``` - -#### 4.2.2 身份证号脱敏 - -```typescript -// utils/mask.ts -export function maskIdCard(idCard: string): string { - if (!idCard || idCard.length !== 18) { - return idCard - } - return idCard.substring(0, 6) + '********' + idCard.substring(14) -} -``` - -#### 4.2.3 银行卡号脱敏 - -```typescript -// utils/mask.ts -export function maskBankCard(bankCard: string): string { - if (!bankCard || bankCard.length < 16) { - return bankCard - } - return bankCard.substring(0, 4) + ' **** **** ' + bankCard.substring(bankCard.length - 4) -} -``` - -### 4.3 敏感信息存储 - -#### 4.3.1 安全存储 - -```typescript -// utils/storage.ts -import { encrypt, decrypt } from './crypto' - -export const secureStorage = { - setItem(key: string, value: any): void { - const encrypted = encrypt(JSON.stringify(value)) - localStorage.setItem(key, encrypted) - }, - - getItem(key: string): T | null { - const encrypted = localStorage.getItem(key) - if (!encrypted) return null - try { - return JSON.parse(decrypt(encrypted)) as T - } catch { - return null - } - }, - - removeItem(key: string): void { - localStorage.removeItem(key) - }, - - clear(): void { - localStorage.clear() - } -} -``` - -#### 4.3.2 会话存储 - -```typescript -// utils/storage.ts -export const sessionStorage = { - setItem(key: string, value: any): void { - const encrypted = encrypt(JSON.stringify(value)) - window.sessionStorage.setItem(key, encrypted) - }, - - getItem(key: string): T | null { - const encrypted = window.sessionStorage.getItem(key) - if (!encrypted) return null - try { - return JSON.parse(decrypt(encrypted)) as T - } catch { - return null - } - }, - - removeItem(key: string): void { - window.sessionStorage.removeItem(key) - }, - - clear(): void { - window.sessionStorage.clear() - } -} -``` - ---- - -## 五、身份认证与授权 - -### 5.1 认证安全 - -#### 5.1.1 密码安全 - -```typescript -// utils/password.ts -export function validatePassword(password: string): { valid: boolean; message?: string } { - if (password.length < 8) { - return { valid: false, message: '密码长度至少8位' } - } - - if (!/[A-Z]/.test(password)) { - return { valid: false, message: '密码必须包含大写字母' } - } - - if (!/[a-z]/.test(password)) { - return { valid: false, message: '密码必须包含小写字母' } - } - - if (!/[0-9]/.test(password)) { - return { valid: false, message: '密码必须包含数字' } - } - - if (!/[!@#$%^&*]/.test(password)) { - return { valid: false, message: '密码必须包含特殊字符' } - } - - return { valid: true } -} -``` - -#### 5.1.2 Token管理 - -```typescript -// stores/auth.ts -import { defineStore } from 'pinia' -import { ref } from 'vue' - -export const useAuthStore = defineStore('auth', () => { - const token = ref('') - const refreshToken = ref('') - const tokenExpireTime = ref(0) - - const setToken = (newToken: string, expireIn: number) => { - token.value = newToken - tokenExpireTime.value = Date.now() + expireIn * 1000 - secureStorage.setItem('auth_token', newToken) - secureStorage.setItem('token_expire_time', tokenExpireTime.value) - } - - const setRefreshToken = (newRefreshToken: string) => { - refreshToken.value = newRefreshToken - secureStorage.setItem('refresh_token', newRefreshToken) - } - - const isTokenExpired = (): boolean => { - return Date.now() >= tokenExpireTime.value - } - - const clearAuth = () => { - token.value = '' - refreshToken.value = '' - tokenExpireTime.value = 0 - secureStorage.removeItem('auth_token') - secureStorage.removeItem('refresh_token') - secureStorage.removeItem('token_expire_time') - } - - return { - token, - refreshToken, - tokenExpireTime, - setToken, - setRefreshToken, - isTokenExpired, - clearAuth - } -}) -``` - -#### 5.1.3 Token刷新 - -```typescript -// api/request.ts -import { useAuthStore } from '@/stores/auth' - -instance.interceptors.request.use(async (config) => { - const authStore = useAuthStore() - - if (authStore.isTokenExpired()) { - try { - const response = await instance.post('/auth/refresh', { - refreshToken: authStore.refreshToken - }) - authStore.setToken(response.token, response.expireIn) - authStore.setRefreshToken(response.refreshToken) - config.headers.Authorization = `Bearer ${response.token}` - } catch (error) { - authStore.clearAuth() - window.location.href = '/login' - return Promise.reject(error) - } - } else { - config.headers.Authorization = `Bearer ${authStore.token}` - } - - return config -}) -``` - -### 5.2 授权安全 - -#### 5.2.1 权限验证 - -```typescript -// utils/permission.ts -export interface Permission { - resource: string - action: string -} - -export function hasPermission(userPermissions: string[], required: Permission): boolean { - const permissionString = `${required.resource}:${required.action}` - return userPermissions.includes(permissionString) -} - -export function hasAnyPermission(userPermissions: string[], required: Permission[]): boolean { - return required.some(p => hasPermission(userPermissions, p)) -} - -export function hasAllPermissions(userPermissions: string[], required: Permission[]): boolean { - return required.every(p => hasPermission(userPermissions, p)) -} -``` - -#### 5.2.2 路由权限守卫 - -```typescript -// router/guards/permission.ts -import { useAuthStore } from '@/stores/auth' -import { usePermissionStore } from '@/stores/permission' - -router.beforeEach(async (to, from, next) => { - const authStore = useAuthStore() - const permissionStore = usePermissionStore() - - if (to.meta.requiresAuth && !authStore.token) { - next('/login') - return - } - - if (to.meta.permission) { - const required = to.meta.permission as Permission - if (!hasPermission(permissionStore.permissions, required)) { - next('/403') - return - } - } - - next() -}) -``` - ---- - -## 六、安全Headers - -### 6.1 基础安全Headers - -```typescript -// utils/headers.ts -export const securityHeaders = { - 'X-Content-Type-Options': 'nosniff', - 'X-Frame-Options': 'DENY', - 'X-XSS-Protection': '1; mode=block', - 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', - 'Referrer-Policy': 'strict-origin-when-cross-origin', - 'Permissions-Policy': 'geolocation=(), microphone=(), camera=()' -} -``` - -### 6.2 动态设置Headers - -```typescript -// api/request.ts -instance.interceptors.request.use((config) => { - Object.assign(config.headers, securityHeaders) - return config -}) -``` - ---- - -## 七、点击劫持防护 - -### 7.1 X-Frame-Options - -```html - - -``` - -### 7.2 CSP frame-ancestors - -```html - - -``` - -### 7.3 JavaScript防护 - -```typescript -// utils/clickjacking.ts -export function preventClickjacking(): void { - if (window.self !== window.top) { - window.top.location = window.self.location - } -} - -// 在应用初始化时调用 -preventClickjacking() -``` - ---- - -## 八、安全键盘 - -### 8.1 安全键盘组件 - -```vue - - - - - - -``` - ---- - -## 九、安全日志与监控 - -### 9.1 操作日志 - -```typescript -// utils/logger.ts -interface LogEntry { - timestamp: number - level: 'info' | 'warn' | 'error' - action: string - userId?: number - details?: any -} - -export class SecurityLogger { - private logs: LogEntry[] = [] - - log(action: string, details?: any, level: 'info' | 'warn' | 'error' = 'info') { - const entry: LogEntry = { - timestamp: Date.now(), - level, - action, - userId: this.getUserId(), - details - } - - this.logs.push(entry) - this.sendToServer(entry) - } - - private getUserId(): number | undefined { - const authStore = useAuthStore() - return authStore.user?.id - } - - private async sendToServer(entry: LogEntry) { - try { - await api.post('/security/log', entry) - } catch (error) { - console.error('Failed to send security log:', error) - } - } -} - -export const securityLogger = new SecurityLogger() -``` - -### 9.2 异常监控 - -```typescript -// utils/sentry.ts -import * as Sentry from '@sentry/vue' - -export function setupSentry(app: App) { - Sentry.init({ - app, - dsn: import.meta.env.VITE_SENTRY_DSN, - environment: import.meta.env.MODE, - tracesSampleRate: 1.0, - beforeSend(event) { - if (event.request) { - delete event.request.cookies - delete event.request.headers - } - return event - } - }) -} - -// 全局错误处理 -window.addEventListener('error', (event) => { - securityLogger.log('window.error', { - message: event.message, - filename: event.filename, - lineno: event.lineno - }, 'error') -}) - -window.addEventListener('unhandledrejection', (event) => { - securityLogger.log('unhandledrejection', { - reason: event.reason - }, 'error') -}) -``` - ---- - -## 十、安全最佳实践 - -### 10.1 开发阶段 - -1. **代码审查** - - 所有代码必须经过安全审查 - - 使用ESLint安全规则 - - 使用npm audit检查依赖漏洞 - -2. **依赖管理** - - 定期更新依赖包 - - 使用npm audit fix修复漏洞 - - 使用Snyk等工具监控依赖安全 - -3. **环境变量** - - 敏感信息使用环境变量 - - 不要将密钥提交到代码仓库 - - 使用.env文件管理配置 - -### 10.2 测试阶段 - -1. **安全测试** - - 使用OWASP ZAP进行安全扫描 - - 进行渗透测试 - - 测试XSS、CSRF等漏洞 - -2. **代码扫描** - - 使用SonarQube进行代码质量检查 - - 使用ESLint进行代码规范检查 - - 使用Prettier进行代码格式化 - -### 10.3 部署阶段 - -1. **HTTPS强制** - - 使用SSL证书 - - 配置HSTS - - 禁用HTTP访问 - -2. **安全配置** - - 配置CSP策略 - - 配置安全Headers - - 配置防火墙规则 - -3. **监控告警** - - 配置错误监控 - - 配置性能监控 - - 配置安全告警 - ---- - -## 十一、合规性要求 - -### 11.1 GDPR合规 - -1. **数据最小化** - - 只收集必要的用户数据 - - 提供数据删除功能 - - 提供数据导出功能 - -2. **用户同意** - - 明确告知数据使用目的 - - 获取用户明确同意 - - 提供撤回同意的选项 - -3. **数据保护** - - 加密存储敏感数据 - - 限制数据访问权限 - - 定期进行安全审计 - -### 11.2 无障碍合规 - -1. **WCAG 2.1 AA级标准** - - 键盘导航支持 - - 屏幕阅读器支持 - - 颜色对比度符合标准 - -2. **ARIA标签** - - 为交互元素添加ARIA标签 - - 为动态内容添加ARIA标签 - - 为表单元素添加ARIA标签 - ---- - -## 十二、安全检查清单 - -### 12.1 代码提交前检查 - -- [ ] 所有用户输入都经过验证和过滤 -- [ ] 所有输出都经过转义 -- [ ] 敏感数据都经过加密存储 -- [ ] 敏感信息都经过脱敏显示 -- [ ] 所有API请求都包含CSRF Token -- [ ] 所有页面都配置了CSP策略 -- [ ] 所有页面都配置了安全Headers -- [ ] 所有密码都符合复杂度要求 -- [ ] 所有权限都经过验证 -- [ ] 所有操作都记录了日志 - -### 12.2 部署前检查 - -- [ ] 所有依赖包都是最新版本 -- [ ] 所有依赖包都没有已知漏洞 -- [ ] 所有环境变量都正确配置 -- [ ] 所有HTTPS证书都有效 -- [ ] 所有监控和告警都正常工作 -- [ ] 所有安全策略都正确配置 -- [ ] 所有安全测试都通过 -- [ ] 所有安全扫描都通过 -- [ ] 所有安全文档都完整 -- [ ] 所有安全培训都完成 - ---- - -## 十三、总结 - -本文档详细描述了健身房管理系统前端的安全规范,包括: - -1. **安全概述**:安全目标、安全原则、安全威胁 -2. **XSS防护**:输入验证、输出转义、CSP策略 -3. **CSRF防护**:Token验证、SameSite Cookie、双重Cookie提交 -4. **数据安全**:数据加密、数据脱敏、敏感信息存储 -5. **身份认证与授权**:认证安全、授权安全 -6. **安全Headers**:基础安全Headers、动态设置Headers -7. **点击劫持防护**:X-Frame-Options、CSP frame-ancestors -8. **安全键盘**:安全键盘组件 -9. **安全日志与监控**:操作日志、异常监控 -10. **安全最佳实践**:开发阶段、测试阶段、部署阶段 -11. **合规性要求**:GDPR合规、无障碍合规 -12. **安全检查清单**:代码提交前检查、部署前检查 - -通过遵循本文档的安全规范,可以确保健身房管理系统前端的安全性,符合金融级安全标准和监管要求。 diff --git a/docs/design/前端工程化建设文档.md b/docs/design/前端工程化建设文档.md index a03be83..14c93ca 100644 --- a/docs/design/前端工程化建设文档.md +++ b/docs/design/前端工程化建设文档.md @@ -4,7 +4,7 @@ > 版本: v1.0 > 日期: 2026-03-04 > 作者: 张翔 -> 状态: 初稿 +> 状态: 正式发布 --- @@ -37,46 +37,53 @@ ### 1.2 工程化体系 -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 前端工程化体系 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 开发工具链 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • Node.js • npm/yarn • Git • VSCode │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 构建工具 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • Vite • TypeScript • ESLint • Prettier │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 代码质量工具 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • Husky • Commitlint • Lint-staged • Stylelint │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 测试工具 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • Vitest • Playwright • Coverage • Testing Library │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ CI/CD工具 │ │ -│ ├─────────────────────────────────────────────────────────────────┤ │ -│ │ • GitHub Actions • Docker • Nginx • CDN │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ +```mermaid +flowchart TB + subgraph DevTools["开发工具链"] + DT1["Node.js"] + DT2["npm/yarn"] + DT3["Git"] + DT4["VSCode"] + end + + subgraph BuildTools["构建工具"] + BT1["Vite"] + BT2["TypeScript"] + BT3["ESLint"] + BT4["Prettier"] + end + + subgraph QualityTools["代码质量工具"] + QT1["Husky"] + QT2["Commitlint"] + QT3["Lint-staged"] + QT4["Stylelint"] + end + + subgraph TestTools["测试工具"] + TT1["Vitest"] + TT2["Playwright"] + TT3["Coverage"] + TT4["Testing Library"] + end + + subgraph CICD["CI/CD工具"] + CD1["GitHub Actions"] + CD2["Docker"] + CD3["Nginx"] + CD4["CDN"] + end + + DevTools --> BuildTools + BuildTools --> QualityTools + QualityTools --> TestTools + TestTools --> CICD + + style DevTools fill:#e1f5ff + style BuildTools fill:#fff4e1 + style QualityTools fill:#f0e1ff + style TestTools fill:#e1ffe1 + style CICD fill:#ffe1e1 ``` --- diff --git a/docs/design/前端开发规范.md b/docs/design/前端开发规范.md deleted file mode 100644 index 533c878..0000000 --- a/docs/design/前端开发规范.md +++ /dev/null @@ -1,1017 +0,0 @@ -# 健身房管理系统前端开发规范文档 - -> 文档编号: GYM-FE-DEV-001 -> 版本: v1.0 -> 日期: 2026-03-04 -> 作者: 张翔 -> 状态: 初稿 - ---- - -## 文档修订历史 - -| 版本 | 日期 | 作者 | 修订内容 | -| ---- | ---------- | ---- | -------- | -| v1.0 | 2026-03-04 | 张翔 | 创建前端开发规范 | - ---- - -## 参考文档 - -- 《健身房管理系统前端技术架构详细设计》 GYM-FE-ARCH-001 -- Vue 3 风格指南 -- TypeScript 编码规范 -- Airbnb JavaScript Style Guide - ---- - -## 一、编码规范 - -### 1.1 代码风格 - -#### 1.1.1 使用ESLint - -```json -// .eslintrc.json -{ - "extends": [ - "plugin:vue/vue3-recommended", - "plugin:@typescript-eslint/recommended", - "prettier" - ], - "rules": { - "vue/multi-word-component-names": "off", - "@typescript-eslint/no-explicit-any": "warn", - "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], - "no-console": ["warn", { "allow": ["warn", "error"] }] - } -} -``` - -#### 1.1.2 使用Prettier - -```json -// .prettierrc -{ - "semi": false, - "singleQuote": true, - "printWidth": 100, - "trailingComma": "es5", - "arrowParens": "avoid", - "endOfLine": "lf" -} -``` - -### 1.2 命名规范 - -#### 1.2.1 文件命名 - -| 类型 | 命名规范 | 示例 | -|------|---------|------| -| **组件文件** | PascalCase | MemberList.vue, CourseCard.vue | -| **工具文件** | camelCase | formatDate.ts, validator.ts | -| **类型文件** | camelCase | api.ts, models.ts | -| **常量文件** | UPPER_SNAKE_CASE | API_BASE_URL.ts | -| **样式文件** | kebab-case | member-list.scss, button.css | - -#### 1.2.2 变量命名 - -```typescript -// 布尔值:使用is/has/can前缀 -const isActive = true -const hasPermission = false -const canEdit = true - -// 常量:使用UPPER_SNAKE_CASE -const API_BASE_URL = 'https://api.example.com' -const MAX_RETRY_COUNT = 3 - -// 函数:使用camelCase,动词开头 -function getMemberList() {} -function validatePhoneNumber() {} -function handleSearch() {} - -// 类:使用PascalCase -class UserService {} -class MemberValidator {} - -// 接口:使用PascalCase,I前缀可选 -interface Member {} -interface IMember {} - -// 类型别名:使用PascalCase -type MemberStatus = 'active' | 'inactive' - -// 枚举:使用PascalCase -enum MemberLevel { - BRONZE = 1, - SILVER = 2, - GOLD = 3 -} -``` - -#### 1.2.3 组件命名 - -```vue - - - - -``` - -### 1.3 代码格式 - -#### 1.3.1 缩进与空格 - -```typescript -// 使用2个空格缩进 -function example() { - if (condition) { - doSomething() - } -} - -// 对象和数组使用空格 -const obj = { a: 1, b: 2 } -const arr = [1, 2, 3] - -// 运算符前后使用空格 -const sum = a + b -const result = a > b ? a : b -``` - -#### 1.3.2 引号使用 - -```typescript -// 优先使用单引号 -const name = 'John' -const message = 'Hello, world!' - -// 字符串中包含单引号时使用双引号 -const quote = "It's a beautiful day" - -// 模板字符串使用反引号 -const greeting = `Hello, ${name}!` -``` - -#### 1.3.3 分号使用 - -```typescript -// 不使用分号 -const a = 1 -const b = 2 -function example() { - return a + b -} -``` - ---- - -## 二、Vue组件规范 - -### 2.1 组件结构 - -```vue - - - - - -``` - -### 2.2 Props定义 - -```typescript -// 使用TypeScript接口定义Props -interface Props { - title: string - count?: number - items: string[] - config: { - enabled: boolean - timeout: number - } -} - -// 使用withDefaults设置默认值 -const props = withDefaults(defineProps(), { - count: 0, - items: () => [], - config: () => ({ enabled: true, timeout: 5000 }) -}) -``` - -### 2.3 Emits定义 - -```typescript -// 使用TypeScript定义Emits -const emit = defineEmits<{ - click: [event: MouseEvent] - change: [value: string] - submit: [data: FormData] - cancel: [] -}>() - -// 触发事件 -emit('click', event) -emit('change', newValue) -emit('submit', formData) -emit('cancel') -``` - -### 2.4 响应式数据 - -```typescript -// 使用ref定义基本类型 -const count = ref(0) -const message = ref('Hello') - -// 使用reactive定义对象 -const user = reactive({ - name: 'John', - age: 30 -}) - -// 使用computed定义计算属性 -const fullName = computed(() => `${user.name} is ${user.age} years old`) - -// 使用readonly定义只读数据 -const readonlyData = readonly({ id: 1, name: 'John' }) - -// 使用shallowRef和shallowReactive优化性能 -const largeList = shallowRef([]) -const largeObject = shallowReactive({}) -``` - -### 2.5 组件通信 - -#### 2.5.1 Props传递 - -```vue - - - - -``` - -#### 2.5.2 事件传递 - -```vue - - - - -``` - -#### 2.5.3 Provide/Inject - -```typescript -// 父组件 -import { provide } from 'vue' - -const theme = ref('light') -provide('theme', theme) - -// 子组件 -import { inject } from 'vue' - -const theme = inject>('theme') -``` - ---- - -## 三、TypeScript规范 - -### 3.1 类型定义 - -#### 3.1.1 基础类型 - -```typescript -// 使用明确的类型 -const count: number = 10 -const name: string = 'John' -const isActive: boolean = true -const items: string[] = ['a', 'b', 'c'] -const user: { name: string; age: number } = { name: 'John', age: 30 } -``` - -#### 3.1.2 接口定义 - -```typescript -// 定义接口 -interface Member { - id: number - name: string - phone: string - level: MemberLevel - status: MemberStatus -} - -// 可选属性 -interface User { - id: number - name: string - email?: string -} - -// 只读属性 -interface Config { - readonly id: number - name: string -} - -// 索引签名 -interface StringDictionary { - [key: string]: string -} -``` - -#### 3.1.3 类型别名 - -```typescript -// 定义类型别名 -type MemberStatus = 'active' | 'inactive' | 'frozen' -type MemberLevel = 1 | 2 | 3 | 4 | 5 - -// 联合类型 -type ID = number | string - -// 交叉类型 -type Name = { firstName: string; lastName: string } -type Age = { age: number } -type Person = Name & Age -``` - -#### 3.1.4 泛型 - -```typescript -// 泛型函数 -function identity(arg: T): T { - return arg -} - -// 泛型接口 -interface Response { - code: number - data: T - message: string -} - -// 泛型类 -class Storage { - private items: T[] = [] - - add(item: T): void { - this.items.push(item) - } - - get(index: number): T | undefined { - return this.items[index] - } -} -``` - -### 3.2 类型断言 - -```typescript -// 使用as关键字进行类型断言 -const element = document.getElementById('app') as HTMLElement - -// 使用尖括号语法(JSX中不可用) -const element2 = document.getElementById('app') - -// 非空断言 -const value = input.value! -``` - -### 3.3 类型守卫 - -```typescript -// typeof类型守卫 -function processValue(value: string | number) { - if (typeof value === 'string') { - console.log(value.toUpperCase()) - } else { - console.log(value.toFixed(2)) - } -} - -// instanceof类型守卫 -class Dog { - bark() {} -} - -class Cat { - meow() {} -} - -function makeSound(animal: Dog | Cat) { - if (animal instanceof Dog) { - animal.bark() - } else { - animal.meow() - } -} - -// 自定义类型守卫 -interface Member { - id: number - name: string - type: 'individual' | 'corporate' -} - -function isCorporateMember(member: Member): member is Member & { type: 'corporate' } { - return member.type === 'corporate' -} -``` - ---- - -## 四、注释规范 - -### 4.1 文件注释 - -```typescript -/** - * 会员服务 - * - * 提供会员相关的业务逻辑处理 - * - * @example - * const memberService = new MemberService() - * const members = await memberService.getList() - */ -export class MemberService { - // ... -} -``` - -### 4.2 函数注释 - -```typescript -/** - * 获取会员列表 - * - * @param params - 查询参数 - * @param params.page - 页码 - * @param params.pageSize - 每页数量 - * @param params.keyword - 搜索关键词 - * @returns 会员列表数据 - * @throws {Error} 当API请求失败时抛出错误 - * - * @example - * const result = await getMemberList({ page: 1, pageSize: 10 }) - */ -export async function getMemberList(params: { - page: number - pageSize: number - keyword?: string -}): Promise { - // ... -} -``` - -### 4.3 类注释 - -```typescript -/** - * 会员验证器 - * - * 提供会员数据验证功能 - */ -export class MemberValidator { - /** - * 验证手机号 - * - * @param phone - 手机号 - * @returns 验证结果 - */ - validatePhone(phone: string): ValidationResult { - // ... - } -} -``` - -### 4.4 行内注释 - -```typescript -// 计算会员等级 -const level = calculateLevel(exp) - -// TODO: 需要优化这个算法的性能 -const result = complexCalculation(data) - -// FIXME: 这里有个bug,需要修复 -const value = buggyFunction() - -// HACK: 临时解决方案,后续需要重构 -const temp = workaround(data) -``` - ---- - -## 五、文件组织规范 - -### 5.1 目录结构 - -``` -src/ -├── api/ # API接口 -│ ├── modules/ # API模块 -│ │ ├── auth.ts -│ │ ├── member.ts -│ │ └── booking.ts -│ └── request.ts # Axios封装 -├── assets/ # 静态资源 -│ ├── images/ # 图片 -│ ├── icons/ # 图标 -│ └── styles/ # 样式 -├── components/ # 组件 -│ ├── base/ # 基础组件 -│ ├── business/ # 业务组件 -│ └── layout/ # 布局组件 -├── composables/ # Composables -├── config/ # 配置 -├── directives/ # 自定义指令 -├── hooks/ # Hooks -├── layouts/ # 布局 -├── router/ # 路由 -├── stores/ # 状态管理 -├── types/ # 类型定义 -├── utils/ # 工具函数 -├── views/ # 页面 -├── App.vue -└── main.ts -``` - -### 5.2 文件导入顺序 - -```typescript -// 1. Vue相关导入 -import { ref, computed, onMounted } from 'vue' -import { useRouter } from 'vue-router' - -// 2. 第三方库导入 -import axios from 'axios' -import dayjs from 'dayjs' - -// 3. 内部模块导入 -import { useAuthStore } from '@/stores/auth' -import { formatDate } from '@/utils/formatter' - -// 4. 类型导入 -import type { Member, MemberListParams } from '@/types/models' - -// 5. 样式导入 -import './styles/index.scss' -``` - -### 5.3 导出规范 - -```typescript -// 命名导出 -export function formatDate(date: Date): string {} -export function formatTime(time: Date): string {} - -// 默认导出(仅用于组件) -export default defineComponent({ - // ... -}) - -// 类型导出 -export type { Member, MemberStatus } -export interface { MemberListParams } -``` - ---- - -## 六、Git提交规范 - -### 6.1 提交信息格式 - -``` -(): - - - -