Compare commits
4
Commits
4c07ec5455
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc68581c5e | ||
|
|
c2f11727fe | ||
|
|
86b7555943 | ||
|
|
b689656faf |
@@ -1,6 +1,6 @@
|
|||||||
# ADR-0001: 教练业绩统计功能设计
|
# ADR-0001: 教练业绩统计功能设计
|
||||||
|
|
||||||
**日期**: 2026-07-22
|
**日期**: 2026-07-22(初版)/ 2026-07-26(修订)
|
||||||
**状态**: 已决定
|
**状态**: 已决定
|
||||||
**决策者**: 通过 grill-with-docs 追问明确
|
**决策者**: 通过 grill-with-docs 追问明确
|
||||||
|
|
||||||
@@ -19,16 +19,18 @@
|
|||||||
**选择**: 在 `gym-dataCount` 模块中新增 CoachPerformance 相关的 Handler + Service + DAO,而非新建独立模块。
|
**选择**: 在 `gym-dataCount` 模块中新增 CoachPerformance 相关的 Handler + Service + DAO,而非新建独立模块。
|
||||||
|
|
||||||
**理由**:
|
**理由**:
|
||||||
- `gym-dataCount` 模块已有成熟的统计架构(DatabaseClient + Reactive + Redis 缓存 + 时间范围推导)
|
- `gym-dataCount` 模块已有成熟的统计架构(DatabaseClient + Reactive + 时间范围推导)
|
||||||
- 现有 `DataStatisticsDao` 已有教练相关的 SQL 聚合查询,可直接复用
|
- 现有 `DataStatisticsDao` 已有教练相关的 SQL 聚合查询,可直接复用
|
||||||
- 避免模块膨胀,将"统计"职责收敛在一个模块中
|
- 避免模块膨胀,将"统计"职责收敛在一个模块中
|
||||||
- `manage-app` 已依赖 `gym-dataCount`,路由注册零成本
|
- `manage-app` 已依赖 `gym-dataCount`,路由注册零成本
|
||||||
|
|
||||||
**替代方案被拒绝**: 新建 `gym-coach-performance` 独立模块。理由:功能规模不足以支撑独立模块,且会引入额外的模块间依赖管理成本。
|
**替代方案被拒绝**: 新建 `gym-coach-performance` 独立模块。理由:功能规模不足以支撑独立模块,且会引入额外的模块间依赖管理成本。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 2. 数据源:完全基于团课预约数据
|
### 2. 数据源:完全基于团课预约数据
|
||||||
|
|
||||||
**选择**: 业绩统计的"出席人次"和"出勤率"完全基于 `group_course_booking` 表(status='2'=已出席),而非 `sign_in_record` 签到表。
|
**选择**: 业绩统计的"出席人次"和"出勤率"完全基于 `group_course_booking` 表,而非 `sign_in_record` 签到表。
|
||||||
|
|
||||||
**理由**:
|
**理由**:
|
||||||
- `sign_in_record` 表中没有 `coach_id` 字段,签到只关联会员(member_id),不关联教练
|
- `sign_in_record` 表中没有 `coach_id` 字段,签到只关联会员(member_id),不关联教练
|
||||||
@@ -37,6 +39,8 @@
|
|||||||
|
|
||||||
**风险**: 如果未来签到记录需要关联教练(例如一对一的私教签到),需要重新评估此决策。
|
**风险**: 如果未来签到记录需要关联教练(例如一对一的私教签到),需要重新评估此决策。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 3. 授课量定义:仅计入已完成课程
|
### 3. 授课量定义:仅计入已完成课程
|
||||||
|
|
||||||
**选择**: 只统计 `status IN (2, 6)` 的课程(已结束 + 自动结束)。
|
**选择**: 只统计 `status IN (2, 6)` 的课程(已结束 + 自动结束)。
|
||||||
@@ -45,23 +49,75 @@
|
|||||||
- 所有非取消课程:会包含教练缺席(status=5)的课程,不应算作业绩
|
- 所有非取消课程:会包含教练缺席(status=5)的课程,不应算作业绩
|
||||||
- 所有排课:会包含已取消的课程,不能反映真实工作量
|
- 所有排课:会包含已取消的课程,不能反映真实工作量
|
||||||
|
|
||||||
### 4. 满员率:按出席人数计算
|
---
|
||||||
|
|
||||||
**选择**: 满员率 = 各课程(出席人数 / max_members)的平均值。
|
### 4. 时间基准:以课程结束时间为准
|
||||||
|
|
||||||
|
**选择**: 所有时间范围过滤均使用 `group_course.end_time`,而非 `start_time`。
|
||||||
|
|
||||||
|
**理由**: 课程可能跨统计周期边界(如月末 23:00 开课、次月 01:00 结束)。以开始时间为准会导致跨月课程被错误归因到上月。以结束时间为准更符合"这个月完成了哪些课程"的直观理解。
|
||||||
|
|
||||||
|
**变更历史**(2026-07-26): 从 `start_time` 改为 `end_time`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. 出席人次口径:参与型状态
|
||||||
|
|
||||||
|
**选择**: 出席人次统计 `booking.status IN ('2', '4', '5')`,即已出席(2) + 教练缺席(4) + 迟到(5)。
|
||||||
|
|
||||||
|
**拒绝的定义**: 仅统计 status='2'(已出席)。理由:教练缺席和迟到同样意味着学员到达了现场(或至少尝试了参与),应计入出席人次;实际缺席责任在教练而非学员。
|
||||||
|
|
||||||
|
**变更历史**(2026-07-26): 从仅 `status='2'` 扩展为 `IN ('2','4','5')`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. 出勤率分母:仅已预约
|
||||||
|
|
||||||
|
**选择**: 出勤率分母仅统计 `booking.status = '0'`(已预约),而非 `status != '1'`(所有非取消)。
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- status='3'(学员缺席)不应出现在分母中——学员预约后无故缺席,既不应计入分子也不应计入分母,因为这既非教练的功劳也非教练的责任
|
||||||
|
- 出勤率语义变为"在已预约的学员中,实际参与的比例"
|
||||||
|
- 排除了预约后取消(status='1')和学员缺席(status='3')的噪声
|
||||||
|
|
||||||
|
**变更历史**(2026-07-26): 从 `status != '1'` 改为 `status = '0'`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. 满员率:按出席人数计算 + 防御除零
|
||||||
|
|
||||||
|
**选择**: 满员率 = 各已完成课程(出席人数 / max_members)的平均值,其中出席人数按 status IN ('2','4','5') 统计。`max_members = 0` 的课程被跳过不参与计算。
|
||||||
|
|
||||||
**拒绝的定义**: 按预约人数(current_members)计算。理由:预约了但没来的学员不能算"满员",出席人数更真实地反映了课程实际到场情况。
|
**拒绝的定义**: 按预约人数(current_members)计算。理由:预约了但没来的学员不能算"满员",出席人数更真实地反映了课程实际到场情况。
|
||||||
|
|
||||||
### 5. 综合评分权重:授课量 40% + 出勤率 30% + 满员率 30%
|
**变更历史**(2026-07-26): 满员率明细的出席人数口径从 `status='2'` 扩展为 `IN ('2','4','5')`,与出席人次保持一致。
|
||||||
|
|
||||||
**选择**: 授课量占比最高,体现工作量;出勤率和满员率体现教学质量。
|
---
|
||||||
|
|
||||||
**归一化规则**: 授课量按所有教练中最大值归一化到 0-100。这样即使只有少数教练开课多,评分也能合理分布。
|
### 8. 综合评分
|
||||||
|
|
||||||
**拒绝的替代方案**:
|
**最终选择**(2026-07-26 修订):
|
||||||
- 三指标等权重(33/33/34):弱化了工作量差异
|
|
||||||
- 授课量 50%:过度强调数量而忽视质量
|
|
||||||
|
|
||||||
### 6. 不包含学员留存率
|
| 指标 | 权重 | 归一化方式 |
|
||||||
|
|------|------|-----------|
|
||||||
|
| 授课量 | 35% | 百分位排名(授课量排序,小于当前教练的教练数 / (总教练数-1) * 100) |
|
||||||
|
| 出勤率 | 25% | 原始百分比(0-100) |
|
||||||
|
| 满员率 | 25% | 原始百分比(0-100) |
|
||||||
|
| 违规扣分 | 15% | 线性扣分:max(0, 100 - 违规次数 * 20) |
|
||||||
|
|
||||||
|
```
|
||||||
|
综合评分 = 授课量归一化分 * 0.35 + 出勤率 * 0.25 + 满员率 * 0.25 + 违规分 * 0.15
|
||||||
|
```
|
||||||
|
|
||||||
|
**公式变更历史**:
|
||||||
|
- 初版(2026-07-22): `授课量归一化(最大值归一化) * 0.4 + 出勤率 * 0.3 + 满员率 * 0.3`,违规仅展示不参与评分
|
||||||
|
- 修订(2026-07-26): 授课量归一化改为百分位排名,违规纳入评分,权重重新分配
|
||||||
|
|
||||||
|
**拒绝的替代方案**: 详见设计文档 `docs/coach-performance-design.md`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. 不包含学员留存率
|
||||||
|
|
||||||
**选择**: 首版不计算学员留存率。
|
**选择**: 首版不计算学员留存率。
|
||||||
|
|
||||||
@@ -69,11 +125,30 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 10. 不引入 Redis 缓存
|
||||||
|
|
||||||
|
**选择**: 教练业绩统计数据不进行 Redis 缓存,每次请求实时计算。
|
||||||
|
|
||||||
|
**理由**: 业绩数据需要准实时性,缓存可能导致教练查看时数据滞后;且当前教练数量级下,6 条聚合查询的响应时间可接受。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11. getCoachPerformanceById 复用全量查询
|
||||||
|
|
||||||
|
**选择**: 查询单个教练业绩时,内部调用 `getCoachPerformanceList` 获取全量后过滤。暂不新增按教练 ID 的单独 DAO 方法。
|
||||||
|
|
||||||
|
**理由**: 当前教练数量有限,全量查询后再过滤的性能损耗可接受,优先保持代码简洁。
|
||||||
|
|
||||||
|
**风险**: 教练数量增长后需要重新评估,届时可新增按 coach_id 直查的 DAO 方法。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 影响
|
## 影响
|
||||||
|
|
||||||
### 后端变更
|
### 后端变更
|
||||||
- `gym-dataCount` 模块新增:`CoachPerformance` domain、`CoachPerformanceHandler`、`CoachPerformanceDao`
|
- `gym-dataCount` 模块新增:`CoachPerformance` domain、`CoachPerformanceHandler`、`DataStatisticsDao`(教练业绩相关方法)
|
||||||
- `manage-app` 的 `SystemRouter` 中新增 2 条路由
|
- `manage-app` 的 `SystemRouter` 中新增 3 条路由
|
||||||
|
- `DataStatisticsServiceImpl` 新增 `getCoachPerformanceList`、`getCoachPerformanceById`、`calculateFillRate` 方法
|
||||||
|
|
||||||
### 前端变更
|
### 前端变更
|
||||||
- `StatisticsDashboard.vue` 新增"教练业绩"Tab
|
- `StatisticsDashboard.vue` 新增"教练业绩"Tab
|
||||||
@@ -96,3 +171,28 @@
|
|||||||
新建 `gym-coach-performance` 独立 Maven 模块。
|
新建 `gym-coach-performance` 独立 Maven 模块。
|
||||||
- 优点:职责隔离清晰
|
- 优点:职责隔离清晰
|
||||||
- 缺点:模块碎片化,增加编译和依赖管理成本
|
- 缺点:模块碎片化,增加编译和依赖管理成本
|
||||||
|
|
||||||
|
### 方案 C:授课量最大值归一化(已拒绝,初版方案)
|
||||||
|
`normalizedCourses = courses / maxCourses * 100`
|
||||||
|
- 优点:数学简洁
|
||||||
|
- 缺点:若有一位教练授课量远超其他,中游教练得分被严重压缩;鼓励"互卷"而非"达标"
|
||||||
|
|
||||||
|
### 方案 D:授课量对数归一化(已拒绝)
|
||||||
|
`normalizedCourses = ln(courses + 1) / ln(maxCourses + 1) * 100`
|
||||||
|
- 优点:自然压制极端值
|
||||||
|
- 缺点:解释性弱,非技术人员难以理解评分含义
|
||||||
|
|
||||||
|
### 方案 E:授课量固定目标归一化(已拒绝)
|
||||||
|
`normalizedCourses = min(courses / target * 100, 100)`,target 可配置
|
||||||
|
- 优点:变成"达标制",不受其他教练影响
|
||||||
|
- 缺点:target 值需要根据实际数据校准,设置不当会全员满分或全员不及格
|
||||||
|
|
||||||
|
### 方案 F:违规阶梯扣分(已拒绝)
|
||||||
|
0次=100, 1次=70, 2次=40, 3次=10, >=4次=0
|
||||||
|
- 优点:首次违规惩罚重,有威慑力
|
||||||
|
- 缺点:阶梯粒度太粗,第 1 次和第 2 次违规之间差距 30 分,过于激进
|
||||||
|
|
||||||
|
### 方案 G:违规归一化扣分(已拒绝)
|
||||||
|
`violationScore = (1 - violations / maxViolations) * 100`
|
||||||
|
- 优点:相对于最差教练扣分
|
||||||
|
- 缺点:依赖数据集中的最大值,若所有教练都无违规则无意义
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
# ADR-0002: 教练迟到/缺席时间判定可配置化
|
||||||
|
|
||||||
|
**日期**: 2026-07-26
|
||||||
|
**状态**: 已决定
|
||||||
|
**决策者**: 通过 grill-with-docs 追问明确
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
当前教练开课/结课/迟到/缺席的时间阈值全部硬编码在代码中:
|
||||||
|
|
||||||
|
| 硬编码值 | 位置 | 含义 |
|
||||||
|
|----------|------|------|
|
||||||
|
| 60 分钟 | CoachCourseService + CoachCourseScheduler | 长/短课时分界线 |
|
||||||
|
| 10 分钟 | CoachCourseService L213 | 长课正常开课窗口 |
|
||||||
|
| 30 分钟 | CoachCourseService L217, Scheduler L118 | 长课迟到/缺席截止线 |
|
||||||
|
| 10% | CoachCourseService L229 | 短课正常开课比例 |
|
||||||
|
| 25% | CoachCourseService L230, Scheduler L120 | 短课迟到/缺席比例 |
|
||||||
|
| 10 分钟 | CoachCourseService L283, Scheduler L34 | 结课宽限期 |
|
||||||
|
|
||||||
|
业务方要求:
|
||||||
|
1. **前端统一传入绝对值**(分钟),短课时比例也由前端换算后传入
|
||||||
|
2. 支持**按课程时长区间**匹配不同规则
|
||||||
|
3. 配置存储在**数据库**中
|
||||||
|
4. **热更新**——修改配置后无需重启即生效
|
||||||
|
5. 配置缺失/非法时使用**硬编码值兜底**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
### 1. 架构:新建 `gym-coach-config` 独立模块
|
||||||
|
|
||||||
|
**选择**: 创建新模块 `gym-coach-config`,封装时间规则配置的完整功能链。
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 将可配置化逻辑从 `gym-coach` 中解耦,符合单一职责原则
|
||||||
|
- `gym-coach-config` 提供规则 CRUD + 规则匹配服务,是纯"配置域"
|
||||||
|
- `gym-coach` 和 `gym-coach-config` 之间通过依赖注入协作,`gym-coach` 依赖 `gym-coach-config`
|
||||||
|
- 后续若其他模块(如签到、预约)也需要时间阈值配置化,可直接复用
|
||||||
|
|
||||||
|
**替代方案被拒绝**:
|
||||||
|
- 放在 `gym-coach` 模块内:配置逻辑和业务逻辑耦合,违反职责分离
|
||||||
|
- 放在 `manage-sys` 的字典模块:字典是通用 key-value 对,无法支撑规则匹配(需范围查询 + 优先级排序)
|
||||||
|
|
||||||
|
### 2. 数据模型:`coach_time_rule` 表
|
||||||
|
|
||||||
|
采用规则表设计,每条规则定义了一个课程时长区间及其对应的时间阈值:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE coach_time_rule (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
min_duration INTEGER, -- 课程时长下限(分钟),NULL 表示无下限
|
||||||
|
max_duration INTEGER, -- 课程时长上限(分钟),NULL 表示无上限
|
||||||
|
normal_window INTEGER NOT NULL, -- 正常开课窗口(分钟)
|
||||||
|
late_window INTEGER NOT NULL, -- 迟到/缺席截止窗口(分钟)
|
||||||
|
end_grace INTEGER NOT NULL, -- 结课宽限期(分钟)
|
||||||
|
is_default BOOLEAN DEFAULT FALSE, -- 是否默认规则
|
||||||
|
sort_order INTEGER DEFAULT 0, -- 优先级
|
||||||
|
status CHAR(1) DEFAULT '1',
|
||||||
|
remark VARCHAR(500),
|
||||||
|
create_by VARCHAR(64),
|
||||||
|
update_by VARCHAR(64),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例数据**:
|
||||||
|
|
||||||
|
| id | min_duration | max_duration | normal_window | late_window | end_grace | is_default | 说明 |
|
||||||
|
|----|-------------|-------------|---------------|-------------|-----------|------------|------|
|
||||||
|
| 1 | NULL | NULL | 10 | 30 | 10 | true | 默认规则:原长课逻辑 |
|
||||||
|
| 2 | NULL | 59 | 1 | 15 | 5 | false | 短课(<60分钟):最小1分钟正常,15分钟迟到 |
|
||||||
|
|
||||||
|
### 3. 规则匹配策略
|
||||||
|
|
||||||
|
```
|
||||||
|
对于一门课程(时长 = endTime - startTime 的分钟数):
|
||||||
|
|
||||||
|
1. 从 Redis 缓存中获取所有启用规则(status='1', deleted_at IS NULL)
|
||||||
|
2. 过滤出 minDuration <= courseDuration <= maxDuration 的规则
|
||||||
|
3. 选择范围最精确的规则 —— 即 (maxDuration - minDuration) 最小的那条
|
||||||
|
4. 若无匹配规则,使用 is_default=true 的默认规则
|
||||||
|
5. 若默认规则也不存在,使用硬编码兜底值
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 热更新机制
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┐ POST/PUT/DELETE ┌──────────────────┐
|
||||||
|
│ 前端 │ ──────────────────> │ CoachTimeRuleHandler │
|
||||||
|
└──────────┘ └────────┬─────────┘
|
||||||
|
│
|
||||||
|
┌──────▼──────┐
|
||||||
|
│ DB 更新 │
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
┌──────▼──────┐
|
||||||
|
│ 删除 Redis │
|
||||||
|
│ key: │
|
||||||
|
│ coach:time: │
|
||||||
|
│ rules │
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
┌──────────────┐ 下次开课/调度器触发时 ┌──▼───────────┐
|
||||||
|
│ 业务代码 │ <────────────────── │ Redis Miss │
|
||||||
|
│ (Service/ │ │ → 从 DB 加载 │
|
||||||
|
│ Scheduler) │ │ → 写入 Redis │
|
||||||
|
└──────────────┘ └──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- 写操作(创建/更新/删除规则)→ 更新 DB → 立即删除 Redis 缓存 key
|
||||||
|
- 读操作 → 先查 Redis → 未命中则查 DB → 写入 Redis(TTL=300s,兜底)
|
||||||
|
- 每次业务调用(开课/调度器)都实时从 CoachTimeRuleService 获取最新规则,不缓存本地变量
|
||||||
|
|
||||||
|
### 5. 兜底策略
|
||||||
|
|
||||||
|
| 场景 | 行为 |
|
||||||
|
|------|------|
|
||||||
|
| 所有规则被删除 | 使用硬编码默认值(原逻辑:长课 10/30,短课 10%/25%,结课 10) |
|
||||||
|
| 单条规则中值为 null/负数 | 该字段使用硬编码兜底值 |
|
||||||
|
| Redis 不可用 | 降级为每次查 DB |
|
||||||
|
| DB 不可用 | 使用硬编码兜底值 |
|
||||||
|
|
||||||
|
### 6. API 设计
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/coach/time-rules -- 获取所有规则列表
|
||||||
|
GET /api/coach/time-rules/{id} -- 获取单条规则
|
||||||
|
POST /api/coach/time-rules -- 创建规则
|
||||||
|
PUT /api/coach/time-rules/{id} -- 更新规则
|
||||||
|
DELETE /api/coach/time-rules/{id} -- 删除规则
|
||||||
|
```
|
||||||
|
|
||||||
|
POST/PUT 请求体:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"minDuration": 60, // 可选,null 表示无下限
|
||||||
|
"maxDuration": null, // 可选,null 表示无上限
|
||||||
|
"normalWindow": 10, // 必填,正常开课窗口(分钟)
|
||||||
|
"lateWindow": 30, // 必填,迟到/缺席截止窗口(分钟)
|
||||||
|
"endGrace": 10, // 必填,结课宽限期(分钟)
|
||||||
|
"isDefault": true, // 是否设为默认规则
|
||||||
|
"sortOrder": 0,
|
||||||
|
"remark": "默认规则"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. API 校验规则
|
||||||
|
|
||||||
|
后端在 Handler 层对前端传入的值做合法性校验:
|
||||||
|
- `normalWindow`:必须 >= 1 且 <= 1440(一天内)
|
||||||
|
- `lateWindow`:必须 >= `normalWindow` 且 <= 1440
|
||||||
|
- `endGrace`:必须 >= 0 且 <= 1440
|
||||||
|
- `minDuration` 和 `maxDuration`:若同时非空,`maxDuration` 必须 >= `minDuration`
|
||||||
|
- 若前端传入非法值,返回 HTTP 400 + 具体错误信息;不落库
|
||||||
|
|
||||||
|
### 8. 模块依赖关系
|
||||||
|
|
||||||
|
```
|
||||||
|
manage-app
|
||||||
|
├── gym-coach (依赖 gym-coach-config)
|
||||||
|
│ └── CoachCourseService → 注入 CoachTimeRuleService 获取规则
|
||||||
|
│ └── CoachCourseScheduler → 注入 CoachTimeRuleService 获取规则
|
||||||
|
└── gym-coach-config (新模块)
|
||||||
|
├── handler/CoachTimeRuleHandler -- HTTP 处理器
|
||||||
|
├── service/CoachTimeRuleService -- 规则匹配 + 缓存
|
||||||
|
├── domain/CoachTimeRule -- 领域对象
|
||||||
|
├── repository/ICoachTimeRuleRepository -- 仓储接口
|
||||||
|
└── router -- 路由注册
|
||||||
|
manage-db
|
||||||
|
├── entity/CoachTimeRuleEntity -- DB 实体
|
||||||
|
├── dao/CoachTimeRuleDao -- DAO (R2DBC)
|
||||||
|
└── migration/V29__Create_coach_time_rule.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
| 文件 | 变更类型 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `pom.xml` | 新增 | 添加 `gym-coach-config` 模块 |
|
||||||
|
| `gym-coach/pom.xml` | 修改 | 添加 `gym-coach-config` 依赖 |
|
||||||
|
| `CoachCourseService.java` | 修改 | 注入 `CoachTimeRuleService`,替换硬编码阈值 |
|
||||||
|
| `CoachCourseScheduler.java` | 修改 | 注入 `CoachTimeRuleService`,替换硬编码阈值 |
|
||||||
|
| `SystemRouter.java` | 修改 | 注册新路由 |
|
||||||
|
| 新建模块文件 | 新增 | 约 8-10 个 Java 文件 + 1 个 SQL 迁移 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已知问题与修复记录
|
||||||
|
|
||||||
|
### 修复 1:Flyway 版本冲突
|
||||||
|
|
||||||
|
原始版本使用了 V25/V26,与已有迁移冲突。最终使用 V29(建表)/ V30(菜单)。
|
||||||
|
|
||||||
|
### 修复 2:LocalDateTime Redis 反序列化
|
||||||
|
|
||||||
|
`CoachTimeRule.domain` 的 `createdAt`/`updatedAt` 存入 Redis 后反序列化失败(DB 格式 `yyyy-MM-dd HH:mm:ss` 无 `T` 分隔符,Jackson 默认期望 ISO 格式)。已添加 `@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")`。
|
||||||
|
|
||||||
|
### 修复 3:默认规则回退未做区间匹配校验
|
||||||
|
|
||||||
|
**问题**:当有区间限制的规则(如 `minDuration=30`)被标记为 `isDefault=true`,或原始默认规则被修改了区间时,不匹配该区间的课程时长(如 20 分钟)会被错误应用该规则的阈值。
|
||||||
|
|
||||||
|
**修复**:`doMatch()` 中回退到默认规则时,增加 `r.matches(courseDurationMinutes)` 校验。若默认规则也不匹配,继续回退到 `buildFallbackRule` 兜底。
|
||||||
|
|
||||||
|
```diff
|
||||||
|
- .filter(r -> Boolean.TRUE.equals(r.getIsDefault()))
|
||||||
|
+ .filter(r -> Boolean.TRUE.equals(r.getIsDefault()) && r.matches(courseDurationMinutes))
|
||||||
|
```
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
# 教练业绩统计设计文档
|
||||||
|
|
||||||
|
**版本**: v2.0
|
||||||
|
**日期**: 2026-07-26
|
||||||
|
**状态**: 已确定
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、功能概述
|
||||||
|
|
||||||
|
教练业绩统计为体育馆管理系统提供按教练维度的绩效评估,帮助管理者横向对比教练表现、激励教练提升教学质量。
|
||||||
|
|
||||||
|
### 核心能力
|
||||||
|
|
||||||
|
- **教练排行榜**:按综合评分降序排列所有教练
|
||||||
|
- **教练详情**:查看单个教练的六项指标详情
|
||||||
|
- **自查看板**:教练查看自己的业绩表现
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、指标体系
|
||||||
|
|
||||||
|
### 2.1 六项指标总览
|
||||||
|
|
||||||
|
| 序号 | 指标 | 类型 | 含义 | 数据源 |
|
||||||
|
|------|------|------|------|--------|
|
||||||
|
| 1 | 授课量 | 基础指标 | 统计周期内已完成的团课数量 | `group_course.status IN ('2','6')` |
|
||||||
|
| 2 | 出席人次 | 基础指标 | 学员实际参与的人次 | `group_course_booking.status IN ('2','4','5')` |
|
||||||
|
| 3 | 总预约数 | 基础指标 | 学员预约该教练课程的次数(仅已预约状态) | `group_course_booking.status = '0'` |
|
||||||
|
| 4 | 出勤率 | 派生指标 | 出席人次 / 总预约数 * 100 | 指标2 + 指标3 |
|
||||||
|
| 5 | 满员率 | 派生指标 | 各课程出席人数/满员上限的平均值 | `group_course.max_members` + 指标2明细 |
|
||||||
|
| 6 | 违规次数 | 基础指标 | 统计周期内违规记录数 | `coach_violation` |
|
||||||
|
| 7 | 综合评分 | 派生指标 | 加权综合得分(详见第三章) | 指标1-6 |
|
||||||
|
|
||||||
|
### 2.2 指标口径详解
|
||||||
|
|
||||||
|
#### 授课量
|
||||||
|
|
||||||
|
```
|
||||||
|
SELECT coach_id, COUNT(*) FROM group_course
|
||||||
|
WHERE end_time >= :startTime AND end_time < :endTime
|
||||||
|
AND status IN ('2', '6') AND deleted_at IS NULL
|
||||||
|
GROUP BY coach_id
|
||||||
|
```
|
||||||
|
|
||||||
|
- **status='2'**: 教练手动结课
|
||||||
|
- **status='6'**: 系统自动结课
|
||||||
|
- **排除**: 已取消(status='1')、教练缺席(status='5')的课程
|
||||||
|
|
||||||
|
#### 出席人次
|
||||||
|
|
||||||
|
```
|
||||||
|
SELECT gc.coach_id, COUNT(*) FROM group_course_booking b
|
||||||
|
INNER JOIN group_course gc ON b.course_id = gc.id
|
||||||
|
WHERE b.status IN ('2', '4', '5') AND b.deleted_at IS NULL
|
||||||
|
AND gc.deleted_at IS NULL
|
||||||
|
AND gc.end_time >= :startTime AND gc.end_time < :endTime
|
||||||
|
GROUP BY gc.coach_id
|
||||||
|
```
|
||||||
|
|
||||||
|
- **status='2'**: 已出席 — 学员正常到课
|
||||||
|
- **status='4'**: 教练缺席 — 教练未到,学员仍需记录
|
||||||
|
- **status='5'**: 迟到 — 学员迟到但仍到场参与
|
||||||
|
|
||||||
|
> **设计意图**: 教练缺席和迟到时,学员仍到达了现场(或尝试参与),责任在教练而非学员,故计入出席人次。学员无故缺席(status='3')不计入,因其既非教练功劳也非教练责任。
|
||||||
|
|
||||||
|
#### 总预约数
|
||||||
|
|
||||||
|
```
|
||||||
|
SELECT gc.coach_id, COUNT(*) FROM group_course_booking b
|
||||||
|
INNER JOIN group_course gc ON b.course_id = gc.id
|
||||||
|
WHERE b.status = '0' AND b.deleted_at IS NULL
|
||||||
|
AND gc.deleted_at IS NULL
|
||||||
|
AND gc.end_time >= :startTime AND gc.end_time < :endTime
|
||||||
|
GROUP BY gc.coach_id
|
||||||
|
```
|
||||||
|
|
||||||
|
- **仅 status='0'(已预约)**: 作为出勤率分母,表示"承诺来上课的学员"。
|
||||||
|
|
||||||
|
#### 满员率
|
||||||
|
|
||||||
|
```
|
||||||
|
SELECT gc.coach_id, gc.max_members, COUNT(b.id) AS attended
|
||||||
|
FROM group_course gc
|
||||||
|
LEFT JOIN group_course_booking b ON gc.id = b.course_id
|
||||||
|
AND b.status IN ('2','4','5') AND b.deleted_at IS NULL
|
||||||
|
WHERE gc.end_time >= :startTime AND gc.end_time < :endTime
|
||||||
|
AND gc.status IN ('2', '6') AND gc.deleted_at IS NULL
|
||||||
|
GROUP BY gc.coach_id, gc.id, gc.max_members
|
||||||
|
```
|
||||||
|
|
||||||
|
- 对每个已完成课程,计算 `出席人数 / max_members`
|
||||||
|
- 所有课程的比值取平均值
|
||||||
|
- `max_members = 0` 的课程被跳过(除零防御)
|
||||||
|
|
||||||
|
#### 违规次数
|
||||||
|
|
||||||
|
```
|
||||||
|
SELECT coach_id, COUNT(*) FROM coach_violation
|
||||||
|
WHERE violation_time >= :startTime AND violation_time < :endTime
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
GROUP BY coach_id
|
||||||
|
```
|
||||||
|
|
||||||
|
- 违规类型: `COACH_LATE`(迟到)、`COACH_ABSENT`(缺席)、`NOT_MANUAL_END`(未手动结课)
|
||||||
|
|
||||||
|
#### 时间基准
|
||||||
|
|
||||||
|
所有指标均基于 `group_course.end_time` 过滤时间范围。跨月课程归属于结束时间所在的月份。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、综合评分算法
|
||||||
|
|
||||||
|
### 3.1 最终公式
|
||||||
|
|
||||||
|
```
|
||||||
|
综合评分 = 授课量归一化分 * 0.35
|
||||||
|
+ 出勤率 * 0.25
|
||||||
|
+ 满员率 * 0.25
|
||||||
|
+ 违规分 * 0.15
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 授课量归一化:百分位排名法(方案 B)
|
||||||
|
|
||||||
|
对于教练数为 N 的集合:
|
||||||
|
|
||||||
|
1. 将所有教练按授课量升序排列
|
||||||
|
2. 统计授课量严格小于当前教练的教练数 `C_fewer`
|
||||||
|
3. `normalizedCourses = C_fewer / (N - 1) * 100`(N=1 时取 100)
|
||||||
|
|
||||||
|
**示例**(4 位教练):
|
||||||
|
|
||||||
|
| 教练 | 授课量 | 小于其的教练数 | 归一化分 |
|
||||||
|
|------|--------|---------------|---------|
|
||||||
|
| A | 20 | 3 | 100.0 |
|
||||||
|
| B | 15 | 2 | 66.7 |
|
||||||
|
| C | 10 | 1 | 33.3 |
|
||||||
|
| D | 5 | 0 | 0.0 |
|
||||||
|
|
||||||
|
**设计意图**: 百分位排名在"相对比较"和"公平性"之间取得平衡。授课量最大的教练得满分,最少的得 0 分,中间按排名线性分布。不受极端值影响——即使第一名开 100 节课、第二名只开 20 节,第二名的排名分数依然是 `2/3 * 100 ≈ 66.7`。
|
||||||
|
|
||||||
|
### 3.3 违规分:线性扣分法(方案 A)
|
||||||
|
|
||||||
|
```
|
||||||
|
violationScore = max(0, 100 - violations * 20)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 违规次数 | 违规分 |
|
||||||
|
|----------|--------|
|
||||||
|
| 0 | 100 |
|
||||||
|
| 1 | 80 |
|
||||||
|
| 2 | 60 |
|
||||||
|
| 3 | 40 |
|
||||||
|
| 4 | 20 |
|
||||||
|
| 5+ | 0 |
|
||||||
|
|
||||||
|
**设计意图**: 线性扣分简单直观,每次违规固定扣 20 分,累计 5 次后清零。在 15% 的权重下,每次违规对综合评分的影响约为 `20 * 0.15 = 3 分`。
|
||||||
|
|
||||||
|
### 3.4 出勤率 & 满员率
|
||||||
|
|
||||||
|
这两项直接使用原始百分比(0-100),无需归一化——它们天然在 0-100 范围内且具有绝对含义。
|
||||||
|
|
||||||
|
```
|
||||||
|
出勤率 = 出席人次 / 总预约数 * 100(分母为 0 时取 0)
|
||||||
|
满员率 = avg(单个课程出席人数 / max_members) * 100(跳过 max_members=0 的课程)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、方案选择记录
|
||||||
|
|
||||||
|
### 4.1 授课量归一化方案
|
||||||
|
|
||||||
|
| 方案 | 公式 | 优点 | 缺点 | 决定 |
|
||||||
|
|------|------|------|------|------|
|
||||||
|
| **B: 百分位排名** | `C_fewer / (N-1) * 100` | 直观、不受极端值影响 | 对教练总数敏感(N<3 时分布粗糙) | **采纳** |
|
||||||
|
| A: 最大值归一化 | `courses / max(courses) * 100` | 数学简洁 | 极端值压缩中游得分 | 初版方案,已废弃 |
|
||||||
|
| C: 对数归一化 | `ln(x+1)/ln(m+1)*100` | 压制极端值 | 解释性弱 | 已拒绝 |
|
||||||
|
| D: 固定目标 | `min(x/target*100, 100)` | 达标制、不互卷 | target 难校准 | 已拒绝 |
|
||||||
|
|
||||||
|
### 4.2 违规扣分方案
|
||||||
|
|
||||||
|
| 方案 | 公式 | 优点 | 缺点 | 决定 |
|
||||||
|
|------|------|------|------|------|
|
||||||
|
| **A: 线性扣分** | `max(0, 100 - v*20)` | 简单直白,每次等量扣分 | 多次违规后惩罚不再加剧 | **采纳** |
|
||||||
|
| B: 阶梯扣分 | 0→100, 1→70, 2→40, 3→10 | 首次违规惩罚重,有威慑力 | 第 1 到第 2 次差距 30 分,太激进 | 已拒绝 |
|
||||||
|
| C: 归一化扣分 | `(1 - v/max)*100` | 相对最差教练 | 依赖数据集,全员无违规则无意义 | 已拒绝 |
|
||||||
|
|
||||||
|
### 4.3 权重分配方案
|
||||||
|
|
||||||
|
| 方案 | 授课量 | 出勤率 | 满员率 | 违规 | 决定 |
|
||||||
|
|------|--------|--------|--------|------|------|
|
||||||
|
| **A: 轻违规** | 35% | 25% | 25% | 15% | **采纳** |
|
||||||
|
| B: 中违规 | 30% | 25% | 25% | 20% | 已拒绝 |
|
||||||
|
| C: 重违规 | 30% | 23% | 22% | 25% | 已拒绝 |
|
||||||
|
|
||||||
|
### 4.4 出勤率口径方案
|
||||||
|
|
||||||
|
| 方案 | 分子 | 分母 | 决定 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| **当前** | status IN ('2','4','5') | status='0' | **采纳** |
|
||||||
|
| 初版 | status='2' | status!='1' | 已废弃 |
|
||||||
|
| 替代方案1 | status='2' | status IN ('0','2','3') | 已拒绝(无故缺席应排除) |
|
||||||
|
| 替代方案2 | status='2' | status IN ('0','2') | 已拒绝(不能区分取消预约) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、数据模型
|
||||||
|
|
||||||
|
### 5.1 API 响应模型
|
||||||
|
|
||||||
|
```java
|
||||||
|
public class CoachPerformance {
|
||||||
|
Long coachId; // 教练ID
|
||||||
|
String coachName; // 教练昵称
|
||||||
|
String avatar; // 头像URL
|
||||||
|
Long completedCourses; // 授课量
|
||||||
|
Long attendedStudents; // 出席人次
|
||||||
|
Long totalBookings; // 总预约数
|
||||||
|
Double attendanceRate; // 出勤率 (%)
|
||||||
|
Double fillRate; // 满员率 (%)
|
||||||
|
Long violationCount; // 违规次数
|
||||||
|
Double compositeScore; // 综合评分
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 API 接口
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/api/datacount/coach-performance/ranking` | 全部教练业绩排行榜 |
|
||||||
|
| GET | `/api/datacount/coach-performance/{coachId}` | 单个教练业绩详情 |
|
||||||
|
| GET | `/api/datacount/coach-performance/mine?coachId=` | 教练自查看板 |
|
||||||
|
|
||||||
|
**查询参数**: `statType`, `periodType`(DAY/WEEK/MONTH/LAST_30_DAYS/LAST_90_DAYS/YEAR), `startTime`, `endTime`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、代码架构
|
||||||
|
|
||||||
|
```
|
||||||
|
CoachPerformanceHandler ── Reactive Router Function,解析请求
|
||||||
|
│
|
||||||
|
IDataStatisticsService ── 接口定义
|
||||||
|
│
|
||||||
|
DataStatisticsServiceImpl ── 6 并行查询 + 聚合计算
|
||||||
|
│
|
||||||
|
DataStatisticsDao ── DatabaseClient SQL 聚合
|
||||||
|
│
|
||||||
|
┌───┼───┬───┬───┬───┐
|
||||||
|
▼ ▼ ▼ ▼ ▼ ▼
|
||||||
|
sys_user group_course group_course_booking coach_violation
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查询执行流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Flux: getAllCoachesWithInfo() ─→ Map<coachId, 基本信息>
|
||||||
|
2. Flux: countCompletedCoursesByCoach() ─→ Map<coachId, 授课量> ┐
|
||||||
|
3. Flux: countAttendedStudentsByCoach() ─→ Map<coachId, 出席人次> │
|
||||||
|
4. Flux: countTotalBookingsByCoach() ─→ Map<coachId, 总预约数> ├─ Mono.zip
|
||||||
|
5. Flux: getFillRateDetailByCoach() ─→ Map<coachId, List<明细>> │
|
||||||
|
6. Flux: countViolationsByCoach() ─→ Map<coachId, 违规次数> ┘
|
||||||
|
│
|
||||||
|
flatMapMany: 逐教练计算指标
|
||||||
|
│
|
||||||
|
sorted: 按综合评分降序
|
||||||
|
│
|
||||||
|
Flux<CoachPerformance>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、边界情况处理
|
||||||
|
|
||||||
|
| 场景 | 行为 |
|
||||||
|
|------|------|
|
||||||
|
| 教练无任何已完成课程 | 所有指标为 0,综合评分 = 0 + 0 + 0 + 15 = **15**(违规分满分 100 * 0.15) |
|
||||||
|
| 教练有课程但无人预约 | 授课量 > 0,出勤率/满员率 = 0 |
|
||||||
|
| 课程 max_members = 0 | 该课程跳过,不参与满员率计算 |
|
||||||
|
| 仅有 1 位教练 | 百分位排名直接返回 100 |
|
||||||
|
| 所有教练授课量相同 | 所有教练 `C_fewer = 0`,授课量归一化分均为 0 |
|
||||||
|
| 跨月课程 | 以 end_time 所在月份归类 |
|
||||||
|
| 查询单个教练不存在 | 返回零值 `CoachPerformance`,coachName="未知教练" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、变更历史
|
||||||
|
|
||||||
|
| 日期 | 版本 | 变更内容 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 2026-07-22 | v1.0 | 初版:最大值归一化 + 三维度评分(4:3:3),违规仅展示 |
|
||||||
|
| 2026-07-26 | v2.0 | 时间基准改为 end_time;出席人次扩展为(2,4,5);出勤率分母改为仅 status='0';授课量归一化改为百分位排名;违规纳入综合评分(权重 15%);满员率防御除零 |
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
package cn.novalon.gym.manage.auth.handler;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.auth.dto.PhoneLoginDto;
|
||||||
|
import cn.novalon.gym.manage.auth.dto.PhoneCodeLoginDto;
|
||||||
|
import cn.novalon.gym.manage.auth.dto.SendCodeRequest;
|
||||||
|
import cn.novalon.gym.manage.auth.service.PhoneAuthService;
|
||||||
|
import cn.novalon.gym.manage.auth.vo.PhoneLoginVO;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.mock.web.reactive.function.server.MockServerRequest;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.test.StepVerifier;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class PhoneAuthHandlerTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private PhoneAuthService phoneAuthService;
|
||||||
|
|
||||||
|
private PhoneAuthHandler phoneAuthHandler;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
phoneAuthHandler = new PhoneAuthHandler(phoneAuthService);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== oneClickLogin ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oneClickLogin_shouldReturnOkWithLoginResult() {
|
||||||
|
PhoneLoginVO loginVO = new PhoneLoginVO();
|
||||||
|
loginVO.setAccessToken("test-jwt-token");
|
||||||
|
loginVO.setPhone("13800138000");
|
||||||
|
|
||||||
|
PhoneLoginDto dto = new PhoneLoginDto();
|
||||||
|
dto.setAccessToken("dcloud-access-token");
|
||||||
|
|
||||||
|
when(phoneAuthService.oneClickLogin(any(PhoneLoginDto.class))).thenReturn(Mono.just(loginVO));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.body(Mono.just(dto));
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = phoneAuthHandler.oneClickLogin(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(phoneAuthService).oneClickLogin(any(PhoneLoginDto.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oneClickLogin_shouldPropagateServiceError() {
|
||||||
|
PhoneLoginDto dto = new PhoneLoginDto();
|
||||||
|
dto.setAccessToken("invalid-token");
|
||||||
|
|
||||||
|
when(phoneAuthService.oneClickLogin(any(PhoneLoginDto.class)))
|
||||||
|
.thenReturn(Mono.error(new RuntimeException("Auth failed")));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.body(Mono.just(dto));
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = phoneAuthHandler.oneClickLogin(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectError(RuntimeException.class)
|
||||||
|
.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== sendSmsCode ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendSmsCode_shouldReturnOkWithSuccessTrue() {
|
||||||
|
SendCodeRequest sendCodeRequest = new SendCodeRequest();
|
||||||
|
sendCodeRequest.setPhone("13800138000");
|
||||||
|
|
||||||
|
when(phoneAuthService.sendSmsCode("13800138000")).thenReturn(Mono.just(true));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.body(Mono.just(sendCodeRequest));
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = phoneAuthHandler.sendSmsCode(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendSmsCode_shouldReturnOkWithSuccessFalseWhenServiceReturnsFalse() {
|
||||||
|
SendCodeRequest sendCodeRequest = new SendCodeRequest();
|
||||||
|
sendCodeRequest.setPhone("13800138000");
|
||||||
|
|
||||||
|
when(phoneAuthService.sendSmsCode("13800138000")).thenReturn(Mono.just(false));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.body(Mono.just(sendCodeRequest));
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = phoneAuthHandler.sendSmsCode(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendSmsCode_shouldPropagateError() {
|
||||||
|
SendCodeRequest sendCodeRequest = new SendCodeRequest();
|
||||||
|
sendCodeRequest.setPhone("13800138000");
|
||||||
|
|
||||||
|
when(phoneAuthService.sendSmsCode("13800138000"))
|
||||||
|
.thenReturn(Mono.error(new RuntimeException("SMS service unavailable")));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.body(Mono.just(sendCodeRequest));
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = phoneAuthHandler.sendSmsCode(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectError(RuntimeException.class)
|
||||||
|
.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== codeLogin ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void codeLogin_shouldReturnOkWithLoginResult() {
|
||||||
|
PhoneLoginVO loginVO = new PhoneLoginVO();
|
||||||
|
loginVO.setAccessToken("test-jwt-token");
|
||||||
|
|
||||||
|
PhoneCodeLoginDto dto = new PhoneCodeLoginDto();
|
||||||
|
dto.setPhone("13800138000");
|
||||||
|
dto.setCode("123456");
|
||||||
|
|
||||||
|
when(phoneAuthService.codeLogin(any(PhoneCodeLoginDto.class))).thenReturn(Mono.just(loginVO));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.body(Mono.just(dto));
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = phoneAuthHandler.codeLogin(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void codeLogin_shouldPropagateServiceError() {
|
||||||
|
PhoneCodeLoginDto dto = new PhoneCodeLoginDto();
|
||||||
|
dto.setPhone("13800138000");
|
||||||
|
dto.setCode("wrong-code");
|
||||||
|
|
||||||
|
when(phoneAuthService.codeLogin(any(PhoneCodeLoginDto.class)))
|
||||||
|
.thenReturn(Mono.error(new RuntimeException("Invalid code")));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.body(Mono.just(dto));
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = phoneAuthHandler.codeLogin(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectError(RuntimeException.class)
|
||||||
|
.verify();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>cn.novalon.gym.manage</groupId>
|
||||||
|
<artifactId>gym-manage-api</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>gym-brand</artifactId>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<name>Gym Brand</name>
|
||||||
|
<description>Brand Customization Module - Logo Upload, Color Settings, Real-time Preview</description>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.novalon.gym.manage</groupId>
|
||||||
|
<artifactId>manage-common</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.novalon.gym.manage</groupId>
|
||||||
|
<artifactId>manage-sys</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springdoc</groupId>
|
||||||
|
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.projectreactor</groupId>
|
||||||
|
<artifactId>reactor-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<!-- Aliyun OSS SDK -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.aliyun.oss</groupId>
|
||||||
|
<artifactId>aliyun-sdk-oss</artifactId>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-jar-plugin</artifactId>
|
||||||
|
<version>3.4.2</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>default-jar</id>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>jar</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>3.11.0</version>
|
||||||
|
<configuration>
|
||||||
|
<source>21</source>
|
||||||
|
<target>21</target>
|
||||||
|
<annotationProcessorPaths>
|
||||||
|
<path>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>${lombok.version}</version>
|
||||||
|
</path>
|
||||||
|
</annotationProcessorPaths>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.jacoco</groupId>
|
||||||
|
<artifactId>jacoco-maven-plugin</artifactId>
|
||||||
|
<version>0.8.12</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>prepare-agent</id>
|
||||||
|
<goals>
|
||||||
|
<goal>prepare-agent</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>report</id>
|
||||||
|
<phase>verify</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>report</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
package cn.novalon.gym.manage.brand.config;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.brand.websocket.BrandWebSocketHandler;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import org.springframework.web.reactive.HandlerMapping;
|
||||||
|
import org.springframework.web.reactive.handler.SimpleUrlHandlerMapping;
|
||||||
|
import org.springframework.web.reactive.socket.WebSocketHandler;
|
||||||
|
import org.springframework.web.reactive.socket.server.support.WebSocketHandlerAdapter;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 品牌预览 WebSocket 配置
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-23
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class BrandWebSocketConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public HandlerMapping brandWebSocketHandlerMapping(BrandWebSocketHandler brandWebSocketHandler) {
|
||||||
|
Map<String, WebSocketHandler> map = new HashMap<>();
|
||||||
|
map.put("/ws/brand", brandWebSocketHandler);
|
||||||
|
|
||||||
|
SimpleUrlHandlerMapping handlerMapping = new SimpleUrlHandlerMapping();
|
||||||
|
handlerMapping.setOrder(Ordered.HIGHEST_PRECEDENCE + 1);
|
||||||
|
handlerMapping.setUrlMap(map);
|
||||||
|
return handlerMapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public WebSocketHandlerAdapter brandWebSocketHandlerAdapter() {
|
||||||
|
return new WebSocketHandlerAdapter();
|
||||||
|
}
|
||||||
|
}
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
package cn.novalon.gym.manage.brand.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 阿里云 OSS 配置属性
|
||||||
|
* <p>
|
||||||
|
* 配置前缀: brand.oss
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-23
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "brand.oss")
|
||||||
|
public class OssProperties {
|
||||||
|
|
||||||
|
/** 是否启用 OSS(默认关闭,仅使用本地存储) */
|
||||||
|
private boolean enabled = false;
|
||||||
|
|
||||||
|
/** OSS Endpoint(如 oss-cn-hangzhou.aliyuncs.com) */
|
||||||
|
private String endpoint;
|
||||||
|
|
||||||
|
/** AccessKey ID */
|
||||||
|
private String accessKeyId;
|
||||||
|
|
||||||
|
/** AccessKey Secret */
|
||||||
|
private String accessKeySecret;
|
||||||
|
|
||||||
|
/** Bucket 名称 */
|
||||||
|
private String bucketName;
|
||||||
|
|
||||||
|
/** 自定义域名/CDN域名(可选,用于生成访问URL) */
|
||||||
|
private String customDomain;
|
||||||
|
|
||||||
|
/** 文件存储基础路径(默认 brand) */
|
||||||
|
private String basePath = "brand";
|
||||||
|
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEnabled(boolean enabled) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEndpoint() {
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEndpoint(String endpoint) {
|
||||||
|
this.endpoint = endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAccessKeyId() {
|
||||||
|
return accessKeyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAccessKeyId(String accessKeyId) {
|
||||||
|
this.accessKeyId = accessKeyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAccessKeySecret() {
|
||||||
|
return accessKeySecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAccessKeySecret(String accessKeySecret) {
|
||||||
|
this.accessKeySecret = accessKeySecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getBucketName() {
|
||||||
|
return bucketName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBucketName(String bucketName) {
|
||||||
|
this.bucketName = bucketName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCustomDomain() {
|
||||||
|
return customDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCustomDomain(String customDomain) {
|
||||||
|
this.customDomain = customDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getBasePath() {
|
||||||
|
return basePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBasePath(String basePath) {
|
||||||
|
this.basePath = basePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断 OSS 配置是否完整可用
|
||||||
|
*/
|
||||||
|
public boolean isConfigured() {
|
||||||
|
return enabled
|
||||||
|
&& endpoint != null && !endpoint.isBlank()
|
||||||
|
&& accessKeyId != null && !accessKeyId.isBlank()
|
||||||
|
&& accessKeySecret != null && !accessKeySecret.isBlank()
|
||||||
|
&& bucketName != null && !bucketName.isBlank();
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
package cn.novalon.gym.manage.brand.core.domain;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 品牌配置领域对象
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-23
|
||||||
|
*/
|
||||||
|
public class BrandConfig {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private String tenantId;
|
||||||
|
private String logoUrl;
|
||||||
|
private String backgroundImageUrl;
|
||||||
|
private String primaryColor;
|
||||||
|
private String primaryColorRgb;
|
||||||
|
private String secondaryColor;
|
||||||
|
private String secondaryColorRgb;
|
||||||
|
private String fontFamily;
|
||||||
|
private String brandName;
|
||||||
|
private String slogan;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
private LocalDateTime deletedAt;
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public void setId(Long id) { this.id = id; }
|
||||||
|
public String getTenantId() { return tenantId; }
|
||||||
|
public void setTenantId(String tenantId) { this.tenantId = tenantId; }
|
||||||
|
public String getLogoUrl() { return logoUrl; }
|
||||||
|
public void setLogoUrl(String logoUrl) { this.logoUrl = logoUrl; }
|
||||||
|
public String getBackgroundImageUrl() { return backgroundImageUrl; }
|
||||||
|
public void setBackgroundImageUrl(String backgroundImageUrl) { this.backgroundImageUrl = backgroundImageUrl; }
|
||||||
|
public String getPrimaryColor() { return primaryColor; }
|
||||||
|
public void setPrimaryColor(String primaryColor) { this.primaryColor = primaryColor; }
|
||||||
|
public String getPrimaryColorRgb() { return primaryColorRgb; }
|
||||||
|
public void setPrimaryColorRgb(String primaryColorRgb) { this.primaryColorRgb = primaryColorRgb; }
|
||||||
|
public String getSecondaryColor() { return secondaryColor; }
|
||||||
|
public void setSecondaryColor(String secondaryColor) { this.secondaryColor = secondaryColor; }
|
||||||
|
public String getSecondaryColorRgb() { return secondaryColorRgb; }
|
||||||
|
public void setSecondaryColorRgb(String secondaryColorRgb) { this.secondaryColorRgb = secondaryColorRgb; }
|
||||||
|
public String getFontFamily() { return fontFamily; }
|
||||||
|
public void setFontFamily(String fontFamily) { this.fontFamily = fontFamily; }
|
||||||
|
public String getBrandName() { return brandName; }
|
||||||
|
public void setBrandName(String brandName) { this.brandName = brandName; }
|
||||||
|
public String getSlogan() { return slogan; }
|
||||||
|
public void setSlogan(String slogan) { this.slogan = slogan; }
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
public LocalDateTime getDeletedAt() { return deletedAt; }
|
||||||
|
public void setDeletedAt(LocalDateTime deletedAt) { this.deletedAt = deletedAt; }
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package cn.novalon.gym.manage.brand.core.repository;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 品牌配置仓储接口
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-23
|
||||||
|
*/
|
||||||
|
public interface IBrandConfigRepository {
|
||||||
|
|
||||||
|
Mono<BrandConfig> findByTenantId(String tenantId);
|
||||||
|
|
||||||
|
Mono<BrandConfig> save(BrandConfig brandConfig);
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package cn.novalon.gym.manage.brand.core.service;
|
||||||
|
|
||||||
|
import org.springframework.http.codec.multipart.FilePart;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件存储服务接口(OSS + 本地兜底)
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-23
|
||||||
|
*/
|
||||||
|
public interface FileStorageService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传图片文件,返回访问URL
|
||||||
|
*
|
||||||
|
* @param filePart 文件数据
|
||||||
|
* @param directory 存储目录(如 "logo", "background")
|
||||||
|
* @return 文件访问URL
|
||||||
|
*/
|
||||||
|
Mono<String> uploadImage(FilePart filePart, String directory);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据URL删除文件
|
||||||
|
*/
|
||||||
|
Mono<Void> deleteFile(String fileUrl);
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
package cn.novalon.gym.manage.brand.core.service;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
|
||||||
|
import org.springframework.http.codec.multipart.FilePart;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 品牌配置服务接口
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-23
|
||||||
|
*/
|
||||||
|
public interface IBrandConfigService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据租户ID获取品牌配置
|
||||||
|
*/
|
||||||
|
Mono<BrandConfig> getBrandConfig(String tenantId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传Logo
|
||||||
|
*/
|
||||||
|
Mono<BrandConfig> uploadLogo(String tenantId, FilePart filePart);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传背景图
|
||||||
|
*/
|
||||||
|
Mono<BrandConfig> uploadBackgroundImage(String tenantId, FilePart filePart);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新品牌配色
|
||||||
|
*/
|
||||||
|
Mono<BrandConfig> updateColorConfig(String tenantId, BrandConfig config);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除Logo(恢复默认)
|
||||||
|
*/
|
||||||
|
Mono<BrandConfig> removeLogo(String tenantId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除背景图(恢复默认)
|
||||||
|
*/
|
||||||
|
Mono<BrandConfig> removeBackgroundImage(String tenantId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新品牌信息(名称、口号)
|
||||||
|
*/
|
||||||
|
Mono<BrandConfig> updateBrandInfo(String tenantId, BrandConfig config);
|
||||||
|
}
|
||||||
+172
@@ -0,0 +1,172 @@
|
|||||||
|
package cn.novalon.gym.manage.brand.core.service.impl;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
|
||||||
|
import cn.novalon.gym.manage.brand.core.repository.IBrandConfigRepository;
|
||||||
|
import cn.novalon.gym.manage.brand.core.service.FileStorageService;
|
||||||
|
import cn.novalon.gym.manage.brand.core.service.IBrandConfigService;
|
||||||
|
import cn.novalon.gym.manage.brand.websocket.BrandWebSocketHandler;
|
||||||
|
import org.springframework.http.codec.multipart.FilePart;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 品牌配置服务实现
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-23
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class BrandConfigServiceImpl implements IBrandConfigService {
|
||||||
|
|
||||||
|
private final IBrandConfigRepository brandConfigRepository;
|
||||||
|
private final FileStorageService fileStorageService;
|
||||||
|
private final BrandWebSocketHandler brandWebSocketHandler;
|
||||||
|
|
||||||
|
public BrandConfigServiceImpl(
|
||||||
|
IBrandConfigRepository brandConfigRepository,
|
||||||
|
FileStorageService fileStorageService,
|
||||||
|
BrandWebSocketHandler brandWebSocketHandler) {
|
||||||
|
this.brandConfigRepository = brandConfigRepository;
|
||||||
|
this.fileStorageService = fileStorageService;
|
||||||
|
this.brandWebSocketHandler = brandWebSocketHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<BrandConfig> getBrandConfig(String tenantId) {
|
||||||
|
return brandConfigRepository.findByTenantId(tenantId)
|
||||||
|
.switchIfEmpty(Mono.defer(() -> createDefaultConfig(tenantId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<BrandConfig> uploadLogo(String tenantId, FilePart filePart) {
|
||||||
|
return fileStorageService.uploadImage(filePart, "logo")
|
||||||
|
.flatMap(logoUrl -> getOrCreateConfig(tenantId)
|
||||||
|
.flatMap(config -> {
|
||||||
|
// 删除旧Logo
|
||||||
|
return fileStorageService.deleteFile(config.getLogoUrl())
|
||||||
|
.then(Mono.defer(() -> {
|
||||||
|
config.setLogoUrl(logoUrl);
|
||||||
|
config.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return brandConfigRepository.save(config);
|
||||||
|
}));
|
||||||
|
}))
|
||||||
|
.doOnSuccess(config -> notifyPreviewUpdate(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<BrandConfig> uploadBackgroundImage(String tenantId, FilePart filePart) {
|
||||||
|
return fileStorageService.uploadImage(filePart, "background")
|
||||||
|
.flatMap(bgUrl -> getOrCreateConfig(tenantId)
|
||||||
|
.flatMap(config -> {
|
||||||
|
return fileStorageService.deleteFile(config.getBackgroundImageUrl())
|
||||||
|
.then(Mono.defer(() -> {
|
||||||
|
config.setBackgroundImageUrl(bgUrl);
|
||||||
|
config.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return brandConfigRepository.save(config);
|
||||||
|
}));
|
||||||
|
}))
|
||||||
|
.doOnSuccess(config -> notifyPreviewUpdate(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<BrandConfig> updateColorConfig(String tenantId, BrandConfig updatedConfig) {
|
||||||
|
return getOrCreateConfig(tenantId)
|
||||||
|
.flatMap(config -> {
|
||||||
|
if (updatedConfig.getPrimaryColor() != null) {
|
||||||
|
config.setPrimaryColor(updatedConfig.getPrimaryColor());
|
||||||
|
}
|
||||||
|
if (updatedConfig.getPrimaryColorRgb() != null) {
|
||||||
|
config.setPrimaryColorRgb(updatedConfig.getPrimaryColorRgb());
|
||||||
|
}
|
||||||
|
if (updatedConfig.getSecondaryColor() != null) {
|
||||||
|
config.setSecondaryColor(updatedConfig.getSecondaryColor());
|
||||||
|
}
|
||||||
|
if (updatedConfig.getSecondaryColorRgb() != null) {
|
||||||
|
config.setSecondaryColorRgb(updatedConfig.getSecondaryColorRgb());
|
||||||
|
}
|
||||||
|
if (updatedConfig.getFontFamily() != null) {
|
||||||
|
config.setFontFamily(updatedConfig.getFontFamily());
|
||||||
|
}
|
||||||
|
config.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return brandConfigRepository.save(config);
|
||||||
|
})
|
||||||
|
.doOnSuccess(config -> notifyPreviewUpdate(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<BrandConfig> removeLogo(String tenantId) {
|
||||||
|
return getOrCreateConfig(tenantId)
|
||||||
|
.flatMap(config -> fileStorageService.deleteFile(config.getLogoUrl())
|
||||||
|
.then(Mono.defer(() -> {
|
||||||
|
config.setLogoUrl(null);
|
||||||
|
config.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return brandConfigRepository.save(config);
|
||||||
|
})))
|
||||||
|
.doOnSuccess(config -> notifyPreviewUpdate(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<BrandConfig> removeBackgroundImage(String tenantId) {
|
||||||
|
return getOrCreateConfig(tenantId)
|
||||||
|
.flatMap(config -> fileStorageService.deleteFile(config.getBackgroundImageUrl())
|
||||||
|
.then(Mono.defer(() -> {
|
||||||
|
config.setBackgroundImageUrl(null);
|
||||||
|
config.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return brandConfigRepository.save(config);
|
||||||
|
})))
|
||||||
|
.doOnSuccess(config -> notifyPreviewUpdate(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<BrandConfig> updateBrandInfo(String tenantId, BrandConfig updatedConfig) {
|
||||||
|
return getOrCreateConfig(tenantId)
|
||||||
|
.flatMap(config -> {
|
||||||
|
if (updatedConfig.getBrandName() != null) {
|
||||||
|
config.setBrandName(updatedConfig.getBrandName());
|
||||||
|
}
|
||||||
|
if (updatedConfig.getSlogan() != null) {
|
||||||
|
config.setSlogan(updatedConfig.getSlogan());
|
||||||
|
}
|
||||||
|
config.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return brandConfigRepository.save(config);
|
||||||
|
})
|
||||||
|
.doOnSuccess(config -> notifyPreviewUpdate(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取或创建品牌配置
|
||||||
|
*/
|
||||||
|
private Mono<BrandConfig> getOrCreateConfig(String tenantId) {
|
||||||
|
return brandConfigRepository.findByTenantId(tenantId)
|
||||||
|
.switchIfEmpty(Mono.defer(() -> createDefaultConfig(tenantId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为租户创建默认品牌配置
|
||||||
|
*/
|
||||||
|
private Mono<BrandConfig> createDefaultConfig(String tenantId) {
|
||||||
|
BrandConfig config = new BrandConfig();
|
||||||
|
config.setTenantId(tenantId);
|
||||||
|
config.setPrimaryColor("#00E676");
|
||||||
|
config.setPrimaryColorRgb("0,230,118");
|
||||||
|
config.setSecondaryColor("#1A1A1A");
|
||||||
|
config.setSecondaryColorRgb("26,26,26");
|
||||||
|
config.setFontFamily("default");
|
||||||
|
config.setCreatedAt(LocalDateTime.now());
|
||||||
|
config.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return brandConfigRepository.save(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过WebSocket通知前端预览更新
|
||||||
|
*/
|
||||||
|
private void notifyPreviewUpdate(BrandConfig config) {
|
||||||
|
try {
|
||||||
|
brandWebSocketHandler.broadcastBrandUpdate(config);
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("Failed to broadcast brand update: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
package cn.novalon.gym.manage.brand.core.service.impl;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.brand.config.OssProperties;
|
||||||
|
import cn.novalon.gym.manage.brand.core.service.FileStorageService;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
|
import org.springframework.http.codec.multipart.FilePart;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 双轨文件存储服务(OSS 优先 + 本地兜底)
|
||||||
|
* <p>
|
||||||
|
* 作为 FileStorageService 的 @Primary 实现,编排 OSS 和本地存储:
|
||||||
|
* <ul>
|
||||||
|
* <li>上传:优先 OSS,失败则回退到本地存储</li>
|
||||||
|
* <li>删除:同时删除 OSS 和本地副本(尽力而为)</li>
|
||||||
|
* </ul>
|
||||||
|
* 当 OSS 未启用或未配置时,直接使用本地存储。
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-23
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Primary
|
||||||
|
public class DualFileStorageService implements FileStorageService {
|
||||||
|
|
||||||
|
private final FileStorageService ossStorage;
|
||||||
|
private final FileStorageService localStorage;
|
||||||
|
private final OssProperties ossProperties;
|
||||||
|
|
||||||
|
public DualFileStorageService(
|
||||||
|
@Qualifier("ossFileStorage") FileStorageService ossStorage,
|
||||||
|
@Qualifier("localFileStorage") FileStorageService localStorage,
|
||||||
|
OssProperties ossProperties) {
|
||||||
|
this.ossStorage = ossStorage;
|
||||||
|
this.localStorage = localStorage;
|
||||||
|
this.ossProperties = ossProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<String> uploadImage(FilePart filePart, String directory) {
|
||||||
|
if (ossProperties.isConfigured()) {
|
||||||
|
return ossStorage.uploadImage(filePart, directory)
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
System.err.println("OSS upload failed, falling back to local storage: " + e.getMessage());
|
||||||
|
return localStorage.uploadImage(filePart, directory);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return localStorage.uploadImage(filePart, directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> deleteFile(String fileUrl) {
|
||||||
|
if (fileUrl == null || fileUrl.isBlank()) {
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地文件总是尝试删除
|
||||||
|
Mono<Void> localDelete = localStorage.deleteFile(fileUrl);
|
||||||
|
|
||||||
|
if (ossProperties.isConfigured()) {
|
||||||
|
// OSS 删除:忽略失败(尽力而为)
|
||||||
|
return ossStorage.deleteFile(fileUrl)
|
||||||
|
.onErrorResume(e -> Mono.empty())
|
||||||
|
.then(localDelete);
|
||||||
|
}
|
||||||
|
|
||||||
|
return localDelete;
|
||||||
|
}
|
||||||
|
}
|
||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
package cn.novalon.gym.manage.brand.core.service.impl;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.brand.core.service.FileStorageService;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.codec.multipart.FilePart;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地文件存储服务(兜底实现)
|
||||||
|
* <p>
|
||||||
|
* 当 OSS 不可用时,文件存储到服务器本地磁盘。
|
||||||
|
* 文件访问通过 /api/files/preview/ 路径提供。
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-23
|
||||||
|
*/
|
||||||
|
@Service("localFileStorage")
|
||||||
|
public class LocalFileStorageService implements FileStorageService {
|
||||||
|
|
||||||
|
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(".png", ".jpg", ".jpeg", ".gif", ".webp");
|
||||||
|
private static final long MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
||||||
|
|
||||||
|
private final String uploadDir;
|
||||||
|
private final String baseUrl;
|
||||||
|
|
||||||
|
public LocalFileStorageService(
|
||||||
|
@Value("${file.upload.dir:/tmp/uploads}") String uploadDir,
|
||||||
|
@Value("${brand.file.base-url:http://localhost:8084/api/files}") String baseUrl) {
|
||||||
|
this.uploadDir = uploadDir;
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<String> uploadImage(FilePart filePart, String directory) {
|
||||||
|
String originalFilename = filePart.filename();
|
||||||
|
String fileExtension = getFileExtension(originalFilename);
|
||||||
|
|
||||||
|
if (!ALLOWED_EXTENSIONS.contains(fileExtension.toLowerCase())) {
|
||||||
|
return Mono.error(new IllegalArgumentException(
|
||||||
|
"不支持的文件格式,仅支持 PNG/JPG/JPEG/GIF/WEBP"));
|
||||||
|
}
|
||||||
|
|
||||||
|
String newFileName = directory + "_" + UUID.randomUUID().toString() + fileExtension;
|
||||||
|
Path targetDir = Paths.get(uploadDir, "brand", directory);
|
||||||
|
|
||||||
|
return Mono.fromCallable(() -> {
|
||||||
|
if (!Files.exists(targetDir)) {
|
||||||
|
Files.createDirectories(targetDir);
|
||||||
|
}
|
||||||
|
return targetDir;
|
||||||
|
}).flatMap(dir -> {
|
||||||
|
Path filePath = dir.resolve(newFileName);
|
||||||
|
return filePart.transferTo(filePath.toFile()).thenReturn(filePath);
|
||||||
|
}).flatMap(filePath -> {
|
||||||
|
try {
|
||||||
|
long fileSize = Files.size(filePath);
|
||||||
|
if (fileSize > MAX_FILE_SIZE) {
|
||||||
|
Files.deleteIfExists(filePath);
|
||||||
|
return Mono.error(new IllegalArgumentException("文件大小超过2MB限制"));
|
||||||
|
}
|
||||||
|
String relativePath = "brand/" + directory + "/" + newFileName;
|
||||||
|
return Mono.just(baseUrl + "/preview/" + relativePath);
|
||||||
|
} catch (IOException e) {
|
||||||
|
return Mono.error(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> deleteFile(String fileUrl) {
|
||||||
|
if (fileUrl == null || fileUrl.isBlank()) {
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
return Mono.fromRunnable(() -> {
|
||||||
|
try {
|
||||||
|
String relativePath = extractRelativePath(fileUrl);
|
||||||
|
if (relativePath != null) {
|
||||||
|
Path filePath = Paths.get(uploadDir, relativePath);
|
||||||
|
Files.deleteIfExists(filePath);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
System.err.println("Failed to delete local file: " + fileUrl + ", error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getFileExtension(String filename) {
|
||||||
|
if (filename == null || !filename.contains(".")) {
|
||||||
|
return ".png";
|
||||||
|
}
|
||||||
|
return filename.substring(filename.lastIndexOf("."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractRelativePath(String fileUrl) {
|
||||||
|
if (fileUrl.contains("/files/preview/")) {
|
||||||
|
return fileUrl.substring(fileUrl.indexOf("/files/preview/") + "/files/preview/".length());
|
||||||
|
}
|
||||||
|
if (fileUrl.contains("/files/")) {
|
||||||
|
return fileUrl.substring(fileUrl.indexOf("/files/") + "/files/".length());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
package cn.novalon.gym.manage.brand.core.service.impl;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.brand.config.OssProperties;
|
||||||
|
import cn.novalon.gym.manage.brand.core.service.FileStorageService;
|
||||||
|
import com.aliyun.oss.OSS;
|
||||||
|
import com.aliyun.oss.OSSClientBuilder;
|
||||||
|
import com.aliyun.oss.model.PutObjectRequest;
|
||||||
|
import org.springframework.http.codec.multipart.FilePart;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 阿里云 OSS 文件存储服务
|
||||||
|
* <p>
|
||||||
|
* 当 brand.oss.enabled=true 且配置完整时启用,
|
||||||
|
* 将品牌图片上传至阿里云 OSS。
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-23
|
||||||
|
*/
|
||||||
|
@Service("ossFileStorage")
|
||||||
|
public class OssFileStorageService implements FileStorageService {
|
||||||
|
|
||||||
|
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(".png", ".jpg", ".jpeg", ".gif", ".webp");
|
||||||
|
private static final long MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
||||||
|
|
||||||
|
private final OssProperties ossProperties;
|
||||||
|
private OSS ossClient;
|
||||||
|
|
||||||
|
public OssFileStorageService(OssProperties ossProperties) {
|
||||||
|
this.ossProperties = ossProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 懒加载 OSS 客户端,避免未配置时启动失败
|
||||||
|
*/
|
||||||
|
private OSS getOssClient() {
|
||||||
|
if (ossClient == null && ossProperties.isConfigured()) {
|
||||||
|
synchronized (this) {
|
||||||
|
if (ossClient == null) {
|
||||||
|
ossClient = new OSSClientBuilder().build(
|
||||||
|
ossProperties.getEndpoint(),
|
||||||
|
ossProperties.getAccessKeyId(),
|
||||||
|
ossProperties.getAccessKeySecret());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ossClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<String> uploadImage(FilePart filePart, String directory) {
|
||||||
|
if (getOssClient() == null) {
|
||||||
|
return Mono.error(new IllegalStateException("OSS 未配置或未启用"));
|
||||||
|
}
|
||||||
|
|
||||||
|
String originalFilename = filePart.filename();
|
||||||
|
String fileExtension = getFileExtension(originalFilename);
|
||||||
|
|
||||||
|
if (!ALLOWED_EXTENSIONS.contains(fileExtension.toLowerCase())) {
|
||||||
|
return Mono.error(new IllegalArgumentException(
|
||||||
|
"不支持的文件格式,仅支持 PNG/JPG/JPEG/GIF/WEBP"));
|
||||||
|
}
|
||||||
|
|
||||||
|
String objectName = ossProperties.getBasePath() + "/" + directory + "/"
|
||||||
|
+ directory + "_" + UUID.randomUUID().toString() + fileExtension;
|
||||||
|
String bucketName = ossProperties.getBucketName();
|
||||||
|
|
||||||
|
return Mono.fromCallable(() -> {
|
||||||
|
Path tempFile = Files.createTempFile("oss-upload-", fileExtension);
|
||||||
|
return tempFile;
|
||||||
|
}).flatMap(tempFile -> filePart.transferTo(tempFile.toFile()).thenReturn(tempFile))
|
||||||
|
.flatMap(tempFile -> {
|
||||||
|
try {
|
||||||
|
long fileSize = Files.size(tempFile);
|
||||||
|
if (fileSize > MAX_FILE_SIZE) {
|
||||||
|
Files.deleteIfExists(tempFile);
|
||||||
|
return Mono.error(new IllegalArgumentException("文件大小超过2MB限制"));
|
||||||
|
}
|
||||||
|
|
||||||
|
PutObjectRequest putRequest = new PutObjectRequest(bucketName, objectName, tempFile.toFile());
|
||||||
|
getOssClient().putObject(putRequest);
|
||||||
|
|
||||||
|
// 删除临时文件
|
||||||
|
Files.deleteIfExists(tempFile);
|
||||||
|
|
||||||
|
// 生成访问URL
|
||||||
|
String fileUrl = buildAccessUrl(objectName);
|
||||||
|
return Mono.just(fileUrl);
|
||||||
|
} catch (Exception e) {
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(tempFile);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
return Mono.error(new RuntimeException("OSS 上传失败: " + e.getMessage(), e));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> deleteFile(String fileUrl) {
|
||||||
|
if (fileUrl == null || fileUrl.isBlank()) {
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
if (getOssClient() == null) {
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
return Mono.fromRunnable(() -> {
|
||||||
|
try {
|
||||||
|
String objectName = extractObjectName(fileUrl);
|
||||||
|
if (objectName != null) {
|
||||||
|
getOssClient().deleteObject(ossProperties.getBucketName(), objectName);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("Failed to delete OSS file: " + fileUrl + ", error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建文件访问 URL
|
||||||
|
* 优先使用自定义域名/CDN域名,否则使用 OSS 默认域名
|
||||||
|
*/
|
||||||
|
private String buildAccessUrl(String objectName) {
|
||||||
|
String domain;
|
||||||
|
if (ossProperties.getCustomDomain() != null && !ossProperties.getCustomDomain().isBlank()) {
|
||||||
|
domain = ossProperties.getCustomDomain();
|
||||||
|
if (domain.endsWith("/")) {
|
||||||
|
domain = domain.substring(0, domain.length() - 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
domain = "https://" + ossProperties.getBucketName() + "." + ossProperties.getEndpoint();
|
||||||
|
}
|
||||||
|
return domain + "/" + objectName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文件 URL 中提取 OSS Object Name
|
||||||
|
*/
|
||||||
|
private String extractObjectName(String fileUrl) {
|
||||||
|
String basePath = ossProperties.getBasePath();
|
||||||
|
int idx = fileUrl.indexOf(basePath);
|
||||||
|
if (idx >= 0) {
|
||||||
|
return fileUrl.substring(idx);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getFileExtension(String filename) {
|
||||||
|
if (filename == null || !filename.contains(".")) {
|
||||||
|
return ".png";
|
||||||
|
}
|
||||||
|
return filename.substring(filename.lastIndexOf("."));
|
||||||
|
}
|
||||||
|
}
|
||||||
+206
@@ -0,0 +1,206 @@
|
|||||||
|
package cn.novalon.gym.manage.brand.handler;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
|
||||||
|
import cn.novalon.gym.manage.brand.core.service.IBrandConfigService;
|
||||||
|
import cn.novalon.gym.manage.sys.util.AuthUtil;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.http.codec.multipart.FilePart;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 品牌配置 HTTP Handler
|
||||||
|
* <p>
|
||||||
|
* tenantId 从 JWT Token 中提取,不再通过 URL 路径参数传递,
|
||||||
|
* 确保每个用户只能操作自己租户的品牌配置。
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-23
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Tag(name = "品牌定制", description = "Logo上传、背景图上传、品牌配色设置、实时预览")
|
||||||
|
public class BrandConfigHandler {
|
||||||
|
|
||||||
|
private final IBrandConfigService brandConfigService;
|
||||||
|
private final AuthUtil authUtil;
|
||||||
|
|
||||||
|
public BrandConfigHandler(IBrandConfigService brandConfigService, AuthUtil authUtil) {
|
||||||
|
this.brandConfigService = brandConfigService;
|
||||||
|
this.authUtil = authUtil;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "获取品牌配置", description = "根据当前租户获取品牌配置信息")
|
||||||
|
public Mono<ServerResponse> getBrandConfig(ServerRequest request) {
|
||||||
|
String tenantId = authUtil.getTenantId(request);
|
||||||
|
return brandConfigService.getBrandConfig(tenantId)
|
||||||
|
.flatMap(config -> ServerResponse.ok().bodyValue(config))
|
||||||
|
.switchIfEmpty(ServerResponse.ok().bodyValue(Map.of(
|
||||||
|
"message", "未找到品牌配置,将使用默认配置"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "上传Logo", description = "上传品牌Logo图片,支持PNG/JPG格式,限制2MB以内")
|
||||||
|
public Mono<ServerResponse> uploadLogo(ServerRequest request) {
|
||||||
|
String tenantId = authUtil.getTenantId(request);
|
||||||
|
|
||||||
|
return request.multipartData()
|
||||||
|
.flatMap(multipartData -> {
|
||||||
|
var part = multipartData.getFirst("file");
|
||||||
|
if (part == null) {
|
||||||
|
return ServerResponse.badRequest()
|
||||||
|
.bodyValue(Map.of("code", 400, "message", "未上传文件"));
|
||||||
|
}
|
||||||
|
if (!(part instanceof FilePart filePart)) {
|
||||||
|
return ServerResponse.badRequest()
|
||||||
|
.bodyValue(Map.of("code", 400, "message", "无效的文件格式"));
|
||||||
|
}
|
||||||
|
return brandConfigService.uploadLogo(tenantId, filePart)
|
||||||
|
.flatMap(config -> ServerResponse.ok().bodyValue(config));
|
||||||
|
})
|
||||||
|
.switchIfEmpty(ServerResponse.badRequest()
|
||||||
|
.bodyValue(Map.of("code", 400, "message", "请求数据为空")))
|
||||||
|
.onErrorResume(IllegalArgumentException.class, ex ->
|
||||||
|
ServerResponse.badRequest().bodyValue(Map.of(
|
||||||
|
"code", 400,
|
||||||
|
"message", ex.getMessage()
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "上传背景图", description = "上传品牌背景图,支持PNG/JPG格式,限制2MB以内")
|
||||||
|
public Mono<ServerResponse> uploadBackgroundImage(ServerRequest request) {
|
||||||
|
String tenantId = authUtil.getTenantId(request);
|
||||||
|
|
||||||
|
return request.multipartData()
|
||||||
|
.flatMap(multipartData -> {
|
||||||
|
var part = multipartData.getFirst("file");
|
||||||
|
if (part == null) {
|
||||||
|
return ServerResponse.badRequest()
|
||||||
|
.bodyValue(Map.of("code", 400, "message", "未上传文件"));
|
||||||
|
}
|
||||||
|
if (!(part instanceof FilePart filePart)) {
|
||||||
|
return ServerResponse.badRequest()
|
||||||
|
.bodyValue(Map.of("code", 400, "message", "无效的文件格式"));
|
||||||
|
}
|
||||||
|
return brandConfigService.uploadBackgroundImage(tenantId, filePart)
|
||||||
|
.flatMap(config -> ServerResponse.ok().bodyValue(config));
|
||||||
|
})
|
||||||
|
.switchIfEmpty(ServerResponse.badRequest()
|
||||||
|
.bodyValue(Map.of("code", 400, "message", "请求数据为空")))
|
||||||
|
.onErrorResume(IllegalArgumentException.class, ex ->
|
||||||
|
ServerResponse.badRequest().bodyValue(Map.of(
|
||||||
|
"code", 400,
|
||||||
|
"message", ex.getMessage()
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "更新品牌配色", description = "设置品牌主色调、辅助色等配色方案")
|
||||||
|
public Mono<ServerResponse> updateColorConfig(ServerRequest request) {
|
||||||
|
String tenantId = authUtil.getTenantId(request);
|
||||||
|
|
||||||
|
return request.bodyToMono(Map.class)
|
||||||
|
.map(this::mapToBrandConfig)
|
||||||
|
.flatMap(config -> brandConfigService.updateColorConfig(tenantId, config))
|
||||||
|
.flatMap(config -> ServerResponse.ok().bodyValue(config))
|
||||||
|
.onErrorResume(IllegalArgumentException.class, ex ->
|
||||||
|
ServerResponse.badRequest().bodyValue(Map.of(
|
||||||
|
"code", 400,
|
||||||
|
"message", ex.getMessage(),
|
||||||
|
"timestamp", LocalDateTime.now()
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "删除Logo", description = "删除品牌Logo,恢复默认")
|
||||||
|
public Mono<ServerResponse> removeLogo(ServerRequest request) {
|
||||||
|
String tenantId = authUtil.getTenantId(request);
|
||||||
|
return brandConfigService.removeLogo(tenantId)
|
||||||
|
.flatMap(config -> ServerResponse.ok().bodyValue(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "删除背景图", description = "删除品牌背景图,恢复默认")
|
||||||
|
public Mono<ServerResponse> removeBackgroundImage(ServerRequest request) {
|
||||||
|
String tenantId = authUtil.getTenantId(request);
|
||||||
|
return brandConfigService.removeBackgroundImage(tenantId)
|
||||||
|
.flatMap(config -> ServerResponse.ok().bodyValue(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "更新品牌信息", description = "设置品牌名称和口号")
|
||||||
|
public Mono<ServerResponse> updateBrandInfo(ServerRequest request) {
|
||||||
|
String tenantId = authUtil.getTenantId(request);
|
||||||
|
|
||||||
|
return request.bodyToMono(Map.class)
|
||||||
|
.map(body -> {
|
||||||
|
BrandConfig config = new BrandConfig();
|
||||||
|
if (body.containsKey("brandName")) {
|
||||||
|
String name = (String) body.get("brandName");
|
||||||
|
if (name.length() > 100) {
|
||||||
|
throw new IllegalArgumentException("品牌名称不能超过100个字符");
|
||||||
|
}
|
||||||
|
config.setBrandName(name);
|
||||||
|
}
|
||||||
|
if (body.containsKey("slogan")) {
|
||||||
|
String slogan = (String) body.get("slogan");
|
||||||
|
if (slogan.length() > 200) {
|
||||||
|
throw new IllegalArgumentException("口号不能超过200个字符");
|
||||||
|
}
|
||||||
|
config.setSlogan(slogan);
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
})
|
||||||
|
.flatMap(config -> brandConfigService.updateBrandInfo(tenantId, config))
|
||||||
|
.flatMap(config -> ServerResponse.ok().bodyValue(config))
|
||||||
|
.onErrorResume(IllegalArgumentException.class, ex ->
|
||||||
|
ServerResponse.badRequest().bodyValue(Map.of(
|
||||||
|
"code", 400,
|
||||||
|
"message", ex.getMessage()
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将前端传来的 Map 转换为 BrandConfig(仅用于颜色配置)
|
||||||
|
*/
|
||||||
|
private BrandConfig mapToBrandConfig(Map<String, Object> body) {
|
||||||
|
BrandConfig config = new BrandConfig();
|
||||||
|
if (body.containsKey("primaryColor")) {
|
||||||
|
String color = (String) body.get("primaryColor");
|
||||||
|
validateHexColor(color);
|
||||||
|
config.setPrimaryColor(color);
|
||||||
|
}
|
||||||
|
if (body.containsKey("primaryColorRgb")) {
|
||||||
|
config.setPrimaryColorRgb((String) body.get("primaryColorRgb"));
|
||||||
|
}
|
||||||
|
if (body.containsKey("secondaryColor")) {
|
||||||
|
String color = (String) body.get("secondaryColor");
|
||||||
|
validateHexColor(color);
|
||||||
|
config.setSecondaryColor(color);
|
||||||
|
}
|
||||||
|
if (body.containsKey("secondaryColorRgb")) {
|
||||||
|
config.setSecondaryColorRgb((String) body.get("secondaryColorRgb"));
|
||||||
|
}
|
||||||
|
if (body.containsKey("fontFamily")) {
|
||||||
|
config.setFontFamily((String) body.get("fontFamily"));
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证HEX颜色格式
|
||||||
|
*/
|
||||||
|
private void validateHexColor(String color) {
|
||||||
|
if (color == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!color.matches("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$")) {
|
||||||
|
throw new IllegalArgumentException("无效的HEX颜色格式: " + color + ",正确格式如 #1E90FF");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+207
@@ -0,0 +1,207 @@
|
|||||||
|
package cn.novalon.gym.manage.brand.websocket;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
|
||||||
|
import cn.novalon.gym.manage.sys.security.JwtTokenProvider;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.reactive.socket.WebSocketHandler;
|
||||||
|
import org.springframework.web.reactive.socket.WebSocketSession;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 品牌配置实时预览 WebSocket 处理器
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-23
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class BrandWebSocketHandler implements WebSocketHandler {
|
||||||
|
|
||||||
|
private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, LocalDateTime> lastActivityTime = new ConcurrentHashMap<>();
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
private final JwtTokenProvider jwtTokenProvider;
|
||||||
|
|
||||||
|
@Value("${websocket.idle-timeout:300s}")
|
||||||
|
private Duration idleTimeout;
|
||||||
|
|
||||||
|
@Value("${websocket.heartbeat-interval:30s}")
|
||||||
|
private Duration heartbeatInterval;
|
||||||
|
|
||||||
|
public BrandWebSocketHandler(JwtTokenProvider jwtTokenProvider) {
|
||||||
|
this.jwtTokenProvider = jwtTokenProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> handle(WebSocketSession session) {
|
||||||
|
String tenantId = extractTenantId(session);
|
||||||
|
sessions.put(tenantId, session);
|
||||||
|
lastActivityTime.put(tenantId, LocalDateTime.now());
|
||||||
|
|
||||||
|
return session.receive()
|
||||||
|
.doOnNext(message -> {
|
||||||
|
String payload = message.getPayloadAsText();
|
||||||
|
handleIncomingMessage(session, tenantId, payload);
|
||||||
|
lastActivityTime.put(tenantId, LocalDateTime.now());
|
||||||
|
})
|
||||||
|
.doOnComplete(() -> {
|
||||||
|
sessions.remove(tenantId);
|
||||||
|
lastActivityTime.remove(tenantId);
|
||||||
|
})
|
||||||
|
.doOnError(error -> {
|
||||||
|
sessions.remove(tenantId);
|
||||||
|
lastActivityTime.remove(tenantId);
|
||||||
|
})
|
||||||
|
.then();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedRate = 60000)
|
||||||
|
public void cleanupIdleConnections() {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
lastActivityTime.entrySet().removeIf(entry -> {
|
||||||
|
if (Duration.between(entry.getValue(), now).compareTo(idleTimeout) > 0) {
|
||||||
|
String tenantId = entry.getKey();
|
||||||
|
WebSocketSession session = sessions.remove(tenantId);
|
||||||
|
if (session != null && session.isOpen()) {
|
||||||
|
session.close().subscribe();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedRate = 30000)
|
||||||
|
public void sendHeartbeat() {
|
||||||
|
sessions.forEach((tenantId, session) -> {
|
||||||
|
if (session.isOpen()) {
|
||||||
|
try {
|
||||||
|
String heartbeat = objectMapper.writeValueAsString(Map.of(
|
||||||
|
"type", "heartbeat",
|
||||||
|
"timestamp", System.currentTimeMillis()
|
||||||
|
));
|
||||||
|
session.send(Mono.just(session.textMessage(heartbeat))).subscribe();
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("Brand WS heartbeat error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向指定租户推送品牌配置更新
|
||||||
|
*/
|
||||||
|
public void sendBrandUpdate(String tenantId, BrandConfig config) {
|
||||||
|
WebSocketSession session = sessions.get(tenantId);
|
||||||
|
if (session != null && session.isOpen()) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> message = Map.of(
|
||||||
|
"type", "brandUpdate",
|
||||||
|
"data", config,
|
||||||
|
"timestamp", System.currentTimeMillis()
|
||||||
|
);
|
||||||
|
String json = objectMapper.writeValueAsString(message);
|
||||||
|
session.send(Mono.just(session.textMessage(json))).subscribe();
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("Brand WS send error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 广播品牌配置更新给所有连接的租户
|
||||||
|
*/
|
||||||
|
public void broadcastBrandUpdate(BrandConfig config) {
|
||||||
|
// 仅推送给对应租户
|
||||||
|
if (config.getTenantId() != null) {
|
||||||
|
sendBrandUpdate(config.getTenantId(), config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 WebSocket 握手中提取租户ID
|
||||||
|
* <ol>
|
||||||
|
* <li>优先从 Authorization Header 的 JWT Token 中提取</li>
|
||||||
|
* <li>回退到 URL 查询参数 tenantId</li>
|
||||||
|
* <li>兜底使用 session ID</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
private String extractTenantId(WebSocketSession session) {
|
||||||
|
// 1. 优先从 JWT Token 提取
|
||||||
|
String tenantId = extractTenantIdFromJwt(session);
|
||||||
|
if (tenantId != null) {
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 回退到查询参数(兼容旧版)
|
||||||
|
String query = session.getHandshakeInfo().getUri().getQuery();
|
||||||
|
if (query != null && query.contains("tenantId=")) {
|
||||||
|
return query.split("tenantId=")[1].split("&")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 兜底
|
||||||
|
return session.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 WebSocket 握手的 Authorization Header 中提取 JWT Token 的 tenantId
|
||||||
|
*/
|
||||||
|
private String extractTenantIdFromJwt(WebSocketSession session) {
|
||||||
|
try {
|
||||||
|
var headers = session.getHandshakeInfo().getHeaders();
|
||||||
|
String authHeader = headers.getFirst(HttpHeaders.AUTHORIZATION);
|
||||||
|
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||||
|
String token = authHeader.substring(7);
|
||||||
|
if (jwtTokenProvider.validateToken(token)) {
|
||||||
|
return jwtTokenProvider.getTenantIdFromToken(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("Brand WS: Failed to extract tenantId from JWT: " + e.getMessage());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleIncomingMessage(WebSocketSession session, String tenantId, String payload) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> message = objectMapper.readValue(payload,
|
||||||
|
new TypeReference<Map<String, Object>>() {});
|
||||||
|
String type = (String) message.get("type");
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "ping":
|
||||||
|
sendPong(session);
|
||||||
|
break;
|
||||||
|
case "subscribe":
|
||||||
|
sessions.put(tenantId, session);
|
||||||
|
lastActivityTime.put(tenantId, LocalDateTime.now());
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("Brand WS message error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendPong(WebSocketSession session) {
|
||||||
|
try {
|
||||||
|
String pong = objectMapper.writeValueAsString(Map.of(
|
||||||
|
"type", "pong",
|
||||||
|
"timestamp", System.currentTimeMillis()
|
||||||
|
));
|
||||||
|
session.send(Mono.just(session.textMessage(pong))).subscribe();
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("Brand WS pong error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
cn.novalon.gym.manage.brand.config.BrandWebSocketConfig
|
||||||
+296
@@ -0,0 +1,296 @@
|
|||||||
|
package cn.novalon.gym.manage.brand.core.service.impl;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
|
||||||
|
import cn.novalon.gym.manage.brand.core.repository.IBrandConfigRepository;
|
||||||
|
import cn.novalon.gym.manage.brand.core.service.FileStorageService;
|
||||||
|
import cn.novalon.gym.manage.brand.websocket.BrandWebSocketHandler;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.codec.multipart.FilePart;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.test.StepVerifier;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class BrandConfigServiceImplTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private IBrandConfigRepository brandConfigRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private FileStorageService fileStorageService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private BrandWebSocketHandler brandWebSocketHandler;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private FilePart filePart;
|
||||||
|
|
||||||
|
private BrandConfigServiceImpl brandConfigService;
|
||||||
|
|
||||||
|
private static final String TENANT_ID = "tenant-001";
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
brandConfigService = new BrandConfigServiceImpl(
|
||||||
|
brandConfigRepository, fileStorageService, brandWebSocketHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getBrandConfig ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getBrandConfig_shouldReturnExistingConfig() {
|
||||||
|
BrandConfig existingConfig = createTestConfig();
|
||||||
|
when(brandConfigRepository.findByTenantId(TENANT_ID)).thenReturn(Mono.just(existingConfig));
|
||||||
|
|
||||||
|
Mono<BrandConfig> result = brandConfigService.getBrandConfig(TENANT_ID);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(config -> {
|
||||||
|
assertThat(config).isNotNull();
|
||||||
|
assertThat(config.getTenantId()).isEqualTo(TENANT_ID);
|
||||||
|
assertThat(config.getPrimaryColor()).isEqualTo("#00E676");
|
||||||
|
})
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(brandConfigRepository).findByTenantId(TENANT_ID);
|
||||||
|
verify(brandConfigRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getBrandConfig_shouldCreateDefaultWhenNotFound() {
|
||||||
|
when(brandConfigRepository.findByTenantId(TENANT_ID)).thenReturn(Mono.empty());
|
||||||
|
when(brandConfigRepository.save(any(BrandConfig.class)))
|
||||||
|
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
|
||||||
|
|
||||||
|
Mono<BrandConfig> result = brandConfigService.getBrandConfig(TENANT_ID);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(config -> {
|
||||||
|
assertThat(config.getTenantId()).isEqualTo(TENANT_ID);
|
||||||
|
assertThat(config.getPrimaryColor()).isEqualTo("#00E676");
|
||||||
|
assertThat(config.getPrimaryColorRgb()).isEqualTo("0,230,118");
|
||||||
|
assertThat(config.getSecondaryColor()).isEqualTo("#1A1A1A");
|
||||||
|
assertThat(config.getLogoUrl()).isNull();
|
||||||
|
})
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(brandConfigRepository).findByTenantId(TENANT_ID);
|
||||||
|
verify(brandConfigRepository).save(any(BrandConfig.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== uploadLogo ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadLogo_shouldUploadAndSaveConfig() {
|
||||||
|
BrandConfig existingConfig = createTestConfig();
|
||||||
|
String newLogoUrl = "https://example.com/new-logo.png";
|
||||||
|
|
||||||
|
lenient().when(brandConfigRepository.findByTenantId(TENANT_ID))
|
||||||
|
.thenReturn(Mono.just(existingConfig));
|
||||||
|
when(fileStorageService.uploadImage(filePart, "logo")).thenReturn(Mono.just(newLogoUrl));
|
||||||
|
when(fileStorageService.deleteFile("https://example.com/logo.png")).thenReturn(Mono.empty());
|
||||||
|
when(brandConfigRepository.save(any(BrandConfig.class)))
|
||||||
|
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
|
||||||
|
|
||||||
|
Mono<BrandConfig> result = brandConfigService.uploadLogo(TENANT_ID, filePart);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(config -> {
|
||||||
|
assertThat(config.getLogoUrl()).isEqualTo(newLogoUrl);
|
||||||
|
})
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(fileStorageService).uploadImage(filePart, "logo");
|
||||||
|
verify(brandConfigRepository, atLeastOnce()).save(any(BrandConfig.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadLogo_shouldCreateConfigWhenTenantNotFound() {
|
||||||
|
String newLogoUrl = "https://example.com/new-logo.png";
|
||||||
|
|
||||||
|
lenient().when(brandConfigRepository.findByTenantId(TENANT_ID)).thenReturn(Mono.empty());
|
||||||
|
when(fileStorageService.uploadImage(filePart, "logo")).thenReturn(Mono.just(newLogoUrl));
|
||||||
|
lenient().when(fileStorageService.deleteFile(any())).thenReturn(Mono.empty());
|
||||||
|
when(brandConfigRepository.save(any(BrandConfig.class)))
|
||||||
|
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
|
||||||
|
|
||||||
|
Mono<BrandConfig> result = brandConfigService.uploadLogo(TENANT_ID, filePart);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(config -> {
|
||||||
|
assertThat(config.getLogoUrl()).isEqualTo(newLogoUrl);
|
||||||
|
assertThat(config.getTenantId()).isEqualTo(TENANT_ID);
|
||||||
|
})
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadLogo_shouldPropagateStorageError() {
|
||||||
|
BrandConfig existingConfig = createTestConfig();
|
||||||
|
|
||||||
|
lenient().when(brandConfigRepository.findByTenantId(TENANT_ID))
|
||||||
|
.thenReturn(Mono.just(existingConfig));
|
||||||
|
when(fileStorageService.uploadImage(filePart, "logo"))
|
||||||
|
.thenReturn(Mono.error(new RuntimeException("Storage error")));
|
||||||
|
|
||||||
|
Mono<BrandConfig> result = brandConfigService.uploadLogo(TENANT_ID, filePart);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectError(RuntimeException.class)
|
||||||
|
.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== uploadBackgroundImage ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadBackgroundImage_shouldUploadAndSaveConfig() {
|
||||||
|
BrandConfig existingConfig = createTestConfig();
|
||||||
|
String newBgUrl = "https://example.com/new-bg.png";
|
||||||
|
|
||||||
|
lenient().when(brandConfigRepository.findByTenantId(TENANT_ID))
|
||||||
|
.thenReturn(Mono.just(existingConfig));
|
||||||
|
when(fileStorageService.uploadImage(filePart, "background")).thenReturn(Mono.just(newBgUrl));
|
||||||
|
lenient().when(fileStorageService.deleteFile(anyString())).thenReturn(Mono.empty());
|
||||||
|
when(brandConfigRepository.save(any(BrandConfig.class)))
|
||||||
|
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
|
||||||
|
|
||||||
|
Mono<BrandConfig> result = brandConfigService.uploadBackgroundImage(TENANT_ID, filePart);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(config -> {
|
||||||
|
assertThat(config.getBackgroundImageUrl()).isEqualTo(newBgUrl);
|
||||||
|
})
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(fileStorageService).uploadImage(filePart, "background");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== updateColorConfig ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateColorConfig_shouldUpdateAllColorFields() {
|
||||||
|
BrandConfig existingConfig = createTestConfig();
|
||||||
|
|
||||||
|
BrandConfig updateConfig = new BrandConfig();
|
||||||
|
updateConfig.setPrimaryColor("#FF0000");
|
||||||
|
updateConfig.setPrimaryColorRgb("255,0,0");
|
||||||
|
updateConfig.setSecondaryColor("#00FF00");
|
||||||
|
updateConfig.setSecondaryColorRgb("0,255,0");
|
||||||
|
updateConfig.setFontFamily("Arial");
|
||||||
|
|
||||||
|
lenient().when(brandConfigRepository.findByTenantId(TENANT_ID))
|
||||||
|
.thenReturn(Mono.just(existingConfig));
|
||||||
|
when(brandConfigRepository.save(any(BrandConfig.class)))
|
||||||
|
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
|
||||||
|
|
||||||
|
Mono<BrandConfig> result = brandConfigService.updateColorConfig(TENANT_ID, updateConfig);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(config -> {
|
||||||
|
assertThat(config.getPrimaryColor()).isEqualTo("#FF0000");
|
||||||
|
assertThat(config.getPrimaryColorRgb()).isEqualTo("255,0,0");
|
||||||
|
assertThat(config.getSecondaryColor()).isEqualTo("#00FF00");
|
||||||
|
assertThat(config.getSecondaryColorRgb()).isEqualTo("0,255,0");
|
||||||
|
assertThat(config.getFontFamily()).isEqualTo("Arial");
|
||||||
|
})
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateColorConfig_shouldOnlyUpdateProvidedFields() {
|
||||||
|
BrandConfig existingConfig = createTestConfig();
|
||||||
|
|
||||||
|
BrandConfig updateConfig = new BrandConfig();
|
||||||
|
updateConfig.setPrimaryColor("#FF0000");
|
||||||
|
|
||||||
|
lenient().when(brandConfigRepository.findByTenantId(TENANT_ID))
|
||||||
|
.thenReturn(Mono.just(existingConfig));
|
||||||
|
when(brandConfigRepository.save(any(BrandConfig.class)))
|
||||||
|
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
|
||||||
|
|
||||||
|
Mono<BrandConfig> result = brandConfigService.updateColorConfig(TENANT_ID, updateConfig);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(config -> {
|
||||||
|
assertThat(config.getPrimaryColor()).isEqualTo("#FF0000");
|
||||||
|
assertThat(config.getSecondaryColor()).isEqualTo("#1A1A1A"); // unchanged
|
||||||
|
assertThat(config.getLogoUrl()).isEqualTo("https://example.com/logo.png"); // unchanged
|
||||||
|
})
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== removeLogo ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removeLogo_shouldClearLogoUrl() {
|
||||||
|
BrandConfig existingConfig = createTestConfig();
|
||||||
|
|
||||||
|
lenient().when(brandConfigRepository.findByTenantId(TENANT_ID))
|
||||||
|
.thenReturn(Mono.just(existingConfig));
|
||||||
|
when(fileStorageService.deleteFile("https://example.com/logo.png")).thenReturn(Mono.empty());
|
||||||
|
when(brandConfigRepository.save(any(BrandConfig.class)))
|
||||||
|
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
|
||||||
|
|
||||||
|
Mono<BrandConfig> result = brandConfigService.removeLogo(TENANT_ID);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(config -> {
|
||||||
|
assertThat(config.getLogoUrl()).isNull();
|
||||||
|
})
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(fileStorageService).deleteFile("https://example.com/logo.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== removeBackgroundImage ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removeBackgroundImage_shouldClearBackgroundImageUrl() {
|
||||||
|
BrandConfig existingConfig = createTestConfig();
|
||||||
|
|
||||||
|
lenient().when(brandConfigRepository.findByTenantId(TENANT_ID))
|
||||||
|
.thenReturn(Mono.just(existingConfig));
|
||||||
|
when(fileStorageService.deleteFile("https://example.com/bg.png")).thenReturn(Mono.empty());
|
||||||
|
when(brandConfigRepository.save(any(BrandConfig.class)))
|
||||||
|
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
|
||||||
|
|
||||||
|
Mono<BrandConfig> result = brandConfigService.removeBackgroundImage(TENANT_ID);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(config -> {
|
||||||
|
assertThat(config.getBackgroundImageUrl()).isNull();
|
||||||
|
})
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(fileStorageService).deleteFile("https://example.com/bg.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== helper ====================
|
||||||
|
|
||||||
|
private BrandConfig createTestConfig() {
|
||||||
|
BrandConfig config = new BrandConfig();
|
||||||
|
config.setId(1L);
|
||||||
|
config.setTenantId(TENANT_ID);
|
||||||
|
config.setLogoUrl("https://example.com/logo.png");
|
||||||
|
config.setBackgroundImageUrl("https://example.com/bg.png");
|
||||||
|
config.setPrimaryColor("#00E676");
|
||||||
|
config.setPrimaryColorRgb("0,230,118");
|
||||||
|
config.setSecondaryColor("#1A1A1A");
|
||||||
|
config.setSecondaryColorRgb("26,26,26");
|
||||||
|
config.setFontFamily("default");
|
||||||
|
config.setCreatedAt(LocalDateTime.now());
|
||||||
|
config.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
package cn.novalon.gym.manage.brand.core.service.impl;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.brand.config.OssProperties;
|
||||||
|
import cn.novalon.gym.manage.brand.core.service.FileStorageService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.codec.multipart.FilePart;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.test.StepVerifier;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class DualFileStorageServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private FileStorageService ossStorage;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private FileStorageService localStorage;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private FilePart filePart;
|
||||||
|
|
||||||
|
private OssProperties ossProperties;
|
||||||
|
private DualFileStorageService dualService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
ossProperties = new OssProperties();
|
||||||
|
// 默认不启用 OSS
|
||||||
|
ossProperties.setEnabled(false);
|
||||||
|
dualService = new DualFileStorageService(ossStorage, localStorage, ossProperties);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== OSS 未启用时,直接走本地存储 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldUseLocalStorageWhenOssDisabled() {
|
||||||
|
String localUrl = "http://localhost:8080/api/files/preview/brand/logo/logo_test.png";
|
||||||
|
when(localStorage.uploadImage(filePart, "logo")).thenReturn(Mono.just(localUrl));
|
||||||
|
|
||||||
|
Mono<String> result = dualService.uploadImage(filePart, "logo");
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(url -> assertThat(url).isEqualTo(localUrl))
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(localStorage).uploadImage(filePart, "logo");
|
||||||
|
verify(ossStorage, never()).uploadImage(any(), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldDeleteFromLocalOnlyWhenOssDisabled() {
|
||||||
|
String fileUrl = "http://localhost/api/files/preview/brand/logo/test.png";
|
||||||
|
when(localStorage.deleteFile(fileUrl)).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
Mono<Void> result = dualService.deleteFile(fileUrl);
|
||||||
|
|
||||||
|
StepVerifier.create(result).verifyComplete();
|
||||||
|
|
||||||
|
verify(localStorage).deleteFile(fileUrl);
|
||||||
|
verify(ossStorage, never()).deleteFile(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== OSS 启用时,优先 OSS ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldUseOssWhenEnabledAndConfigured() {
|
||||||
|
configureOss();
|
||||||
|
String ossUrl = "https://my-bucket.oss-cn-hangzhou.aliyuncs.com/brand/logo/logo_test.png";
|
||||||
|
when(ossStorage.uploadImage(filePart, "logo")).thenReturn(Mono.just(ossUrl));
|
||||||
|
|
||||||
|
Mono<String> result = dualService.uploadImage(filePart, "logo");
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(url -> assertThat(url).isEqualTo(ossUrl))
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(ossStorage).uploadImage(filePart, "logo");
|
||||||
|
verify(localStorage, never()).uploadImage(any(), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldFallbackToLocalWhenOssFails() {
|
||||||
|
configureOss();
|
||||||
|
String localUrl = "http://localhost:8080/api/files/preview/brand/logo/logo_fallback.png";
|
||||||
|
when(ossStorage.uploadImage(filePart, "logo"))
|
||||||
|
.thenReturn(Mono.error(new RuntimeException("OSS unavailable")));
|
||||||
|
when(localStorage.uploadImage(filePart, "logo")).thenReturn(Mono.just(localUrl));
|
||||||
|
|
||||||
|
Mono<String> result = dualService.uploadImage(filePart, "logo");
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(url -> assertThat(url).isEqualTo(localUrl))
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(ossStorage).uploadImage(filePart, "logo");
|
||||||
|
verify(localStorage).uploadImage(filePart, "logo");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldDeleteFromBothWhenOssEnabled() {
|
||||||
|
configureOss();
|
||||||
|
String fileUrl = "https://my-bucket.oss-cn-hangzhou.aliyuncs.com/brand/logo/test.png";
|
||||||
|
when(ossStorage.deleteFile(fileUrl)).thenReturn(Mono.empty());
|
||||||
|
when(localStorage.deleteFile(fileUrl)).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
Mono<Void> result = dualService.deleteFile(fileUrl);
|
||||||
|
|
||||||
|
StepVerifier.create(result).verifyComplete();
|
||||||
|
|
||||||
|
verify(ossStorage).deleteFile(fileUrl);
|
||||||
|
verify(localStorage).deleteFile(fileUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldDeleteLocalEvenWhenOssDeleteFails() {
|
||||||
|
configureOss();
|
||||||
|
String fileUrl = "https://my-bucket.oss-cn-hangzhou.aliyuncs.com/brand/logo/test.png";
|
||||||
|
when(ossStorage.deleteFile(fileUrl))
|
||||||
|
.thenReturn(Mono.error(new RuntimeException("OSS delete failed")));
|
||||||
|
when(localStorage.deleteFile(fileUrl)).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
Mono<Void> result = dualService.deleteFile(fileUrl);
|
||||||
|
|
||||||
|
StepVerifier.create(result).verifyComplete();
|
||||||
|
|
||||||
|
verify(ossStorage).deleteFile(fileUrl);
|
||||||
|
verify(localStorage).deleteFile(fileUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 边界情况 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteFile_shouldHandleNullUrl() {
|
||||||
|
Mono<Void> result = dualService.deleteFile(null);
|
||||||
|
|
||||||
|
StepVerifier.create(result).verifyComplete();
|
||||||
|
|
||||||
|
verify(localStorage, never()).deleteFile(any());
|
||||||
|
verify(ossStorage, never()).deleteFile(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteFile_shouldHandleEmptyUrl() {
|
||||||
|
Mono<Void> result = dualService.deleteFile("");
|
||||||
|
|
||||||
|
StepVerifier.create(result).verifyComplete();
|
||||||
|
|
||||||
|
verify(localStorage, never()).deleteFile(any());
|
||||||
|
verify(ossStorage, never()).deleteFile(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== helper ====================
|
||||||
|
|
||||||
|
private void configureOss() {
|
||||||
|
ossProperties.setEnabled(true);
|
||||||
|
ossProperties.setEndpoint("oss-cn-hangzhou.aliyuncs.com");
|
||||||
|
ossProperties.setAccessKeyId("test-access-key");
|
||||||
|
ossProperties.setAccessKeySecret("test-access-secret");
|
||||||
|
ossProperties.setBucketName("test-bucket");
|
||||||
|
}
|
||||||
|
}
|
||||||
+182
@@ -0,0 +1,182 @@
|
|||||||
|
package cn.novalon.gym.manage.brand.core.service.impl;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.brand.core.service.FileStorageService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.codec.multipart.FilePart;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.test.StepVerifier;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class LocalFileStorageServiceTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private FilePart filePart;
|
||||||
|
|
||||||
|
private FileStorageService localStorageService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
String uploadDir = tempDir.toString();
|
||||||
|
String baseUrl = "http://localhost:8080/api/files";
|
||||||
|
localStorageService = new LocalFileStorageService(uploadDir, baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadImage_shouldAcceptAndSaveValidFiles() {
|
||||||
|
when(filePart.filename()).thenReturn("test-logo.png");
|
||||||
|
when(filePart.transferTo(any(File.class))).thenAnswer(invocation -> {
|
||||||
|
File targetFile = invocation.getArgument(0);
|
||||||
|
targetFile.createNewFile();
|
||||||
|
return Mono.empty();
|
||||||
|
});
|
||||||
|
|
||||||
|
Mono<String> result = localStorageService.uploadImage(filePart, "logo");
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(url -> {
|
||||||
|
assertThat(url).contains("brand/logo/logo_");
|
||||||
|
assertThat(url).endsWith(".png");
|
||||||
|
})
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadImage_shouldRejectInvalidFormat() {
|
||||||
|
when(filePart.filename()).thenReturn("document.pdf");
|
||||||
|
|
||||||
|
Mono<String> result = localStorageService.uploadImage(filePart, "logo");
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectError(IllegalArgumentException.class)
|
||||||
|
.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadImage_shouldRejectTxtFormat() {
|
||||||
|
when(filePart.filename()).thenReturn("script.txt");
|
||||||
|
|
||||||
|
Mono<String> result = localStorageService.uploadImage(filePart, "logo");
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectError(IllegalArgumentException.class)
|
||||||
|
.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadImage_shouldRejectBatchFormat() {
|
||||||
|
when(filePart.filename()).thenReturn("file.bat");
|
||||||
|
|
||||||
|
Mono<String> result = localStorageService.uploadImage(filePart, "logo");
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectError(IllegalArgumentException.class)
|
||||||
|
.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadImage_shouldHandleNoExtensionAsPng() {
|
||||||
|
when(filePart.filename()).thenReturn("noextension");
|
||||||
|
when(filePart.transferTo(any(File.class))).thenAnswer(invocation -> {
|
||||||
|
File targetFile = invocation.getArgument(0);
|
||||||
|
targetFile.createNewFile();
|
||||||
|
return Mono.empty();
|
||||||
|
});
|
||||||
|
|
||||||
|
Mono<String> result = localStorageService.uploadImage(filePart, "logo");
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(url -> {
|
||||||
|
assertThat(url).endsWith(".png");
|
||||||
|
})
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadImage_shouldGenerateCorrectUrlFormat() {
|
||||||
|
when(filePart.filename()).thenReturn("company-logo.png");
|
||||||
|
when(filePart.transferTo(any(File.class))).thenAnswer(invocation -> {
|
||||||
|
File targetFile = invocation.getArgument(0);
|
||||||
|
targetFile.createNewFile();
|
||||||
|
return Mono.empty();
|
||||||
|
});
|
||||||
|
|
||||||
|
Mono<String> result = localStorageService.uploadImage(filePart, "logo");
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(url -> {
|
||||||
|
assertThat(url).startsWith("http://localhost:8080/api/files/preview/brand/logo/");
|
||||||
|
})
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadImage_shouldUseCorrectDirectory() {
|
||||||
|
when(filePart.filename()).thenReturn("bg.png");
|
||||||
|
when(filePart.transferTo(any(File.class))).thenAnswer(invocation -> {
|
||||||
|
File targetFile = invocation.getArgument(0);
|
||||||
|
targetFile.createNewFile();
|
||||||
|
return Mono.empty();
|
||||||
|
});
|
||||||
|
|
||||||
|
Mono<String> result = localStorageService.uploadImage(filePart, "background");
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(url -> {
|
||||||
|
assertThat(url).contains("brand/background/background_");
|
||||||
|
})
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadImage_shouldHandleTransferError() {
|
||||||
|
when(filePart.filename()).thenReturn("logo.png");
|
||||||
|
when(filePart.transferTo(any(File.class)))
|
||||||
|
.thenReturn(Mono.error(new RuntimeException("Transfer failed")));
|
||||||
|
|
||||||
|
Mono<String> result = localStorageService.uploadImage(filePart, "logo");
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectError(RuntimeException.class)
|
||||||
|
.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteFile_shouldNotThrowForNullUrl() {
|
||||||
|
Mono<Void> result = localStorageService.deleteFile(null);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteFile_shouldNotThrowForEmptyUrl() {
|
||||||
|
Mono<Void> result = localStorageService.deleteFile("");
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteFile_shouldNotThrowForNonExistentFile() {
|
||||||
|
Mono<Void> result = localStorageService.deleteFile(
|
||||||
|
"http://localhost:8080/api/files/preview/brand/logo/nonexistent.png");
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
+209
@@ -0,0 +1,209 @@
|
|||||||
|
package cn.novalon.gym.manage.brand.handler;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
|
||||||
|
import cn.novalon.gym.manage.brand.core.service.IBrandConfigService;
|
||||||
|
import cn.novalon.gym.manage.sys.util.AuthUtil;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.mock.web.reactive.function.server.MockServerRequest;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.test.StepVerifier;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class BrandConfigHandlerTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private IBrandConfigService brandConfigService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private AuthUtil authUtil;
|
||||||
|
|
||||||
|
private BrandConfigHandler brandConfigHandler;
|
||||||
|
|
||||||
|
private static final String TENANT_ID = "tenant-001";
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
brandConfigHandler = new BrandConfigHandler(brandConfigService, authUtil);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MockServerRequest.Builder mockRequest() {
|
||||||
|
return MockServerRequest.builder()
|
||||||
|
.header("X-Tenant-Id", "tenant-001");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getBrandConfig ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getBrandConfig_shouldReturnConfig() {
|
||||||
|
BrandConfig config = createTestConfig();
|
||||||
|
when(authUtil.getTenantId(any())).thenReturn(TENANT_ID);
|
||||||
|
when(brandConfigService.getBrandConfig(TENANT_ID)).thenReturn(Mono.just(config));
|
||||||
|
|
||||||
|
MockServerRequest request = mockRequest().build();
|
||||||
|
Mono<ServerResponse> result = brandConfigHandler.getBrandConfig(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(brandConfigService).getBrandConfig(TENANT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getBrandConfig_shouldReturnOkEvenWhenNotFound() {
|
||||||
|
when(authUtil.getTenantId(any())).thenReturn(TENANT_ID);
|
||||||
|
when(brandConfigService.getBrandConfig(TENANT_ID)).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
MockServerRequest request = mockRequest().build();
|
||||||
|
Mono<ServerResponse> result = brandConfigHandler.getBrandConfig(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== updateColorConfig ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateColorConfig_shouldUpdateAndReturnOk() {
|
||||||
|
BrandConfig config = createTestConfig();
|
||||||
|
config.setPrimaryColor("#FF0000");
|
||||||
|
Map<String, Object> requestBody = Map.of("primaryColor", "#FF0000");
|
||||||
|
|
||||||
|
when(authUtil.getTenantId(any())).thenReturn(TENANT_ID);
|
||||||
|
when(brandConfigService.updateColorConfig(eq(TENANT_ID), any(BrandConfig.class)))
|
||||||
|
.thenReturn(Mono.just(config));
|
||||||
|
|
||||||
|
MockServerRequest request = mockRequest()
|
||||||
|
.body(Mono.just(requestBody));
|
||||||
|
Mono<ServerResponse> result = brandConfigHandler.updateColorConfig(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateColorConfig_shouldRejectInvalidHex() {
|
||||||
|
Map<String, Object> requestBody = Map.of("primaryColor", "INVALID");
|
||||||
|
|
||||||
|
when(authUtil.getTenantId(any())).thenReturn(TENANT_ID);
|
||||||
|
|
||||||
|
MockServerRequest request = mockRequest()
|
||||||
|
.body(Mono.just(requestBody));
|
||||||
|
Mono<ServerResponse> result = brandConfigHandler.updateColorConfig(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST))
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(brandConfigService, never()).updateColorConfig(anyString(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateColorConfig_shouldAcceptShortHexFormat() {
|
||||||
|
BrandConfig config = createTestConfig();
|
||||||
|
config.setPrimaryColor("#F00");
|
||||||
|
Map<String, Object> requestBody = Map.of("primaryColor", "#F00");
|
||||||
|
|
||||||
|
when(authUtil.getTenantId(any())).thenReturn(TENANT_ID);
|
||||||
|
when(brandConfigService.updateColorConfig(eq(TENANT_ID), any(BrandConfig.class)))
|
||||||
|
.thenReturn(Mono.just(config));
|
||||||
|
|
||||||
|
MockServerRequest request = mockRequest()
|
||||||
|
.body(Mono.just(requestBody));
|
||||||
|
Mono<ServerResponse> result = brandConfigHandler.updateColorConfig(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateColorConfig_shouldRejectInvalidHexInSecondary() {
|
||||||
|
Map<String, Object> requestBody = Map.of("secondaryColor", "not-a-color");
|
||||||
|
|
||||||
|
when(authUtil.getTenantId(any())).thenReturn(TENANT_ID);
|
||||||
|
|
||||||
|
MockServerRequest request = mockRequest()
|
||||||
|
.body(Mono.just(requestBody));
|
||||||
|
Mono<ServerResponse> result = brandConfigHandler.updateColorConfig(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== removeLogo ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removeLogo_shouldRemoveAndReturnOk() {
|
||||||
|
BrandConfig config = createTestConfig();
|
||||||
|
config.setLogoUrl(null);
|
||||||
|
|
||||||
|
when(authUtil.getTenantId(any())).thenReturn(TENANT_ID);
|
||||||
|
when(brandConfigService.removeLogo(TENANT_ID)).thenReturn(Mono.just(config));
|
||||||
|
|
||||||
|
MockServerRequest request = mockRequest().build();
|
||||||
|
Mono<ServerResponse> result = brandConfigHandler.removeLogo(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(brandConfigService).removeLogo(TENANT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== removeBackgroundImage ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removeBackgroundImage_shouldRemoveAndReturnOk() {
|
||||||
|
BrandConfig config = createTestConfig();
|
||||||
|
config.setBackgroundImageUrl(null);
|
||||||
|
|
||||||
|
when(authUtil.getTenantId(any())).thenReturn(TENANT_ID);
|
||||||
|
when(brandConfigService.removeBackgroundImage(TENANT_ID)).thenReturn(Mono.just(config));
|
||||||
|
|
||||||
|
MockServerRequest request = mockRequest().build();
|
||||||
|
Mono<ServerResponse> result = brandConfigHandler.removeBackgroundImage(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(brandConfigService).removeBackgroundImage(TENANT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== helper ====================
|
||||||
|
|
||||||
|
private BrandConfig createTestConfig() {
|
||||||
|
BrandConfig config = new BrandConfig();
|
||||||
|
config.setId(1L);
|
||||||
|
config.setTenantId(TENANT_ID);
|
||||||
|
config.setLogoUrl("https://example.com/logo.png");
|
||||||
|
config.setBackgroundImageUrl("https://example.com/bg.png");
|
||||||
|
config.setPrimaryColor("#00E676");
|
||||||
|
config.setPrimaryColorRgb("0,230,118");
|
||||||
|
config.setSecondaryColor("#1A1A1A");
|
||||||
|
config.setSecondaryColorRgb("26,26,26");
|
||||||
|
config.setFontFamily("default");
|
||||||
|
config.setCreatedAt(LocalDateTime.now());
|
||||||
|
config.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
+181
@@ -0,0 +1,181 @@
|
|||||||
|
package cn.novalon.gym.manage.brand.websocket;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
|
||||||
|
import cn.novalon.gym.manage.sys.security.JwtTokenProvider;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.web.reactive.socket.HandshakeInfo;
|
||||||
|
import org.springframework.web.reactive.socket.WebSocketMessage;
|
||||||
|
import org.springframework.web.reactive.socket.WebSocketSession;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.test.StepVerifier;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class BrandWebSocketHandlerTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private WebSocketSession session;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private WebSocketMessage webSocketMessage;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private HandshakeInfo handshakeInfo;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private JwtTokenProvider jwtTokenProvider;
|
||||||
|
|
||||||
|
private BrandWebSocketHandler handler;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
handler = new BrandWebSocketHandler(jwtTokenProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== JWT-based tenantId extraction ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void handle_shouldExtractTenantIdFromJwtAuthHeader() {
|
||||||
|
setupJwtAuthHeader("Bearer valid-jwt-token");
|
||||||
|
when(jwtTokenProvider.validateToken("valid-jwt-token")).thenReturn(true);
|
||||||
|
when(jwtTokenProvider.getTenantIdFromToken("valid-jwt-token")).thenReturn("tenant-from-jwt");
|
||||||
|
when(session.receive()).thenReturn(Flux.never());
|
||||||
|
|
||||||
|
// Should not throw — tenantId extracted from JWT
|
||||||
|
handler.handle(session).subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Query param fallback ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void handle_shouldFallbackToQueryParamWhenNoJwtHeader() {
|
||||||
|
// No Authorization header
|
||||||
|
when(handshakeInfo.getHeaders()).thenReturn(new HttpHeaders());
|
||||||
|
URI uri = URI.create("ws://localhost/ws/brand?tenantId=tenant-from-query");
|
||||||
|
when(session.getHandshakeInfo()).thenReturn(handshakeInfo);
|
||||||
|
when(handshakeInfo.getUri()).thenReturn(uri);
|
||||||
|
when(session.receive()).thenReturn(Flux.never());
|
||||||
|
|
||||||
|
handler.handle(session).subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void handle_shouldFallbackToSessionIdWhenNoQueryParamAndNoJwt() {
|
||||||
|
when(handshakeInfo.getHeaders()).thenReturn(new HttpHeaders());
|
||||||
|
URI uri = URI.create("ws://localhost/ws/brand");
|
||||||
|
when(session.getHandshakeInfo()).thenReturn(handshakeInfo);
|
||||||
|
when(handshakeInfo.getUri()).thenReturn(uri);
|
||||||
|
when(session.getId()).thenReturn("session-id-123");
|
||||||
|
when(session.receive()).thenReturn(Flux.never());
|
||||||
|
|
||||||
|
handler.handle(session).subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Message handling ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void handle_shouldProcessPingMessage() {
|
||||||
|
setupSessionWithJwt("tenant-001");
|
||||||
|
when(session.receive()).thenReturn(Flux.just(webSocketMessage));
|
||||||
|
when(webSocketMessage.getPayloadAsText()).thenReturn("{\"type\":\"ping\"}");
|
||||||
|
when(session.textMessage(anyString())).thenReturn(webSocketMessage);
|
||||||
|
when(session.send(any())).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
Mono<Void> result = handler.handle(session);
|
||||||
|
|
||||||
|
StepVerifier.create(result).verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void handle_shouldProcessSubscribeMessage() {
|
||||||
|
setupSessionWithJwt("tenant-001");
|
||||||
|
when(session.receive()).thenReturn(Flux.just(webSocketMessage));
|
||||||
|
when(webSocketMessage.getPayloadAsText()).thenReturn("{\"type\":\"subscribe\"}");
|
||||||
|
|
||||||
|
Mono<Void> result = handler.handle(session);
|
||||||
|
|
||||||
|
StepVerifier.create(result).verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void handle_shouldIgnoreUnknownMessageType() {
|
||||||
|
setupSessionWithJwt("tenant-001");
|
||||||
|
when(session.receive()).thenReturn(Flux.just(webSocketMessage));
|
||||||
|
when(webSocketMessage.getPayloadAsText()).thenReturn("{\"type\":\"unknown\"}");
|
||||||
|
|
||||||
|
Mono<Void> result = handler.handle(session);
|
||||||
|
|
||||||
|
StepVerifier.create(result).verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void handle_shouldNotCrashOnInvalidJson() {
|
||||||
|
setupSessionWithJwt("tenant-001");
|
||||||
|
when(session.receive()).thenReturn(Flux.just(webSocketMessage));
|
||||||
|
when(webSocketMessage.getPayloadAsText()).thenReturn("not-valid-json");
|
||||||
|
|
||||||
|
Mono<Void> result = handler.handle(session);
|
||||||
|
|
||||||
|
StepVerifier.create(result).verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void handle_shouldPropagateConnectionError() {
|
||||||
|
setupSessionWithJwt("tenant-001");
|
||||||
|
when(session.receive()).thenReturn(Flux.error(new RuntimeException("Connection error")));
|
||||||
|
|
||||||
|
Mono<Void> result = handler.handle(session);
|
||||||
|
|
||||||
|
StepVerifier.create(result).verifyError();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== sendBrandUpdate ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendBrandUpdate_shouldNotFailWhenNoSession() {
|
||||||
|
BrandConfig config = createTestConfig();
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
handler.sendBrandUpdate("nonexistent-tenant", config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== helper ====================
|
||||||
|
|
||||||
|
private void setupSessionWithJwt(String tenantId) {
|
||||||
|
String token = "jwt-" + tenantId;
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.set(HttpHeaders.AUTHORIZATION, "Bearer " + token);
|
||||||
|
when(handshakeInfo.getHeaders()).thenReturn(headers);
|
||||||
|
when(session.getHandshakeInfo()).thenReturn(handshakeInfo);
|
||||||
|
when(jwtTokenProvider.validateToken(token)).thenReturn(true);
|
||||||
|
when(jwtTokenProvider.getTenantIdFromToken(token)).thenReturn(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setupJwtAuthHeader(String authHeader) {
|
||||||
|
String token = authHeader.substring(7); // strip "Bearer "
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.set(HttpHeaders.AUTHORIZATION, authHeader);
|
||||||
|
when(handshakeInfo.getHeaders()).thenReturn(headers);
|
||||||
|
when(session.getHandshakeInfo()).thenReturn(handshakeInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BrandConfig createTestConfig() {
|
||||||
|
BrandConfig config = new BrandConfig();
|
||||||
|
config.setId(1L);
|
||||||
|
config.setTenantId("tenant-001");
|
||||||
|
config.setLogoUrl("https://example.com/logo.png");
|
||||||
|
config.setPrimaryColor("#00E676");
|
||||||
|
config.setFontFamily("default");
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
+172
@@ -0,0 +1,172 @@
|
|||||||
|
package cn.novalon.gym.manage.checkin.handler;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.checkIn.handler.CheckInHandler;
|
||||||
|
import cn.novalon.gym.manage.checkIn.service.impl.CheckServiceImpl;
|
||||||
|
import cn.novalon.gym.manage.checkIn.vo.QRCodeVo;
|
||||||
|
import cn.novalon.gym.manage.checkIn.vo.SignInRecordVO;
|
||||||
|
import cn.novalon.gym.manage.checkIn.vo.SignInStatsVO;
|
||||||
|
import cn.novalon.gym.manage.sys.util.AuthUtil;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.mock.web.reactive.function.server.MockServerRequest;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class CheckInHandlerTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private AuthUtil authUtil;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private CheckServiceImpl checkService;
|
||||||
|
|
||||||
|
private CheckInHandler checkInHandler;
|
||||||
|
|
||||||
|
private static final Long MEMBER_ID = 10001L;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
checkInHandler = new CheckInHandler(authUtil, checkService);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== checkIn ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void checkIn_shouldReturnOk() {
|
||||||
|
Map<String, Object> body = Map.of("qrContent", "checkin:member:10001");
|
||||||
|
|
||||||
|
when(authUtil.getMemberIdOrThrow(any())).thenReturn(MEMBER_ID);
|
||||||
|
when(checkService.checkIn(MEMBER_ID, "checkin:member:10001")).thenReturn(Mono.just("签到成功"));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.body(Mono.just(body));
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = checkInHandler.checkIn(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
|
||||||
|
verify(checkService).checkIn(MEMBER_ID, "checkin:member:10001");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void checkIn_shouldReturnBadRequestOnError() {
|
||||||
|
Map<String, Object> body = Map.of("qrContent", "invalid-content");
|
||||||
|
|
||||||
|
when(authUtil.getMemberIdOrThrow(any())).thenReturn(MEMBER_ID);
|
||||||
|
when(checkService.checkIn(MEMBER_ID, "invalid-content"))
|
||||||
|
.thenReturn(Mono.error(new RuntimeException("Invalid QR code")));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.body(Mono.just(body));
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = checkInHandler.checkIn(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getQRCode ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getQRCode_shouldReturnOkWithQRCode() {
|
||||||
|
QRCodeVo qrCode = new QRCodeVo("base64content", false, "qr-content", 200, 200, LocalDate.now());
|
||||||
|
|
||||||
|
when(authUtil.getMemberIdOrThrow(any())).thenReturn(MEMBER_ID);
|
||||||
|
when(checkService.getQRCode(MEMBER_ID)).thenReturn(Mono.just(qrCode));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder().build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = checkInHandler.getQRCode(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getSignInRecords ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getSignInRecords_shouldReturnOkWithRecords() {
|
||||||
|
List<SignInRecordVO> records = List.of(createTestRecord());
|
||||||
|
|
||||||
|
when(authUtil.getMemberIdOrThrow(any())).thenReturn(MEMBER_ID);
|
||||||
|
when(checkService.getSignInRecords(eq(MEMBER_ID), any(LocalDate.class), any(LocalDate.class)))
|
||||||
|
.thenReturn(Flux.fromIterable(records));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.queryParam("startDate", "2025-01-01")
|
||||||
|
.queryParam("endDate", "2025-01-31")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = checkInHandler.getSignInRecords(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getSignInStatistics ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getSignInStatistics_shouldReturnOkWithStats() {
|
||||||
|
SignInStatsVO stats = new SignInStatsVO();
|
||||||
|
|
||||||
|
when(authUtil.getMemberIdOrThrow(any())).thenReturn(MEMBER_ID);
|
||||||
|
when(checkService.getSignInStats(eq(MEMBER_ID), any(LocalDate.class), any(LocalDate.class)))
|
||||||
|
.thenReturn(Mono.just(stats));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.queryParam("startDate", "2025-01-01")
|
||||||
|
.queryParam("endDate", "2025-01-31")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = checkInHandler.getSignInStatistics(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getDailySignInStats ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getDailySignInStats_shouldReturnOkWithDailyStats() {
|
||||||
|
SignInStatsVO stats = new SignInStatsVO();
|
||||||
|
|
||||||
|
when(checkService.getDailySignInStats(any(LocalDate.class))).thenReturn(Mono.just(stats));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.queryParam("date", "2025-01-15")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = checkInHandler.getDailySignInStats(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== helper ====================
|
||||||
|
|
||||||
|
private SignInRecordVO createTestRecord() {
|
||||||
|
SignInRecordVO record = new SignInRecordVO();
|
||||||
|
record.setId(1L);
|
||||||
|
record.setMemberId(MEMBER_ID);
|
||||||
|
record.setSignInTime(LocalDateTime.now());
|
||||||
|
record.setSignInType("QR_CODE");
|
||||||
|
record.setSignInStatus("SUCCESS");
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>cn.novalon.gym.manage</groupId>
|
||||||
|
<artifactId>gym-manage-api</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>gym-coach-config</artifactId>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<name>Gym Coach Config</name>
|
||||||
|
<description>Coach Time Rule Configuration Module - Configurable coach lateness/absence thresholds</description>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.novalon.gym.manage</groupId>
|
||||||
|
<artifactId>manage-common</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.novalon.gym.manage</groupId>
|
||||||
|
<artifactId>manage-db</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.novalon.gym.manage</groupId>
|
||||||
|
<artifactId>manage-sys</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.data</groupId>
|
||||||
|
<artifactId>spring-data-commons</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springdoc</groupId>
|
||||||
|
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
package cn.novalon.gym.manage.coachconfig.converter;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.coachconfig.domain.CoachTimeRule;
|
||||||
|
import cn.novalon.gym.manage.db.entity.CoachTimeRuleEntity;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教练时间规则实体转换器
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-26
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class CoachTimeRuleConverter {
|
||||||
|
|
||||||
|
public CoachTimeRule toDomain(CoachTimeRuleEntity entity) {
|
||||||
|
if (entity == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
CoachTimeRule domain = new CoachTimeRule();
|
||||||
|
domain.setId(entity.getId());
|
||||||
|
domain.setMinDuration(entity.getMinDuration());
|
||||||
|
domain.setMaxDuration(entity.getMaxDuration());
|
||||||
|
domain.setNormalWindow(entity.getNormalWindow());
|
||||||
|
domain.setLateWindow(entity.getLateWindow());
|
||||||
|
domain.setEndGrace(entity.getEndGrace());
|
||||||
|
domain.setIsDefault(entity.getIsDefault());
|
||||||
|
domain.setSortOrder(entity.getSortOrder());
|
||||||
|
domain.setStatus(entity.getStatus());
|
||||||
|
domain.setRemark(entity.getRemark());
|
||||||
|
domain.setCreatedAt(entity.getCreatedAt());
|
||||||
|
domain.setUpdatedAt(entity.getUpdatedAt());
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CoachTimeRuleEntity toEntity(CoachTimeRule domain) {
|
||||||
|
if (domain == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
CoachTimeRuleEntity entity = new CoachTimeRuleEntity();
|
||||||
|
entity.setId(domain.getId());
|
||||||
|
entity.setMinDuration(domain.getMinDuration());
|
||||||
|
entity.setMaxDuration(domain.getMaxDuration());
|
||||||
|
entity.setNormalWindow(domain.getNormalWindow());
|
||||||
|
entity.setLateWindow(domain.getLateWindow());
|
||||||
|
entity.setEndGrace(domain.getEndGrace());
|
||||||
|
entity.setIsDefault(domain.getIsDefault());
|
||||||
|
entity.setSortOrder(domain.getSortOrder());
|
||||||
|
entity.setStatus(domain.getStatus());
|
||||||
|
entity.setRemark(domain.getRemark());
|
||||||
|
entity.setCreateBy(domain.getCreateBy());
|
||||||
|
entity.setUpdateBy(domain.getUpdateBy());
|
||||||
|
entity.setCreatedAt(domain.getCreatedAt());
|
||||||
|
entity.setUpdatedAt(domain.getUpdatedAt());
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
package cn.novalon.gym.manage.coachconfig.domain;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教练时间规则领域对象
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-26
|
||||||
|
*/
|
||||||
|
public class CoachTimeRule {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private Integer minDuration;
|
||||||
|
private Integer maxDuration;
|
||||||
|
private Integer normalWindow;
|
||||||
|
private Integer lateWindow;
|
||||||
|
private Integer endGrace;
|
||||||
|
private Boolean isDefault;
|
||||||
|
private Integer sortOrder;
|
||||||
|
private String status;
|
||||||
|
private String remark;
|
||||||
|
private String createBy;
|
||||||
|
private String updateBy;
|
||||||
|
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断该规则是否匹配给定课程时长
|
||||||
|
*/
|
||||||
|
public boolean matches(long courseDurationMinutes) {
|
||||||
|
boolean aboveMin = minDuration == null || courseDurationMinutes >= minDuration;
|
||||||
|
boolean belowMax = maxDuration == null || courseDurationMinutes <= maxDuration;
|
||||||
|
return aboveMin && belowMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回规则区间的宽度(用于精确匹配排序),无边界时返回 Integer.MAX_VALUE
|
||||||
|
*/
|
||||||
|
public int rangeWidth() {
|
||||||
|
if (minDuration == null && maxDuration == null) {
|
||||||
|
return Integer.MAX_VALUE;
|
||||||
|
}
|
||||||
|
if (minDuration == null) {
|
||||||
|
return maxDuration;
|
||||||
|
}
|
||||||
|
if (maxDuration == null) {
|
||||||
|
return Integer.MAX_VALUE - minDuration;
|
||||||
|
}
|
||||||
|
return maxDuration - minDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public void setId(Long id) { this.id = id; }
|
||||||
|
|
||||||
|
public Integer getMinDuration() { return minDuration; }
|
||||||
|
public void setMinDuration(Integer minDuration) { this.minDuration = minDuration; }
|
||||||
|
|
||||||
|
public Integer getMaxDuration() { return maxDuration; }
|
||||||
|
public void setMaxDuration(Integer maxDuration) { this.maxDuration = maxDuration; }
|
||||||
|
|
||||||
|
public Integer getNormalWindow() { return normalWindow; }
|
||||||
|
public void setNormalWindow(Integer normalWindow) { this.normalWindow = normalWindow; }
|
||||||
|
|
||||||
|
public Integer getLateWindow() { return lateWindow; }
|
||||||
|
public void setLateWindow(Integer lateWindow) { this.lateWindow = lateWindow; }
|
||||||
|
|
||||||
|
public Integer getEndGrace() { return endGrace; }
|
||||||
|
public void setEndGrace(Integer endGrace) { this.endGrace = endGrace; }
|
||||||
|
|
||||||
|
public Boolean getIsDefault() { return isDefault; }
|
||||||
|
public void setIsDefault(Boolean isDefault) { this.isDefault = isDefault; }
|
||||||
|
|
||||||
|
public Integer getSortOrder() { return sortOrder; }
|
||||||
|
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
|
||||||
|
|
||||||
|
public String getStatus() { return status; }
|
||||||
|
public void setStatus(String status) { this.status = status; }
|
||||||
|
|
||||||
|
public String getRemark() { return remark; }
|
||||||
|
public void setRemark(String remark) { this.remark = remark; }
|
||||||
|
|
||||||
|
public String getCreateBy() { return createBy; }
|
||||||
|
public void setCreateBy(String createBy) { this.createBy = createBy; }
|
||||||
|
|
||||||
|
public String getUpdateBy() { return updateBy; }
|
||||||
|
public void setUpdateBy(String updateBy) { this.updateBy = updateBy; }
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
|
||||||
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
}
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
package cn.novalon.gym.manage.coachconfig.handler;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.coachconfig.domain.CoachTimeRule;
|
||||||
|
import cn.novalon.gym.manage.coachconfig.service.CoachTimeRuleService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教练时间规则 HTTP 处理器
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-26
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Tag(name = "教练时间规则配置", description = "教练迟到/缺席时间阈值的可配置化管理")
|
||||||
|
public class CoachTimeRuleHandler {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(CoachTimeRuleHandler.class);
|
||||||
|
private final CoachTimeRuleService service;
|
||||||
|
|
||||||
|
public CoachTimeRuleHandler(CoachTimeRuleService service) {
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "获取所有规则", description = "获取所有启用和停用的教练时间规则")
|
||||||
|
public Mono<ServerResponse> getAllRules(ServerRequest request) {
|
||||||
|
return service.getAllRules()
|
||||||
|
.collectList()
|
||||||
|
.flatMap(rules -> ServerResponse.ok().bodyValue(rules))
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
logger.error("获取规则列表失败: {}", e.getMessage());
|
||||||
|
return ServerResponse.badRequest().bodyValue(Map.of("error", e.getMessage()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "获取单条规则", description = "根据ID获取教练时间规则")
|
||||||
|
public Mono<ServerResponse> getRuleById(ServerRequest request) {
|
||||||
|
Long id = Long.valueOf(request.pathVariable("id"));
|
||||||
|
return service.getRuleById(id)
|
||||||
|
.flatMap(rule -> ServerResponse.ok().bodyValue(rule))
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
logger.error("获取规则失败: {}", e.getMessage());
|
||||||
|
return ServerResponse.badRequest().bodyValue(Map.of("error", e.getMessage()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "创建规则", description = "创建一条新的教练时间规则,创建后立即生效")
|
||||||
|
public Mono<ServerResponse> createRule(ServerRequest request) {
|
||||||
|
return request.bodyToMono(CoachTimeRule.class)
|
||||||
|
.flatMap(rule -> service.createRule(rule)
|
||||||
|
.flatMap(saved -> {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("message", "规则创建成功");
|
||||||
|
result.put("id", saved.getId());
|
||||||
|
return ServerResponse.ok().bodyValue(result);
|
||||||
|
}))
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
logger.error("创建规则失败: {}", e.getMessage());
|
||||||
|
return ServerResponse.badRequest().bodyValue(Map.of("error", e.getMessage()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "更新规则", description = "更新教练时间规则,更新后立即生效")
|
||||||
|
public Mono<ServerResponse> updateRule(ServerRequest request) {
|
||||||
|
Long id = Long.valueOf(request.pathVariable("id"));
|
||||||
|
return request.bodyToMono(CoachTimeRule.class)
|
||||||
|
.flatMap(rule -> service.updateRule(id, rule)
|
||||||
|
.flatMap(updated -> ServerResponse.ok().bodyValue(Map.of("message", "规则更新成功"))))
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
logger.error("更新规则失败: {}", e.getMessage());
|
||||||
|
return ServerResponse.badRequest().bodyValue(Map.of("error", e.getMessage()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "删除规则", description = "软删除教练时间规则,删除后立即生效")
|
||||||
|
public Mono<ServerResponse> deleteRule(ServerRequest request) {
|
||||||
|
Long id = Long.valueOf(request.pathVariable("id"));
|
||||||
|
return service.deleteRule(id)
|
||||||
|
.then(ServerResponse.ok().bodyValue(Map.of("message", "规则删除成功")))
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
logger.error("删除规则失败: {}", e.getMessage());
|
||||||
|
return ServerResponse.badRequest().bodyValue(Map.of("error", e.getMessage()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
package cn.novalon.gym.manage.coachconfig.repository;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.coachconfig.domain.CoachTimeRule;
|
||||||
|
import cn.novalon.gym.manage.coachconfig.converter.CoachTimeRuleConverter;
|
||||||
|
import cn.novalon.gym.manage.db.dao.CoachTimeRuleDao;
|
||||||
|
import cn.novalon.gym.manage.db.entity.CoachTimeRuleEntity;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教练时间规则仓储实现类
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-26
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class CoachTimeRuleRepository implements ICoachTimeRuleRepository {
|
||||||
|
|
||||||
|
private final CoachTimeRuleDao dao;
|
||||||
|
private final CoachTimeRuleConverter converter;
|
||||||
|
|
||||||
|
public CoachTimeRuleRepository(CoachTimeRuleDao dao, CoachTimeRuleConverter converter) {
|
||||||
|
this.dao = dao;
|
||||||
|
this.converter = converter;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<CoachTimeRule> findByStatusAndDeletedAtIsNull(String status) {
|
||||||
|
return dao.findByStatusAndDeletedAtIsNull(status, Sort.by(Sort.Direction.ASC, "sort_order"))
|
||||||
|
.map(converter::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<CoachTimeRule> findByDeletedAtIsNull() {
|
||||||
|
return dao.findByDeletedAtIsNull()
|
||||||
|
.map(converter::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<CoachTimeRule> findById(Long id) {
|
||||||
|
return dao.findByIdAndDeletedAtIsNull(id)
|
||||||
|
.map(converter::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<CoachTimeRule> save(CoachTimeRule rule) {
|
||||||
|
CoachTimeRuleEntity entity = converter.toEntity(rule);
|
||||||
|
entity.setCreatedAt(rule.getId() == null ? LocalDateTime.now() : entity.getCreatedAt());
|
||||||
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return dao.save(entity)
|
||||||
|
.map(converter::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> deleteById(Long id) {
|
||||||
|
return dao.findByIdAndDeletedAtIsNull(id)
|
||||||
|
.flatMap(entity -> {
|
||||||
|
entity.setDeletedAt(LocalDateTime.now());
|
||||||
|
return dao.save(entity);
|
||||||
|
})
|
||||||
|
.then();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> unsetOtherDefaults(Long excludeId) {
|
||||||
|
return dao.findByIsDefaultTrueAndStatusAndDeletedAtIsNull("1")
|
||||||
|
.filter(entity -> excludeId == null || !entity.getId().equals(excludeId))
|
||||||
|
.flatMap(entity -> {
|
||||||
|
entity.setIsDefault(false);
|
||||||
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return dao.save(entity);
|
||||||
|
})
|
||||||
|
.then();
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package cn.novalon.gym.manage.coachconfig.repository;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.coachconfig.domain.CoachTimeRule;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教练时间规则仓储接口
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-26
|
||||||
|
*/
|
||||||
|
public interface ICoachTimeRuleRepository {
|
||||||
|
|
||||||
|
Flux<CoachTimeRule> findByStatusAndDeletedAtIsNull(String status);
|
||||||
|
|
||||||
|
Flux<CoachTimeRule> findByDeletedAtIsNull();
|
||||||
|
|
||||||
|
Mono<CoachTimeRule> findById(Long id);
|
||||||
|
|
||||||
|
Mono<CoachTimeRule> save(CoachTimeRule rule);
|
||||||
|
|
||||||
|
Mono<Void> deleteById(Long id);
|
||||||
|
|
||||||
|
/** 将除指定 id 之外的所有启用默认规则设为非默认(excludeId 为 null 表示清除全部) */
|
||||||
|
Mono<Void> unsetOtherDefaults(Long excludeId);
|
||||||
|
}
|
||||||
+240
@@ -0,0 +1,240 @@
|
|||||||
|
package cn.novalon.gym.manage.coachconfig.service;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.coachconfig.domain.CoachTimeRule;
|
||||||
|
import cn.novalon.gym.manage.coachconfig.repository.ICoachTimeRuleRepository;
|
||||||
|
import cn.novalon.gym.manage.common.constant.RedisKeyConstants;
|
||||||
|
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教练时间规则服务
|
||||||
|
*
|
||||||
|
* 提供规则 CRUD、缓存管理、以及基于课程时长的规则匹配。
|
||||||
|
* 每次写操作后立即清除 Redis 缓存,读操作使用 Cache-Aside 模式。
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-26
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class CoachTimeRuleService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(CoachTimeRuleService.class);
|
||||||
|
|
||||||
|
/** 硬编码兜底值:长课时(>=60分钟)默认正常窗口 */
|
||||||
|
private static final int FALLBACK_LONG_NORMAL_WINDOW = 10;
|
||||||
|
/** 硬编码兜底值:长课时(>=60分钟)默认迟到/缺席窗口 */
|
||||||
|
private static final int FALLBACK_LONG_LATE_WINDOW = 30;
|
||||||
|
/** 硬编码兜底值:短课时(<60分钟)默认正常窗口比例 */
|
||||||
|
private static final double FALLBACK_SHORT_NORMAL_RATIO = 0.10;
|
||||||
|
/** 硬编码兜底值:短课时(<60分钟)默认迟到/缺席窗口比例 */
|
||||||
|
private static final double FALLBACK_SHORT_LATE_RATIO = 0.25;
|
||||||
|
/** 硬编码兜底值:结课宽限期 */
|
||||||
|
private static final int FALLBACK_END_GRACE = 10;
|
||||||
|
/** 长/短课时分界线 */
|
||||||
|
private static final long ONE_HOUR_MINUTES = 60;
|
||||||
|
/** Redis 缓存 TTL(秒) */
|
||||||
|
private static final long CACHE_TTL_SECONDS = 300;
|
||||||
|
|
||||||
|
private final ICoachTimeRuleRepository repository;
|
||||||
|
private final RedisUtil redisUtil;
|
||||||
|
|
||||||
|
public CoachTimeRuleService(ICoachTimeRuleRepository repository, RedisUtil redisUtil) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.redisUtil = redisUtil;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 规则匹配 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据课程时长(分钟)匹配最精确的时间规则。
|
||||||
|
* 匹配逻辑:
|
||||||
|
* 1. 从缓存/DB 获取所有启用规则
|
||||||
|
* 2. 过滤出 courseDuration 在 [minDuration, maxDuration] 范围内的规则
|
||||||
|
* 3. 选择区间范围最小的(最精确匹配)
|
||||||
|
* 4. 无匹配时使用 isDefault=true 的默认规则
|
||||||
|
* 5. 全部无匹配时使用硬编码兜底值
|
||||||
|
*/
|
||||||
|
public Mono<CoachTimeRule> matchRule(long courseDurationMinutes) {
|
||||||
|
return getActiveRules()
|
||||||
|
.collectList()
|
||||||
|
.map(rules -> doMatch(rules, courseDurationMinutes));
|
||||||
|
}
|
||||||
|
|
||||||
|
private CoachTimeRule doMatch(List<CoachTimeRule> rules, long courseDurationMinutes) {
|
||||||
|
// 过滤出匹配的规则
|
||||||
|
List<CoachTimeRule> matched = rules.stream()
|
||||||
|
.filter(r -> r.matches(courseDurationMinutes))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (!matched.isEmpty()) {
|
||||||
|
// 选择范围最精确(区间宽度最小)的规则
|
||||||
|
return matched.stream()
|
||||||
|
.min(Comparator.comparingInt(CoachTimeRule::rangeWidth))
|
||||||
|
.orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无匹配,查找默认规则(同时也须匹配区间,防止有区间限制的默认规则覆盖不匹配的课程时长)
|
||||||
|
CoachTimeRule defaultRule = rules.stream()
|
||||||
|
.filter(r -> Boolean.TRUE.equals(r.getIsDefault()) && r.matches(courseDurationMinutes))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
if (defaultRule != null) {
|
||||||
|
logger.debug("无精确匹配规则,回退到默认规则 id={}", defaultRule.getId());
|
||||||
|
return defaultRule;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:返回硬编码默认值构造的虚拟规则
|
||||||
|
logger.warn("无匹配规则且默认规则也不匹配课程时长{}分钟,使用硬编码兜底值", courseDurationMinutes);
|
||||||
|
return buildFallbackRule(courseDurationMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造硬编码兜底规则(不持久化,仅在内存中使用)
|
||||||
|
*/
|
||||||
|
private CoachTimeRule buildFallbackRule(long courseDurationMinutes) {
|
||||||
|
CoachTimeRule fallback = new CoachTimeRule();
|
||||||
|
fallback.setEndGrace(FALLBACK_END_GRACE);
|
||||||
|
if (courseDurationMinutes >= ONE_HOUR_MINUTES) {
|
||||||
|
fallback.setNormalWindow(FALLBACK_LONG_NORMAL_WINDOW);
|
||||||
|
fallback.setLateWindow(FALLBACK_LONG_LATE_WINDOW);
|
||||||
|
} else {
|
||||||
|
fallback.setNormalWindow(Math.max(1, (int) (courseDurationMinutes * FALLBACK_SHORT_NORMAL_RATIO)));
|
||||||
|
fallback.setLateWindow(Math.max(1, (int) (courseDurationMinutes * FALLBACK_SHORT_LATE_RATIO)));
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 缓存管理 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有启用规则(带 Redis 缓存)
|
||||||
|
*/
|
||||||
|
private Flux<CoachTimeRule> getActiveRules() {
|
||||||
|
return redisUtil.get(RedisKeyConstants.COACH_TIME_RULES)
|
||||||
|
.flatMapMany(cached -> {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<CoachTimeRule> list = (List<CoachTimeRule>) cached;
|
||||||
|
logger.debug("从 Redis 缓存加载教练时间规则,共 {} 条", list.size());
|
||||||
|
return Flux.fromIterable(list);
|
||||||
|
})
|
||||||
|
.switchIfEmpty(Flux.defer(() -> {
|
||||||
|
logger.debug("Redis 缓存未命中,从 DB 加载教练时间规则");
|
||||||
|
return repository.findByStatusAndDeletedAtIsNull("1")
|
||||||
|
.collectList()
|
||||||
|
.flatMapMany(list -> {
|
||||||
|
redisUtil.setWithExpire(RedisKeyConstants.COACH_TIME_RULES, list, CACHE_TTL_SECONDS)
|
||||||
|
.subscribe(
|
||||||
|
ok -> logger.debug("教练时间规则已写入 Redis 缓存,共 {} 条", list.size()),
|
||||||
|
err -> logger.warn("教练时间规则写入 Redis 缓存失败: {}", err.getMessage())
|
||||||
|
);
|
||||||
|
return Flux.fromIterable(list);
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写操作后清除缓存(热更新入口)
|
||||||
|
*/
|
||||||
|
private void invalidateCache() {
|
||||||
|
redisUtil.delete(RedisKeyConstants.COACH_TIME_RULES)
|
||||||
|
.subscribe(
|
||||||
|
count -> logger.info("教练时间规则缓存已清除"),
|
||||||
|
err -> logger.warn("教练时间规则缓存清除失败: {}", err.getMessage())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== CRUD ====================
|
||||||
|
|
||||||
|
public Flux<CoachTimeRule> getAllRules() {
|
||||||
|
return repository.findByDeletedAtIsNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<CoachTimeRule> getRuleById(Long id) {
|
||||||
|
return repository.findById(id)
|
||||||
|
.switchIfEmpty(Mono.error(new RuntimeException("规则不存在")));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<CoachTimeRule> createRule(CoachTimeRule rule) {
|
||||||
|
return validateRule(rule)
|
||||||
|
.then(Mono.defer(() -> {
|
||||||
|
if (Boolean.TRUE.equals(rule.getIsDefault())) {
|
||||||
|
return repository.unsetOtherDefaults(null).then(repository.save(rule));
|
||||||
|
}
|
||||||
|
return repository.save(rule);
|
||||||
|
}))
|
||||||
|
.doOnSuccess(r -> invalidateCache());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<CoachTimeRule> updateRule(Long id, CoachTimeRule rule) {
|
||||||
|
return repository.findById(id)
|
||||||
|
.switchIfEmpty(Mono.error(new RuntimeException("规则不存在")))
|
||||||
|
.flatMap(existing -> {
|
||||||
|
existing.setMinDuration(rule.getMinDuration());
|
||||||
|
existing.setMaxDuration(rule.getMaxDuration());
|
||||||
|
existing.setNormalWindow(rule.getNormalWindow());
|
||||||
|
existing.setLateWindow(rule.getLateWindow());
|
||||||
|
existing.setEndGrace(rule.getEndGrace());
|
||||||
|
existing.setIsDefault(rule.getIsDefault());
|
||||||
|
existing.setSortOrder(rule.getSortOrder());
|
||||||
|
existing.setStatus(rule.getStatus());
|
||||||
|
existing.setRemark(rule.getRemark());
|
||||||
|
existing.setUpdateBy(rule.getUpdateBy());
|
||||||
|
return validateRule(existing)
|
||||||
|
.then(Mono.defer(() -> {
|
||||||
|
if (Boolean.TRUE.equals(existing.getIsDefault())) {
|
||||||
|
return repository.unsetOtherDefaults(id).then(repository.save(existing));
|
||||||
|
}
|
||||||
|
return repository.save(existing);
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.doOnSuccess(r -> invalidateCache());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Void> deleteRule(Long id) {
|
||||||
|
return repository.deleteById(id)
|
||||||
|
.doOnSuccess(v -> invalidateCache());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 校验 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验规则字段合法性
|
||||||
|
*/
|
||||||
|
private Mono<Void> validateRule(CoachTimeRule rule) {
|
||||||
|
Integer normalWindow = rule.getNormalWindow();
|
||||||
|
if (normalWindow == null || normalWindow < 1 || normalWindow > 1440) {
|
||||||
|
return Mono.error(new RuntimeException("正常开课窗口必须为 1-1440 之间的整数"));
|
||||||
|
}
|
||||||
|
Integer lateWindow = rule.getLateWindow();
|
||||||
|
if (lateWindow == null || lateWindow < normalWindow || lateWindow > 1440) {
|
||||||
|
return Mono.error(new RuntimeException("迟到/缺席窗口必须 >= 正常开课窗口(" + normalWindow + ")且 <= 1440"));
|
||||||
|
}
|
||||||
|
Integer endGrace = rule.getEndGrace();
|
||||||
|
if (endGrace == null || endGrace < 0 || endGrace > 1440) {
|
||||||
|
return Mono.error(new RuntimeException("结课宽限期必须为 0-1440 之间的整数"));
|
||||||
|
}
|
||||||
|
// 默认规则作为兜底,不允许设置时长区间
|
||||||
|
if (Boolean.TRUE.equals(rule.getIsDefault())) {
|
||||||
|
if (rule.getMinDuration() != null || rule.getMaxDuration() != null) {
|
||||||
|
return Mono.error(new RuntimeException("默认规则作为兜底规则,不允许设置时长区间"));
|
||||||
|
}
|
||||||
|
rule.setMinDuration(null);
|
||||||
|
rule.setMaxDuration(null);
|
||||||
|
}
|
||||||
|
// 区间合法性:若 minDuration 和 maxDuration 同时非空,则 maxDuration >= minDuration
|
||||||
|
Integer minDur = rule.getMinDuration();
|
||||||
|
Integer maxDur = rule.getMaxDuration();
|
||||||
|
if (minDur != null && maxDur != null && maxDur < minDur) {
|
||||||
|
return Mono.error(new RuntimeException("时长上限必须 >= 时长下限"));
|
||||||
|
}
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,11 @@
|
|||||||
<artifactId>gym-groupCourse</artifactId>
|
<artifactId>gym-groupCourse</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.novalon.gym.manage</groupId>
|
||||||
|
<artifactId>gym-coach-config</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||||
|
|||||||
+25
-24
@@ -1,6 +1,7 @@
|
|||||||
package cn.novalon.gym.manage.coach.scheduler;
|
package cn.novalon.gym.manage.coach.scheduler;
|
||||||
|
|
||||||
import cn.novalon.gym.manage.coach.enums.ViolationReason;
|
import cn.novalon.gym.manage.coach.enums.ViolationReason;
|
||||||
|
import cn.novalon.gym.manage.coachconfig.service.CoachTimeRuleService;
|
||||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||||
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseBookingDao;
|
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseBookingDao;
|
||||||
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseDao;
|
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseDao;
|
||||||
@@ -30,22 +31,23 @@ import java.time.LocalDateTime;
|
|||||||
public class CoachCourseScheduler {
|
public class CoachCourseScheduler {
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(CoachCourseScheduler.class);
|
private static final Logger logger = LoggerFactory.getLogger(CoachCourseScheduler.class);
|
||||||
private static final long ONE_HOUR_MINUTES = 60;
|
|
||||||
private static final long END_GRACE_MINUTES = 10;
|
|
||||||
|
|
||||||
private final GroupCourseDao groupCourseDao;
|
private final GroupCourseDao groupCourseDao;
|
||||||
private final GroupCourseBookingDao groupCourseBookingDao;
|
private final GroupCourseBookingDao groupCourseBookingDao;
|
||||||
private final DatabaseClient databaseClient;
|
private final DatabaseClient databaseClient;
|
||||||
private final RedisUtil redisUtil;
|
private final RedisUtil redisUtil;
|
||||||
|
private final CoachTimeRuleService timeRuleService;
|
||||||
|
|
||||||
public CoachCourseScheduler(GroupCourseDao groupCourseDao,
|
public CoachCourseScheduler(GroupCourseDao groupCourseDao,
|
||||||
GroupCourseBookingDao groupCourseBookingDao,
|
GroupCourseBookingDao groupCourseBookingDao,
|
||||||
DatabaseClient databaseClient,
|
DatabaseClient databaseClient,
|
||||||
RedisUtil redisUtil) {
|
RedisUtil redisUtil,
|
||||||
|
CoachTimeRuleService timeRuleService) {
|
||||||
this.groupCourseDao = groupCourseDao;
|
this.groupCourseDao = groupCourseDao;
|
||||||
this.groupCourseBookingDao = groupCourseBookingDao;
|
this.groupCourseBookingDao = groupCourseBookingDao;
|
||||||
this.databaseClient = databaseClient;
|
this.databaseClient = databaseClient;
|
||||||
this.redisUtil = redisUtil;
|
this.redisUtil = redisUtil;
|
||||||
|
this.timeRuleService = timeRuleService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,39 +91,38 @@ public class CoachCourseScheduler {
|
|||||||
*/
|
*/
|
||||||
private Mono<Long> processAbsentCourses(LocalDateTime now) {
|
private Mono<Long> processAbsentCourses(LocalDateTime now) {
|
||||||
return groupCourseDao.findByStatusAndStartTimeBefore(databaseClient, "0", now)
|
return groupCourseDao.findByStatusAndStartTimeBefore(databaseClient, "0", now)
|
||||||
.filter(course -> isAbsentThresholdExceeded(course, now))
|
.flatMap(course -> {
|
||||||
.flatMap(course -> markAsCoachAbsent(course, now))
|
long courseDurationMinutes = Duration.between(course.getStartTime(), course.getEndTime()).toMinutes();
|
||||||
|
return timeRuleService.matchRule(courseDurationMinutes)
|
||||||
|
.filter(rule -> {
|
||||||
|
long minutesSinceStart = Duration.between(course.getStartTime(), now).toMinutes();
|
||||||
|
return minutesSinceStart > rule.getLateWindow();
|
||||||
|
})
|
||||||
|
.flatMap(rule -> markAsCoachAbsent(course, now));
|
||||||
|
})
|
||||||
.count();
|
.count();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理自动结课:status IN ('3','7') 且 end_time + 10分钟 已过
|
* 处理自动结课:status IN ('3','7') 且 end_time + endGrace 已过
|
||||||
*/
|
*/
|
||||||
private Mono<Long> processAutoEndCourses(LocalDateTime now) {
|
private Mono<Long> processAutoEndCourses(LocalDateTime now) {
|
||||||
LocalDateTime endThreshold = now.minusMinutes(END_GRACE_MINUTES);
|
|
||||||
return groupCourseDao.findByStatusInAndEndTimeBefore(databaseClient,
|
return groupCourseDao.findByStatusInAndEndTimeBefore(databaseClient,
|
||||||
new String[]{String.valueOf(CourseStatus.IN_PROGRESS.getValue()),
|
new String[]{String.valueOf(CourseStatus.IN_PROGRESS.getValue()),
|
||||||
String.valueOf(CourseStatus.COACH_LATE.getValue())},
|
String.valueOf(CourseStatus.COACH_LATE.getValue())},
|
||||||
endThreshold)
|
now)
|
||||||
.flatMap(course -> markAsAutoEnded(course, now))
|
.flatMap(course -> {
|
||||||
|
long courseDurationMinutes = Duration.between(course.getStartTime(), course.getEndTime()).toMinutes();
|
||||||
|
return timeRuleService.matchRule(courseDurationMinutes)
|
||||||
|
.filter(rule -> {
|
||||||
|
long minutesAfterEnd = Duration.between(course.getEndTime(), now).toMinutes();
|
||||||
|
return minutesAfterEnd > rule.getEndGrace();
|
||||||
|
})
|
||||||
|
.flatMap(rule -> markAsAutoEnded(course, now));
|
||||||
|
})
|
||||||
.count();
|
.count();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断课程是否已过缺席阈值
|
|
||||||
*/
|
|
||||||
private boolean isAbsentThresholdExceeded(GroupCourseEntity course, LocalDateTime now) {
|
|
||||||
long courseDurationMinutes = Duration.between(course.getStartTime(), course.getEndTime()).toMinutes();
|
|
||||||
long minutesSinceStart = Duration.between(course.getStartTime(), now).toMinutes();
|
|
||||||
|
|
||||||
if (courseDurationMinutes >= ONE_HOUR_MINUTES) {
|
|
||||||
return minutesSinceStart > 30;
|
|
||||||
} else {
|
|
||||||
long thresholdB = Math.max(1, (long) (courseDurationMinutes * 0.25));
|
|
||||||
return minutesSinceStart > thresholdB;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标记课程为教练缺席(5),更新预约记录为教练缺席(4),记录违规
|
* 标记课程为教练缺席(5),更新预约记录为教练缺席(4),记录违规
|
||||||
*/
|
*/
|
||||||
|
|||||||
+33
-48
@@ -3,6 +3,8 @@ package cn.novalon.gym.manage.coach.service;
|
|||||||
import cn.novalon.gym.manage.coach.dao.CoachViolationDao;
|
import cn.novalon.gym.manage.coach.dao.CoachViolationDao;
|
||||||
import cn.novalon.gym.manage.coach.entity.CoachViolationEntity;
|
import cn.novalon.gym.manage.coach.entity.CoachViolationEntity;
|
||||||
import cn.novalon.gym.manage.coach.enums.ViolationReason;
|
import cn.novalon.gym.manage.coach.enums.ViolationReason;
|
||||||
|
import cn.novalon.gym.manage.coachconfig.domain.CoachTimeRule;
|
||||||
|
import cn.novalon.gym.manage.coachconfig.service.CoachTimeRuleService;
|
||||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||||
import cn.novalon.gym.manage.common.util.StatusConstants;
|
import cn.novalon.gym.manage.common.util.StatusConstants;
|
||||||
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseBookingDao;
|
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseBookingDao;
|
||||||
@@ -43,7 +45,6 @@ public class CoachCourseService {
|
|||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(CoachCourseService.class);
|
private static final Logger logger = LoggerFactory.getLogger(CoachCourseService.class);
|
||||||
private static final String COACH_ROLE_NAME = "教练";
|
private static final String COACH_ROLE_NAME = "教练";
|
||||||
private static final long ONE_HOUR_MINUTES = 60;
|
|
||||||
|
|
||||||
private final ISysUserRepository userRepository;
|
private final ISysUserRepository userRepository;
|
||||||
private final ISysRoleRepository roleRepository;
|
private final ISysRoleRepository roleRepository;
|
||||||
@@ -56,6 +57,7 @@ public class CoachCourseService {
|
|||||||
private final DatabaseClient databaseClient;
|
private final DatabaseClient databaseClient;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final RedisUtil redisUtil;
|
private final RedisUtil redisUtil;
|
||||||
|
private final CoachTimeRuleService timeRuleService;
|
||||||
|
|
||||||
public CoachCourseService(ISysUserRepository userRepository,
|
public CoachCourseService(ISysUserRepository userRepository,
|
||||||
ISysRoleRepository roleRepository,
|
ISysRoleRepository roleRepository,
|
||||||
@@ -67,7 +69,8 @@ public class CoachCourseService {
|
|||||||
CoachViolationDao violationDao,
|
CoachViolationDao violationDao,
|
||||||
DatabaseClient databaseClient,
|
DatabaseClient databaseClient,
|
||||||
PasswordEncoder passwordEncoder,
|
PasswordEncoder passwordEncoder,
|
||||||
RedisUtil redisUtil) {
|
RedisUtil redisUtil,
|
||||||
|
CoachTimeRuleService timeRuleService) {
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.roleRepository = roleRepository;
|
this.roleRepository = roleRepository;
|
||||||
this.userRoleRepository = userRoleRepository;
|
this.userRoleRepository = userRoleRepository;
|
||||||
@@ -79,6 +82,7 @@ public class CoachCourseService {
|
|||||||
this.databaseClient = databaseClient;
|
this.databaseClient = databaseClient;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
this.redisUtil = redisUtil;
|
this.redisUtil = redisUtil;
|
||||||
|
this.timeRuleService = timeRuleService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 教练管理(从原 CoachService 迁移) ====================
|
// ==================== 教练管理(从原 CoachService 迁移) ====================
|
||||||
@@ -176,9 +180,7 @@ public class CoachCourseService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 教练手动开课
|
* 教练手动开课
|
||||||
* 判定逻辑:
|
* 通过 CoachTimeRuleService 匹配规则获取时间阈值,替代原硬编码逻辑
|
||||||
* - 长课时(>=1h): 10分钟内正常,10~30分钟迟到,>30分钟拒绝
|
|
||||||
* - 短课时(<1h): 10%时长内正常,10%~25%迟到,>25%拒绝
|
|
||||||
*/
|
*/
|
||||||
public Mono<GroupCourseEntity> startCourse(Long courseId, Long coachId) {
|
public Mono<GroupCourseEntity> startCourse(Long courseId, Long coachId) {
|
||||||
return groupCourseDao.findByIdIsAndDeletedAtIsNull(courseId)
|
return groupCourseDao.findByIdIsAndDeletedAtIsNull(courseId)
|
||||||
@@ -196,53 +198,29 @@ public class CoachCourseService {
|
|||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
long courseDurationMinutes = Duration.between(course.getStartTime(), course.getEndTime()).toMinutes();
|
long courseDurationMinutes = Duration.between(course.getStartTime(), course.getEndTime()).toMinutes();
|
||||||
|
|
||||||
if (courseDurationMinutes >= ONE_HOUR_MINUTES) {
|
return timeRuleService.matchRule(courseDurationMinutes)
|
||||||
return handleLongCourseStart(course, now);
|
.flatMap(rule -> doStartCourseWithRule(course, now, rule));
|
||||||
} else {
|
|
||||||
return handleShortCourseStart(course, now, courseDurationMinutes);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mono<GroupCourseEntity> handleLongCourseStart(GroupCourseEntity course, LocalDateTime now) {
|
private Mono<GroupCourseEntity> doStartCourseWithRule(GroupCourseEntity course, LocalDateTime now,
|
||||||
|
CoachTimeRule rule) {
|
||||||
long minutesSinceStart = Duration.between(course.getStartTime(), now).toMinutes();
|
long minutesSinceStart = Duration.between(course.getStartTime(), now).toMinutes();
|
||||||
|
|
||||||
if (minutesSinceStart < 0) {
|
if (minutesSinceStart < 0) {
|
||||||
return Mono.error(new RuntimeException("课程尚未到开课时间"));
|
return Mono.error(new RuntimeException("课程尚未到开课时间"));
|
||||||
}
|
}
|
||||||
if (minutesSinceStart <= 10) {
|
if (minutesSinceStart <= rule.getNormalWindow()) {
|
||||||
// 正常开课
|
// 正常开课
|
||||||
return doStartCourse(course, now, CourseStatus.IN_PROGRESS, null);
|
return doStartCourse(course, now, CourseStatus.IN_PROGRESS, null);
|
||||||
}
|
}
|
||||||
if (minutesSinceStart <= 30) {
|
if (minutesSinceStart <= rule.getLateWindow()) {
|
||||||
// 教练迟到
|
// 教练迟到
|
||||||
return recordViolation(course.getCoachId(), course.getId(), now, ViolationReason.COACH_LATE)
|
return recordViolation(course.getCoachId(), course.getId(), now, ViolationReason.COACH_LATE)
|
||||||
.then(doStartCourse(course, now, CourseStatus.COACH_LATE, ViolationReason.COACH_LATE));
|
.then(doStartCourse(course, now, CourseStatus.COACH_LATE, ViolationReason.COACH_LATE));
|
||||||
}
|
}
|
||||||
// >30分钟,拒绝(调度器应已标记为缺席)
|
// 超过 lateWindow,拒绝
|
||||||
return Mono.error(new RuntimeException("已超过开课时间30分钟,无法开课"));
|
return Mono.error(new RuntimeException("已超过开课时间" + rule.getLateWindow() + "分钟,无法开课"));
|
||||||
}
|
|
||||||
|
|
||||||
private Mono<GroupCourseEntity> handleShortCourseStart(GroupCourseEntity course, LocalDateTime now,
|
|
||||||
long courseDurationMinutes) {
|
|
||||||
long minutesSinceStart = Duration.between(course.getStartTime(), now).toMinutes();
|
|
||||||
long thresholdA = Math.max(1, (long) (courseDurationMinutes * 0.10));
|
|
||||||
long thresholdB = Math.max(1, (long) (courseDurationMinutes * 0.25));
|
|
||||||
|
|
||||||
if (minutesSinceStart < 0) {
|
|
||||||
return Mono.error(new RuntimeException("课程尚未到开课时间"));
|
|
||||||
}
|
|
||||||
if (minutesSinceStart <= thresholdA) {
|
|
||||||
// 正常开课
|
|
||||||
return doStartCourse(course, now, CourseStatus.IN_PROGRESS, null);
|
|
||||||
}
|
|
||||||
if (minutesSinceStart <= thresholdB) {
|
|
||||||
// 教练迟到
|
|
||||||
return recordViolation(course.getCoachId(), course.getId(), now, ViolationReason.COACH_LATE)
|
|
||||||
.then(doStartCourse(course, now, CourseStatus.COACH_LATE, ViolationReason.COACH_LATE));
|
|
||||||
}
|
|
||||||
// >thresholdB,拒绝
|
|
||||||
return Mono.error(new RuntimeException("已超过开课时间,无法开课"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mono<GroupCourseEntity> doStartCourse(GroupCourseEntity course, LocalDateTime now,
|
private Mono<GroupCourseEntity> doStartCourse(GroupCourseEntity course, LocalDateTime now,
|
||||||
@@ -260,7 +238,7 @@ public class CoachCourseService {
|
|||||||
/**
|
/**
|
||||||
* 教练手动结课
|
* 教练手动结课
|
||||||
* 可在 IN_PROGRESS(3) 或 COACH_LATE(7) 状态下结课
|
* 可在 IN_PROGRESS(3) 或 COACH_LATE(7) 状态下结课
|
||||||
* 必须在标注结课时间 + 10分钟内
|
* 必须在标注结课时间 + endGrace 分钟内
|
||||||
*/
|
*/
|
||||||
public Mono<GroupCourseEntity> endCourse(Long courseId, Long coachId) {
|
public Mono<GroupCourseEntity> endCourse(Long courseId, Long coachId) {
|
||||||
return groupCourseDao.findByIdIsAndDeletedAtIsNull(courseId)
|
return groupCourseDao.findByIdIsAndDeletedAtIsNull(courseId)
|
||||||
@@ -278,18 +256,25 @@ public class CoachCourseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
long minutesAfterEnd = Duration.between(course.getEndTime(), now).toMinutes();
|
long courseDurationMinutes = Duration.between(course.getStartTime(), course.getEndTime()).toMinutes();
|
||||||
|
|
||||||
if (minutesAfterEnd > 10) {
|
return timeRuleService.matchRule(courseDurationMinutes)
|
||||||
return Mono.error(new RuntimeException("已超过结课时间10分钟,请等待系统自动结课"));
|
.flatMap(rule -> {
|
||||||
}
|
long minutesAfterEnd = Duration.between(course.getEndTime(), now).toMinutes();
|
||||||
|
|
||||||
course.setStatus(CourseStatus.ENDED.getValue());
|
if (minutesAfterEnd > rule.getEndGrace()) {
|
||||||
course.setActualEndTime(now);
|
return Mono.error(new RuntimeException(
|
||||||
course.setUpdatedAt(now);
|
"已超过结课时间" + rule.getEndGrace() + "分钟,请等待系统自动结课"));
|
||||||
return groupCourseDao.updateEndInfo(course.getId(), String.valueOf(CourseStatus.ENDED.getValue()), now, now)
|
}
|
||||||
.then(groupCourseDao.findByIdIsAndDeletedAtIsNull(course.getId()))
|
|
||||||
.flatMap(entity -> invalidateStatisticsCache().thenReturn(entity));
|
course.setStatus(CourseStatus.ENDED.getValue());
|
||||||
|
course.setActualEndTime(now);
|
||||||
|
course.setUpdatedAt(now);
|
||||||
|
return groupCourseDao.updateEndInfo(course.getId(),
|
||||||
|
String.valueOf(CourseStatus.ENDED.getValue()), now, now)
|
||||||
|
.then(groupCourseDao.findByIdIsAndDeletedAtIsNull(course.getId()))
|
||||||
|
.flatMap(entity -> invalidateStatisticsCache().thenReturn(entity));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+176
@@ -0,0 +1,176 @@
|
|||||||
|
package cn.novalon.gym.manage.coach.handler;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.coach.service.CoachCourseService;
|
||||||
|
import cn.novalon.gym.manage.sys.core.domain.SysUser;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.mock.web.reactive.function.server.MockServerRequest;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import jakarta.validation.Validator;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class CoachHandlerTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private CoachCourseService coachCourseService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private Validator validator;
|
||||||
|
|
||||||
|
private CoachHandler coachHandler;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
coachHandler = new CoachHandler(coachCourseService, validator);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getAllCoaches ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAllCoaches_shouldReturnOkWithCoachList() {
|
||||||
|
SysUser coach = mock(SysUser.class);
|
||||||
|
when(coachCourseService.getAllCoaches()).thenReturn(Flux.just(coach));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder().build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = coachHandler.getAllCoaches(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
|
||||||
|
verify(coachCourseService).getAllCoaches();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAllCoaches_shouldReturnOkWhenEmptyList() {
|
||||||
|
when(coachCourseService.getAllCoaches()).thenReturn(Flux.empty());
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder().build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = coachHandler.getAllCoaches(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getCoachCourses ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getCoachCourses_shouldReturnOkWithCourses() {
|
||||||
|
GroupCourse course = mock(GroupCourse.class);
|
||||||
|
when(coachCourseService.getCoachCourses(anyLong())).thenReturn(Flux.just(course));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("id", "1")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = coachHandler.getCoachCourses(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getCoachCourses_shouldReturnOkWhenEmpty() {
|
||||||
|
when(coachCourseService.getCoachCourses(anyLong())).thenReturn(Flux.empty());
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("id", "999")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = coachHandler.getCoachCourses(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== disableCoach ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void disableCoach_shouldReturnOkWhenDisabled() {
|
||||||
|
when(coachCourseService.disableCoach(anyLong())).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("id", "1")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = coachHandler.disableCoach(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void disableCoach_shouldReturnBadRequestOnError() {
|
||||||
|
when(coachCourseService.disableCoach(anyLong()))
|
||||||
|
.thenReturn(Mono.error(new RuntimeException("Coach not found")));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("id", "999")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = coachHandler.disableCoach(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getViolationCounts ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getViolationCounts_shouldReturnOk() {
|
||||||
|
when(coachCourseService.getViolationCounts()).thenReturn(Flux.empty());
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder().build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = coachHandler.getViolationCounts(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getCoachViolations ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getCoachViolations_shouldReturnOkWithViolations() {
|
||||||
|
when(coachCourseService.getCoachViolations(anyLong())).thenReturn(Flux.just(Map.of("violationId", 1, "reason", "迟到")));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("id", "1")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = coachHandler.getCoachViolations(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getCoachViolations_shouldReturnEmptyListWhenNone() {
|
||||||
|
when(coachCourseService.getCoachViolations(anyLong())).thenReturn(Flux.empty());
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("id", "999")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = coachHandler.getCoachViolations(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-9
@@ -271,7 +271,7 @@ public class DataStatisticsDao {
|
|||||||
return databaseClient.sql("""
|
return databaseClient.sql("""
|
||||||
SELECT coach_id, COUNT(*) AS count
|
SELECT coach_id, COUNT(*) AS count
|
||||||
FROM group_course
|
FROM group_course
|
||||||
WHERE start_time >= :startTime AND start_time < :endTime
|
WHERE end_time >= :startTime AND end_time < :endTime
|
||||||
AND status IN ('2', '6') AND deleted_at IS NULL
|
AND status IN ('2', '6') AND deleted_at IS NULL
|
||||||
GROUP BY coach_id
|
GROUP BY coach_id
|
||||||
""")
|
""")
|
||||||
@@ -282,16 +282,16 @@ public class DataStatisticsDao {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按教练统计出席人次(通过团课关联,booking.status='2')
|
* 按教练统计出席人次(booking.status IN ('2','4','5'):已出席 + 教练缺席 + 迟到)
|
||||||
*/
|
*/
|
||||||
public reactor.core.publisher.Flux<java.util.Map<String, Object>> countAttendedStudentsByCoach(LocalDateTime startTime, LocalDateTime endTime) {
|
public reactor.core.publisher.Flux<java.util.Map<String, Object>> countAttendedStudentsByCoach(LocalDateTime startTime, LocalDateTime endTime) {
|
||||||
return databaseClient.sql("""
|
return databaseClient.sql("""
|
||||||
SELECT gc.coach_id, COUNT(*) AS count
|
SELECT gc.coach_id, COUNT(*) AS count
|
||||||
FROM group_course_booking b
|
FROM group_course_booking b
|
||||||
INNER JOIN group_course gc ON b.course_id = gc.id
|
INNER JOIN group_course gc ON b.course_id = gc.id
|
||||||
WHERE b.status = '2' AND b.deleted_at IS NULL
|
WHERE b.status IN ('2', '4', '5') AND b.deleted_at IS NULL
|
||||||
AND gc.deleted_at IS NULL
|
AND gc.deleted_at IS NULL
|
||||||
AND gc.start_time >= :startTime AND gc.start_time < :endTime
|
AND gc.end_time >= :startTime AND gc.end_time < :endTime
|
||||||
GROUP BY gc.coach_id
|
GROUP BY gc.coach_id
|
||||||
""")
|
""")
|
||||||
.bind("startTime", startTime)
|
.bind("startTime", startTime)
|
||||||
@@ -301,16 +301,16 @@ public class DataStatisticsDao {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按教练统计总非取消预约数(用于计算出勤率分母)
|
* 按教练统计总预约数(仅 status='0' 已预约,用于出勤率分母)
|
||||||
*/
|
*/
|
||||||
public reactor.core.publisher.Flux<java.util.Map<String, Object>> countTotalBookingsByCoach(LocalDateTime startTime, LocalDateTime endTime) {
|
public reactor.core.publisher.Flux<java.util.Map<String, Object>> countTotalBookingsByCoach(LocalDateTime startTime, LocalDateTime endTime) {
|
||||||
return databaseClient.sql("""
|
return databaseClient.sql("""
|
||||||
SELECT gc.coach_id, COUNT(*) AS count
|
SELECT gc.coach_id, COUNT(*) AS count
|
||||||
FROM group_course_booking b
|
FROM group_course_booking b
|
||||||
INNER JOIN group_course gc ON b.course_id = gc.id
|
INNER JOIN group_course gc ON b.course_id = gc.id
|
||||||
WHERE b.status != '1' AND b.deleted_at IS NULL
|
WHERE b.status = '0' AND b.deleted_at IS NULL
|
||||||
AND gc.deleted_at IS NULL
|
AND gc.deleted_at IS NULL
|
||||||
AND gc.start_time >= :startTime AND gc.start_time < :endTime
|
AND gc.end_time >= :startTime AND gc.end_time < :endTime
|
||||||
GROUP BY gc.coach_id
|
GROUP BY gc.coach_id
|
||||||
""")
|
""")
|
||||||
.bind("startTime", startTime)
|
.bind("startTime", startTime)
|
||||||
@@ -326,8 +326,8 @@ public class DataStatisticsDao {
|
|||||||
return databaseClient.sql("""
|
return databaseClient.sql("""
|
||||||
SELECT gc.coach_id, gc.max_members, COUNT(b.id) AS attended
|
SELECT gc.coach_id, gc.max_members, COUNT(b.id) AS attended
|
||||||
FROM group_course gc
|
FROM group_course gc
|
||||||
LEFT JOIN group_course_booking b ON gc.id = b.course_id AND b.status = '2' AND b.deleted_at IS NULL
|
LEFT JOIN group_course_booking b ON gc.id = b.course_id AND b.status IN ('2','4','5') AND b.deleted_at IS NULL
|
||||||
WHERE gc.start_time >= :startTime AND gc.start_time < :endTime
|
WHERE gc.end_time >= :startTime AND gc.end_time < :endTime
|
||||||
AND gc.status IN ('2', '6') AND gc.deleted_at IS NULL
|
AND gc.status IN ('2', '6') AND gc.deleted_at IS NULL
|
||||||
GROUP BY gc.coach_id, gc.id, gc.max_members
|
GROUP BY gc.coach_id, gc.id, gc.max_members
|
||||||
""")
|
""")
|
||||||
|
|||||||
+16
-6
@@ -574,8 +574,10 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
|
|||||||
Map<Long, Collection<FillRateItem>> fillRateMap = tuple.getT5();
|
Map<Long, Collection<FillRateItem>> fillRateMap = tuple.getT5();
|
||||||
Map<Long, Long> violationsMap = tuple.getT6();
|
Map<Long, Long> violationsMap = tuple.getT6();
|
||||||
|
|
||||||
// 计算最大授课量(用于归一化)
|
// 授课量百分位排名归一化
|
||||||
long maxCourses = coursesMap.values().stream().mapToLong(Long::longValue).max().orElse(1L);
|
List<Long> sortedCourses = coursesMap.values().stream()
|
||||||
|
.sorted().collect(Collectors.toList());
|
||||||
|
int totalCoaches = sortedCourses.size();
|
||||||
|
|
||||||
List<CoachPerformance> performances = coaches.keySet().stream()
|
List<CoachPerformance> performances = coaches.keySet().stream()
|
||||||
.map(coachId -> {
|
.map(coachId -> {
|
||||||
@@ -588,10 +590,18 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
|
|||||||
double attendanceRate = totalBookings > 0
|
double attendanceRate = totalBookings > 0
|
||||||
? (double) attended / totalBookings * 100 : 0;
|
? (double) attended / totalBookings * 100 : 0;
|
||||||
double fillRate = calculateFillRate(fillRateMap.getOrDefault(coachId, List.of()));
|
double fillRate = calculateFillRate(fillRateMap.getOrDefault(coachId, List.of()));
|
||||||
double normalizedCourses = maxCourses > 0
|
|
||||||
? (double) courses / maxCourses * 100 : 0;
|
// 百分位排名:授课量小于当前教练的教练数 / (总教练数-1) * 100
|
||||||
double compositeScore = normalizedCourses * 0.4
|
long coachesWithFewer = sortedCourses.stream().filter(v -> v < courses).count();
|
||||||
+ attendanceRate * 0.3 + fillRate * 0.3;
|
double normalizedCourses = totalCoaches > 1
|
||||||
|
? (double) coachesWithFewer / (totalCoaches - 1) * 100 : 100;
|
||||||
|
|
||||||
|
// 违规扣分:每次违规扣20分,最低0分
|
||||||
|
double violationScore = Math.max(0, 100 - violations * 20);
|
||||||
|
|
||||||
|
double compositeScore = normalizedCourses * 0.35
|
||||||
|
+ attendanceRate * 0.25 + fillRate * 0.25
|
||||||
|
+ violationScore * 0.15;
|
||||||
|
|
||||||
return CoachPerformance.builder()
|
return CoachPerformance.builder()
|
||||||
.coachId(coachId)
|
.coachId(coachId)
|
||||||
|
|||||||
+219
@@ -0,0 +1,219 @@
|
|||||||
|
package cn.novalon.gym.manage.datacount.handler;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.datacount.domain.*;
|
||||||
|
import cn.novalon.gym.manage.datacount.service.IDataStatisticsService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.mock.web.reactive.function.server.MockServerRequest;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class DataStatisticsHandlerTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private IDataStatisticsService dataStatisticsService;
|
||||||
|
|
||||||
|
private DataStatisticsHandler handler;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() throws Exception {
|
||||||
|
handler = new DataStatisticsHandler();
|
||||||
|
java.lang.reflect.Field field = DataStatisticsHandler.class.getDeclaredField("dataStatisticsService");
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(handler, dataStatisticsService);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getStatisticsSummary ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getStatisticsSummary_shouldReturnOkWithSummary() {
|
||||||
|
StatisticsSummary summary = createTestSummary();
|
||||||
|
when(dataStatisticsService.getStatisticsSummaryWithCache(any(StatisticsQuery.class))).thenReturn(Mono.just(summary));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.queryParam("periodType", "DAY")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = handler.getStatisticsSummary(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getStatisticsSummary_shouldReturnOkEvenOnError() {
|
||||||
|
when(dataStatisticsService.getStatisticsSummaryWithCache(any(StatisticsQuery.class)))
|
||||||
|
.thenReturn(Mono.error(new RuntimeException("Service error")));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.queryParam("periodType", "DAY")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Error handler returns empty/default summary with 200 OK
|
||||||
|
Mono<ServerResponse> result = handler.getStatisticsSummary(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getMemberStatistics ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getMemberStatistics_shouldReturnOk() {
|
||||||
|
MemberStatistics stats = new MemberStatistics();
|
||||||
|
when(dataStatisticsService.getMemberStatistics(any(StatisticsQuery.class))).thenReturn(Mono.just(stats));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.queryParam("periodType", "WEEK")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = handler.getMemberStatistics(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getMemberStatistics_shouldReturnOkEvenOnError() {
|
||||||
|
when(dataStatisticsService.getMemberStatistics(any(StatisticsQuery.class)))
|
||||||
|
.thenReturn(Mono.error(new RuntimeException("Service error")));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.queryParam("periodType", "WEEK")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = handler.getMemberStatistics(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getBookingStatistics ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getBookingStatistics_shouldReturnOk() {
|
||||||
|
BookingStatistics stats = new BookingStatistics();
|
||||||
|
when(dataStatisticsService.getBookingStatistics(any(StatisticsQuery.class))).thenReturn(Mono.just(stats));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.queryParam("periodType", "MONTH")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = handler.getBookingStatistics(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getSignInStatistics ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getSignInStatistics_shouldReturnOk() {
|
||||||
|
SignInStatistics stats = new SignInStatistics();
|
||||||
|
when(dataStatisticsService.getSignInStatistics(any(StatisticsQuery.class))).thenReturn(Mono.just(stats));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.queryParam("periodType", "MONTH")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = handler.getSignInStatistics(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== queryHistoricalStatistics ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void queryHistoricalStatistics_shouldReturnOkWithList() {
|
||||||
|
when(dataStatisticsService.queryHistoricalStatistics(any(StatisticsQuery.class)))
|
||||||
|
.thenReturn(Flux.just(createTestDataStatistics()));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.queryParam("periodType", "YEAR")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = handler.queryHistoricalStatistics(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void queryHistoricalStatistics_shouldReturnOkWhenEmpty() {
|
||||||
|
when(dataStatisticsService.queryHistoricalStatistics(any(StatisticsQuery.class)))
|
||||||
|
.thenReturn(Flux.empty());
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.queryParam("periodType", "YEAR")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = handler.queryHistoricalStatistics(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== exportStatistics ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exportStatistics_shouldReturnOkWithExcelContent() {
|
||||||
|
byte[] excelData = "mock-excel-content".getBytes();
|
||||||
|
when(dataStatisticsService.exportStatistics(any(StatisticsQuery.class))).thenReturn(Mono.just(excelData));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.queryParam("periodType", "MONTH")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = handler.exportStatistics(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== buildQueryFromRequest (via parameterized tests) ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getStatisticsSummary_shouldUseDefaultPeriodWhenMissing() {
|
||||||
|
StatisticsSummary summary = createTestSummary();
|
||||||
|
when(dataStatisticsService.getStatisticsSummaryWithCache(any(StatisticsQuery.class))).thenReturn(Mono.just(summary));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder().build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = handler.getStatisticsSummary(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== helper ====================
|
||||||
|
|
||||||
|
private StatisticsSummary createTestSummary() {
|
||||||
|
StatisticsSummary summary = new StatisticsSummary();
|
||||||
|
summary.setMemberStatistics(new MemberStatistics());
|
||||||
|
summary.setBookingStatistics(new BookingStatistics());
|
||||||
|
summary.setSignInStatistics(new SignInStatistics());
|
||||||
|
summary.setCoachStatistics(new CoachStatistics());
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DataStatistics createTestDataStatistics() {
|
||||||
|
return DataStatistics.builder()
|
||||||
|
.statType("MEMBER")
|
||||||
|
.periodType("DAY")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-2
@@ -753,8 +753,16 @@ public class GroupCourseService implements IGroupCourseService {
|
|||||||
}
|
}
|
||||||
return groupCourseRepository.findByCoachId(coachId)
|
return groupCourseRepository.findByCoachId(coachId)
|
||||||
.filter(course -> {
|
.filter(course -> {
|
||||||
// 排除已取消的课程
|
// 排除已软删除的课程
|
||||||
if (course.getStatus() != null && course.getStatus().equals(CourseStatus.CANCELLED.getValue())) {
|
if (course.getDeletedAt() != null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 仅检查以下有效状态的课程:0-正常, 3-进行中, 7-教练迟到
|
||||||
|
Long status = course.getStatus();
|
||||||
|
if (status == null ||
|
||||||
|
(!status.equals(CourseStatus.NORMAL.getValue()) &&
|
||||||
|
!status.equals(CourseStatus.IN_PROGRESS.getValue()) &&
|
||||||
|
!status.equals(CourseStatus.COACH_LATE.getValue()))) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 排除自身(编辑时)
|
// 排除自身(编辑时)
|
||||||
|
|||||||
+227
@@ -0,0 +1,227 @@
|
|||||||
|
package cn.novalon.gym.manage.groupcourse.handler;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.vo.GroupCourseVO;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.validation.Validator;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.mock.web.reactive.function.server.MockServerRequest;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class GroupCourseHandlerTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private IGroupCourseService groupCourseService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private Validator validator;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private RedisUtil redisUtil;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
private GroupCourseHandler handler;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
handler = new GroupCourseHandler(groupCourseService, validator, redisUtil, objectMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getAllGroupCourse ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAllGroupCourse_shouldReturnOkWithCourses() {
|
||||||
|
GroupCourseVO vo1 = mock(GroupCourseVO.class);
|
||||||
|
GroupCourseVO vo2 = mock(GroupCourseVO.class);
|
||||||
|
when(groupCourseService.findAllAsVO(false)).thenReturn(Flux.just(vo1, vo2));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder().build();
|
||||||
|
Mono<ServerResponse> result = handler.getAllGroupCourse(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
verify(groupCourseService).findAllAsVO(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAllGroupCourse_shouldReturnOkWhenEmpty() {
|
||||||
|
when(groupCourseService.findAllAsVO(false)).thenReturn(Flux.empty());
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder().build();
|
||||||
|
Mono<ServerResponse> result = handler.getAllGroupCourse(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getGroupCourseById ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getGroupCourseById_shouldReturnOkWhenFound() {
|
||||||
|
GroupCourse course = createTestCourse(1L, "瑜伽课");
|
||||||
|
when(groupCourseService.findById(1L)).thenReturn(Mono.just(course));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("id", "1")
|
||||||
|
.build();
|
||||||
|
Mono<ServerResponse> result = handler.getGroupCourseById(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getGroupCourseById_shouldReturnNotFound() {
|
||||||
|
when(groupCourseService.findById(999L)).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("id", "999")
|
||||||
|
.build();
|
||||||
|
Mono<ServerResponse> result = handler.getGroupCourseById(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getGroupCourseDetailById ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getGroupCourseDetailById_shouldReturnOkWhenFound() {
|
||||||
|
GroupCourseDetail detail = mock(GroupCourseDetail.class);
|
||||||
|
when(groupCourseService.findDetailById(1L)).thenReturn(Mono.just(detail));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("id", "1")
|
||||||
|
.build();
|
||||||
|
Mono<ServerResponse> result = handler.getGroupCourseDetailById(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getGroupCourseDetailById_shouldReturnNotFound() {
|
||||||
|
when(groupCourseService.findDetailById(999L)).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("id", "999")
|
||||||
|
.build();
|
||||||
|
Mono<ServerResponse> result = handler.getGroupCourseDetailById(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== cancelGroupCourse ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelGroupCourse_shouldReturnOkWhenCancelled() {
|
||||||
|
GroupCourse cancelled = createTestCourse(1L, "瑜伽课");
|
||||||
|
cancelled.setStatus(2L);
|
||||||
|
when(groupCourseService.cancel(1L)).thenReturn(Mono.just(cancelled));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("id", "1")
|
||||||
|
.build();
|
||||||
|
Mono<ServerResponse> result = handler.cancelGroupCourse(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelGroupCourse_shouldReturnNotFound() {
|
||||||
|
when(groupCourseService.cancel(999L)).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("id", "999")
|
||||||
|
.build();
|
||||||
|
Mono<ServerResponse> result = handler.cancelGroupCourse(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== deleteGroupCourse ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteGroupCourse_shouldReturnOkWhenDeleted() {
|
||||||
|
when(groupCourseService.delete(1L)).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("id", "1")
|
||||||
|
.build();
|
||||||
|
Mono<ServerResponse> result = handler.deleteGroupCourse(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteGroupCourse_shouldReturnNotFound() {
|
||||||
|
when(groupCourseService.delete(999L)).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("id", "999")
|
||||||
|
.build();
|
||||||
|
Mono<ServerResponse> result = handler.deleteGroupCourse(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== signIn ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signIn_shouldReturnOk() {
|
||||||
|
GroupCourse course = createTestCourse(1L, "瑜伽课");
|
||||||
|
when(validator.validate(any())).thenReturn(java.util.Collections.emptySet());
|
||||||
|
when(groupCourseService.signIn(eq(1L), eq(10001L))).thenReturn(Mono.just(course));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("courseId", "1")
|
||||||
|
.body(Mono.just(java.util.Map.of("memberId", 10001L, "courseId", 1L)));
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = handler.signIn(request);
|
||||||
|
|
||||||
|
ServerResponse response = result.block();
|
||||||
|
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== helper ====================
|
||||||
|
|
||||||
|
private GroupCourse createTestCourse(Long id, String courseName) {
|
||||||
|
GroupCourse course = new GroupCourse();
|
||||||
|
course.setId(id);
|
||||||
|
course.setCourseName(courseName);
|
||||||
|
course.setCourseType(1L);
|
||||||
|
course.setCoachId(1L);
|
||||||
|
course.setStartTime(LocalDateTime.now().plusDays(1));
|
||||||
|
course.setEndTime(LocalDateTime.now().plusDays(1).plusHours(1));
|
||||||
|
course.setLocation("101室");
|
||||||
|
course.setMaxMembers(20);
|
||||||
|
course.setCurrentMembers(5);
|
||||||
|
course.setStatus(0L);
|
||||||
|
return course;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,10 +169,22 @@
|
|||||||
<version>1.0.0</version>
|
<version>1.0.0</version>
|
||||||
<scope>compile</scope>
|
<scope>compile</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.novalon.gym.manage</groupId>
|
||||||
|
<artifactId>gym-coach-config</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
<scope>compile</scope>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>cn.novalon.gym.manage</groupId>
|
<groupId>cn.novalon.gym.manage</groupId>
|
||||||
<artifactId>gym-coach</artifactId>
|
<artifactId>gym-coach</artifactId>
|
||||||
<version>1.0.0</version>
|
<version>${project.version}</version>
|
||||||
|
<scope>compile</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.novalon.gym.manage</groupId>
|
||||||
|
<artifactId>gym-brand</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
<scope>compile</scope>
|
<scope>compile</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|||||||
+23
-1
@@ -20,8 +20,10 @@ import cn.novalon.gym.manage.notify.handler.BannerHandler;
|
|||||||
import cn.novalon.gym.manage.notify.handler.SysNoticeHandler;
|
import cn.novalon.gym.manage.notify.handler.SysNoticeHandler;
|
||||||
import cn.novalon.gym.manage.notify.handler.SysUserMessageHandler;
|
import cn.novalon.gym.manage.notify.handler.SysUserMessageHandler;
|
||||||
import cn.novalon.gym.manage.payment.handler.PaymentHandler;
|
import cn.novalon.gym.manage.payment.handler.PaymentHandler;
|
||||||
|
import cn.novalon.gym.manage.brand.handler.BrandConfigHandler;
|
||||||
import cn.novalon.gym.manage.coach.handler.CoachCourseHandler;
|
import cn.novalon.gym.manage.coach.handler.CoachCourseHandler;
|
||||||
import cn.novalon.gym.manage.coach.handler.CoachHandler;
|
import cn.novalon.gym.manage.coach.handler.CoachHandler;
|
||||||
|
import cn.novalon.gym.manage.coachconfig.handler.CoachTimeRuleHandler;
|
||||||
import cn.novalon.gym.manage.datacount.handler.CoachPerformanceHandler;
|
import cn.novalon.gym.manage.datacount.handler.CoachPerformanceHandler;
|
||||||
import cn.novalon.gym.manage.sys.handler.auth.PasswordDiagnosticHandler;
|
import cn.novalon.gym.manage.sys.handler.auth.PasswordDiagnosticHandler;
|
||||||
import cn.novalon.gym.manage.sys.handler.auth.SysAuthHandler;
|
import cn.novalon.gym.manage.sys.handler.auth.SysAuthHandler;
|
||||||
@@ -88,8 +90,10 @@ public class SystemRouter {
|
|||||||
DataStatisticsHandler dataStatisticsHandler,
|
DataStatisticsHandler dataStatisticsHandler,
|
||||||
PhoneAuthHandler phoneAuthHandler,
|
PhoneAuthHandler phoneAuthHandler,
|
||||||
PaymentHandler paymentHandler,
|
PaymentHandler paymentHandler,
|
||||||
|
BrandConfigHandler brandConfigHandler,
|
||||||
CoachHandler coachHandler,
|
CoachHandler coachHandler,
|
||||||
CoachCourseHandler coachCourseHandler,
|
CoachCourseHandler coachCourseHandler,
|
||||||
|
CoachTimeRuleHandler coachTimeRuleHandler,
|
||||||
CoachPerformanceHandler coachPerformanceHandler) {
|
CoachPerformanceHandler coachPerformanceHandler) {
|
||||||
|
|
||||||
return route()
|
return route()
|
||||||
@@ -135,6 +139,13 @@ public class SystemRouter {
|
|||||||
.POST("/api/coach/courses/{courseId}/start", coachCourseHandler::startCourse)
|
.POST("/api/coach/courses/{courseId}/start", coachCourseHandler::startCourse)
|
||||||
.POST("/api/coach/courses/{courseId}/end", coachCourseHandler::endCourse)
|
.POST("/api/coach/courses/{courseId}/end", coachCourseHandler::endCourse)
|
||||||
|
|
||||||
|
// ========== 教练时间规则配置路由 ==========
|
||||||
|
.GET("/api/coach/time-rules", coachTimeRuleHandler::getAllRules)
|
||||||
|
.GET("/api/coach/time-rules/{id}", coachTimeRuleHandler::getRuleById)
|
||||||
|
.POST("/api/coach/time-rules", coachTimeRuleHandler::createRule)
|
||||||
|
.PUT("/api/coach/time-rules/{id}", coachTimeRuleHandler::updateRule)
|
||||||
|
.DELETE("/api/coach/time-rules/{id}", coachTimeRuleHandler::deleteRule)
|
||||||
|
|
||||||
// ========== 菜单路由 ==========
|
// ========== 菜单路由 ==========
|
||||||
.GET("/api/menus", menuHandler::getAllMenus)
|
.GET("/api/menus", menuHandler::getAllMenus)
|
||||||
.GET("/api/menus/tree", menuHandler::getMenuTree)
|
.GET("/api/menus/tree", menuHandler::getMenuTree)
|
||||||
@@ -241,7 +252,7 @@ public class SystemRouter {
|
|||||||
.GET("/api/files/{id}/download", fileHandler::downloadFile)
|
.GET("/api/files/{id}/download", fileHandler::downloadFile)
|
||||||
.GET("/api/files/download/{fileName}", fileHandler::downloadFileByName)
|
.GET("/api/files/download/{fileName}", fileHandler::downloadFileByName)
|
||||||
.GET("/api/files/{id}/preview", fileHandler::previewFile)
|
.GET("/api/files/{id}/preview", fileHandler::previewFile)
|
||||||
.GET("/api/files/preview/{fileName}", fileHandler::previewFileByName)
|
.GET("/api/files/preview/{*fileName}", fileHandler::previewFileByName)
|
||||||
.DELETE("/api/files/{id}", fileHandler::deleteFile)
|
.DELETE("/api/files/{id}", fileHandler::deleteFile)
|
||||||
|
|
||||||
// ========== 权限路由 ==========
|
// ========== 权限路由 ==========
|
||||||
@@ -429,6 +440,17 @@ public class SystemRouter {
|
|||||||
.POST("/api/payment/{orderId}/refund", paymentHandler::refundPayment)
|
.POST("/api/payment/{orderId}/refund", paymentHandler::refundPayment)
|
||||||
.POST("/api/payment/{orderId}/close", paymentHandler::closeOrder)
|
.POST("/api/payment/{orderId}/close", paymentHandler::closeOrder)
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ========== 品牌定制模块路由 ============
|
||||||
|
// ========================================
|
||||||
|
.GET("/api/brand", brandConfigHandler::getBrandConfig)
|
||||||
|
.POST("/api/brand/logo", brandConfigHandler::uploadLogo)
|
||||||
|
.DELETE("/api/brand/logo", brandConfigHandler::removeLogo)
|
||||||
|
.POST("/api/brand/background", brandConfigHandler::uploadBackgroundImage)
|
||||||
|
.DELETE("/api/brand/background", brandConfigHandler::removeBackgroundImage)
|
||||||
|
.PUT("/api/brand/color", brandConfigHandler::updateColorConfig)
|
||||||
|
.PUT("/api/brand/info", brandConfigHandler::updateBrandInfo)
|
||||||
|
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
@@ -70,4 +70,13 @@ public final class RedisKeyConstants {
|
|||||||
* 用途:存储登录/注册等场景的短信验证码
|
* 用途:存储登录/注册等场景的短信验证码
|
||||||
*/
|
*/
|
||||||
public static final String SMS_CODE = "sms:code:";
|
public static final String SMS_CODE = "sms:code:";
|
||||||
|
|
||||||
|
// ==================== 教练配置模块 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教练时间规则缓存
|
||||||
|
* 格式:coach:time:rules
|
||||||
|
* 用途:缓存所有启用的教练时间规则列表
|
||||||
|
*/
|
||||||
|
public static final String COACH_TIME_RULES = "coach:time:rules";
|
||||||
}
|
}
|
||||||
@@ -27,6 +27,11 @@
|
|||||||
<artifactId>manage-notify</artifactId>
|
<artifactId>manage-notify</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.novalon.gym.manage</groupId>
|
||||||
|
<artifactId>gym-brand</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>cn.novalon.gym.manage</groupId>
|
<groupId>cn.novalon.gym.manage</groupId>
|
||||||
<artifactId>manage-file</artifactId>
|
<artifactId>manage-file</artifactId>
|
||||||
|
|||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
package cn.novalon.gym.manage.db.converter;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
|
||||||
|
import cn.novalon.gym.manage.db.entity.BrandConfigEntity;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 品牌配置实体转换器
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-23
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class BrandConfigConverter {
|
||||||
|
|
||||||
|
public BrandConfig toDomain(BrandConfigEntity entity) {
|
||||||
|
if (entity == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
BrandConfig domain = new BrandConfig();
|
||||||
|
domain.setId(entity.getId());
|
||||||
|
domain.setTenantId(entity.getTenantId());
|
||||||
|
domain.setLogoUrl(entity.getLogoUrl());
|
||||||
|
domain.setBackgroundImageUrl(entity.getBackgroundImageUrl());
|
||||||
|
domain.setPrimaryColor(entity.getPrimaryColor());
|
||||||
|
domain.setPrimaryColorRgb(entity.getPrimaryColorRgb());
|
||||||
|
domain.setSecondaryColor(entity.getSecondaryColor());
|
||||||
|
domain.setSecondaryColorRgb(entity.getSecondaryColorRgb());
|
||||||
|
domain.setFontFamily(entity.getFontFamily());
|
||||||
|
domain.setBrandName(entity.getBrandName());
|
||||||
|
domain.setSlogan(entity.getSlogan());
|
||||||
|
domain.setCreatedAt(entity.getCreatedAt());
|
||||||
|
domain.setUpdatedAt(entity.getUpdatedAt());
|
||||||
|
domain.setDeletedAt(entity.getDeletedAt());
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BrandConfigEntity toEntity(BrandConfig domain) {
|
||||||
|
if (domain == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
BrandConfigEntity entity = new BrandConfigEntity();
|
||||||
|
entity.setId(domain.getId());
|
||||||
|
entity.setTenantId(domain.getTenantId());
|
||||||
|
entity.setLogoUrl(domain.getLogoUrl());
|
||||||
|
entity.setBackgroundImageUrl(domain.getBackgroundImageUrl());
|
||||||
|
entity.setPrimaryColor(domain.getPrimaryColor());
|
||||||
|
entity.setPrimaryColorRgb(domain.getPrimaryColorRgb());
|
||||||
|
entity.setSecondaryColor(domain.getSecondaryColor());
|
||||||
|
entity.setSecondaryColorRgb(domain.getSecondaryColorRgb());
|
||||||
|
entity.setFontFamily(domain.getFontFamily());
|
||||||
|
entity.setBrandName(domain.getBrandName());
|
||||||
|
entity.setSlogan(domain.getSlogan());
|
||||||
|
entity.setCreatedAt(domain.getCreatedAt());
|
||||||
|
entity.setUpdatedAt(domain.getUpdatedAt());
|
||||||
|
entity.setDeletedAt(domain.getDeletedAt());
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package cn.novalon.gym.manage.db.dao;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.db.entity.BrandConfigEntity;
|
||||||
|
import org.springframework.data.r2dbc.repository.Query;
|
||||||
|
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 品牌配置数据访问接口
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-23
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface BrandConfigDao extends R2dbcRepository<BrandConfigEntity, Long> {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM brand_config WHERE tenant_id = $1 AND deleted_at IS NULL")
|
||||||
|
Mono<BrandConfigEntity> findByTenantIdAndDeletedAtIsNull(String tenantId);
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
package cn.novalon.gym.manage.db.dao;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.db.entity.CoachTimeRuleEntity;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教练时间规则 DAO
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-26
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface CoachTimeRuleDao extends R2dbcRepository<CoachTimeRuleEntity, Long> {
|
||||||
|
|
||||||
|
Flux<CoachTimeRuleEntity> findByStatusAndDeletedAtIsNull(String status);
|
||||||
|
|
||||||
|
Flux<CoachTimeRuleEntity> findByStatusAndDeletedAtIsNull(String status, Sort sort);
|
||||||
|
|
||||||
|
Flux<CoachTimeRuleEntity> findByDeletedAtIsNull();
|
||||||
|
|
||||||
|
Mono<CoachTimeRuleEntity> findByIdAndDeletedAtIsNull(Long id);
|
||||||
|
|
||||||
|
Flux<CoachTimeRuleEntity> findByIsDefaultTrueAndStatusAndDeletedAtIsNull(String status);
|
||||||
|
}
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
package cn.novalon.gym.manage.db.entity;
|
||||||
|
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
|
import org.springframework.data.relational.core.mapping.Column;
|
||||||
|
import org.springframework.data.relational.core.mapping.Table;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 品牌配置数据库实体类
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-23
|
||||||
|
*/
|
||||||
|
@Table("brand_config")
|
||||||
|
public class BrandConfigEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column("tenant_id")
|
||||||
|
private String tenantId;
|
||||||
|
|
||||||
|
@Column("logo_url")
|
||||||
|
private String logoUrl;
|
||||||
|
|
||||||
|
@Column("background_image_url")
|
||||||
|
private String backgroundImageUrl;
|
||||||
|
|
||||||
|
@Column("primary_color")
|
||||||
|
private String primaryColor;
|
||||||
|
|
||||||
|
@Column("primary_color_rgb")
|
||||||
|
private String primaryColorRgb;
|
||||||
|
|
||||||
|
@Column("secondary_color")
|
||||||
|
private String secondaryColor;
|
||||||
|
|
||||||
|
@Column("secondary_color_rgb")
|
||||||
|
private String secondaryColorRgb;
|
||||||
|
|
||||||
|
@Column("font_family")
|
||||||
|
private String fontFamily;
|
||||||
|
|
||||||
|
@Column("brand_name")
|
||||||
|
private String brandName;
|
||||||
|
|
||||||
|
@Column("slogan")
|
||||||
|
private String slogan;
|
||||||
|
|
||||||
|
@Column("created_at")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column("updated_at")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@Column("deleted_at")
|
||||||
|
private LocalDateTime deletedAt;
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public void setId(Long id) { this.id = id; }
|
||||||
|
public String getTenantId() { return tenantId; }
|
||||||
|
public void setTenantId(String tenantId) { this.tenantId = tenantId; }
|
||||||
|
public String getLogoUrl() { return logoUrl; }
|
||||||
|
public void setLogoUrl(String logoUrl) { this.logoUrl = logoUrl; }
|
||||||
|
public String getBackgroundImageUrl() { return backgroundImageUrl; }
|
||||||
|
public void setBackgroundImageUrl(String backgroundImageUrl) { this.backgroundImageUrl = backgroundImageUrl; }
|
||||||
|
public String getPrimaryColor() { return primaryColor; }
|
||||||
|
public void setPrimaryColor(String primaryColor) { this.primaryColor = primaryColor; }
|
||||||
|
public String getPrimaryColorRgb() { return primaryColorRgb; }
|
||||||
|
public void setPrimaryColorRgb(String primaryColorRgb) { this.primaryColorRgb = primaryColorRgb; }
|
||||||
|
public String getSecondaryColor() { return secondaryColor; }
|
||||||
|
public void setSecondaryColor(String secondaryColor) { this.secondaryColor = secondaryColor; }
|
||||||
|
public String getSecondaryColorRgb() { return secondaryColorRgb; }
|
||||||
|
public void setSecondaryColorRgb(String secondaryColorRgb) { this.secondaryColorRgb = secondaryColorRgb; }
|
||||||
|
public String getFontFamily() { return fontFamily; }
|
||||||
|
public void setFontFamily(String fontFamily) { this.fontFamily = fontFamily; }
|
||||||
|
public String getBrandName() { return brandName; }
|
||||||
|
public void setBrandName(String brandName) { this.brandName = brandName; }
|
||||||
|
public String getSlogan() { return slogan; }
|
||||||
|
public void setSlogan(String slogan) { this.slogan = slogan; }
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
public LocalDateTime getDeletedAt() { return deletedAt; }
|
||||||
|
public void setDeletedAt(LocalDateTime deletedAt) { this.deletedAt = deletedAt; }
|
||||||
|
}
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
package cn.novalon.gym.manage.db.entity;
|
||||||
|
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
|
import org.springframework.data.relational.core.mapping.Column;
|
||||||
|
import org.springframework.data.relational.core.mapping.Table;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教练时间规则配置数据库实体类
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-26
|
||||||
|
*/
|
||||||
|
@Table("coach_time_rule")
|
||||||
|
public class CoachTimeRuleEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column("min_duration")
|
||||||
|
private Integer minDuration;
|
||||||
|
|
||||||
|
@Column("max_duration")
|
||||||
|
private Integer maxDuration;
|
||||||
|
|
||||||
|
@Column("normal_window")
|
||||||
|
private Integer normalWindow;
|
||||||
|
|
||||||
|
@Column("late_window")
|
||||||
|
private Integer lateWindow;
|
||||||
|
|
||||||
|
@Column("end_grace")
|
||||||
|
private Integer endGrace;
|
||||||
|
|
||||||
|
@Column("is_default")
|
||||||
|
private Boolean isDefault;
|
||||||
|
|
||||||
|
@Column("sort_order")
|
||||||
|
private Integer sortOrder;
|
||||||
|
|
||||||
|
@Column("status")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Column("remark")
|
||||||
|
private String remark;
|
||||||
|
|
||||||
|
@Column("create_by")
|
||||||
|
private String createBy;
|
||||||
|
|
||||||
|
@Column("update_by")
|
||||||
|
private String updateBy;
|
||||||
|
|
||||||
|
@Column("created_at")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column("updated_at")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@Column("deleted_at")
|
||||||
|
private LocalDateTime deletedAt;
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public void setId(Long id) { this.id = id; }
|
||||||
|
|
||||||
|
public Integer getMinDuration() { return minDuration; }
|
||||||
|
public void setMinDuration(Integer minDuration) { this.minDuration = minDuration; }
|
||||||
|
|
||||||
|
public Integer getMaxDuration() { return maxDuration; }
|
||||||
|
public void setMaxDuration(Integer maxDuration) { this.maxDuration = maxDuration; }
|
||||||
|
|
||||||
|
public Integer getNormalWindow() { return normalWindow; }
|
||||||
|
public void setNormalWindow(Integer normalWindow) { this.normalWindow = normalWindow; }
|
||||||
|
|
||||||
|
public Integer getLateWindow() { return lateWindow; }
|
||||||
|
public void setLateWindow(Integer lateWindow) { this.lateWindow = lateWindow; }
|
||||||
|
|
||||||
|
public Integer getEndGrace() { return endGrace; }
|
||||||
|
public void setEndGrace(Integer endGrace) { this.endGrace = endGrace; }
|
||||||
|
|
||||||
|
public Boolean getIsDefault() { return isDefault; }
|
||||||
|
public void setIsDefault(Boolean isDefault) { this.isDefault = isDefault; }
|
||||||
|
|
||||||
|
public Integer getSortOrder() { return sortOrder; }
|
||||||
|
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
|
||||||
|
|
||||||
|
public String getStatus() { return status; }
|
||||||
|
public void setStatus(String status) { this.status = status; }
|
||||||
|
|
||||||
|
public String getRemark() { return remark; }
|
||||||
|
public void setRemark(String remark) { this.remark = remark; }
|
||||||
|
|
||||||
|
public String getCreateBy() { return createBy; }
|
||||||
|
public void setCreateBy(String createBy) { this.createBy = createBy; }
|
||||||
|
|
||||||
|
public String getUpdateBy() { return updateBy; }
|
||||||
|
public void setUpdateBy(String updateBy) { this.updateBy = updateBy; }
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
|
||||||
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
|
||||||
|
public LocalDateTime getDeletedAt() { return deletedAt; }
|
||||||
|
public void setDeletedAt(LocalDateTime deletedAt) { this.deletedAt = deletedAt; }
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
package cn.novalon.gym.manage.db.repository;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
|
||||||
|
import cn.novalon.gym.manage.brand.core.repository.IBrandConfigRepository;
|
||||||
|
import cn.novalon.gym.manage.db.converter.BrandConfigConverter;
|
||||||
|
import cn.novalon.gym.manage.db.dao.BrandConfigDao;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 品牌配置仓储实现类
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-23
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class BrandConfigRepository implements IBrandConfigRepository {
|
||||||
|
|
||||||
|
private final BrandConfigDao brandConfigDao;
|
||||||
|
private final BrandConfigConverter brandConfigConverter;
|
||||||
|
|
||||||
|
public BrandConfigRepository(BrandConfigDao brandConfigDao, BrandConfigConverter brandConfigConverter) {
|
||||||
|
this.brandConfigDao = brandConfigDao;
|
||||||
|
this.brandConfigConverter = brandConfigConverter;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<BrandConfig> findByTenantId(String tenantId) {
|
||||||
|
return brandConfigDao.findByTenantIdAndDeletedAtIsNull(tenantId)
|
||||||
|
.map(brandConfigConverter::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<BrandConfig> save(BrandConfig brandConfig) {
|
||||||
|
return brandConfigDao.save(brandConfigConverter.toEntity(brandConfig))
|
||||||
|
.map(brandConfigConverter::toDomain);
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-26
@@ -66,121 +66,121 @@ ON CONFLICT DO NOTHING;
|
|||||||
|
|
||||||
-- ============================================
|
-- ============================================
|
||||||
-- 4. 团课课程数据(5个类型 × 5个课程 = 25个)
|
-- 4. 团课课程数据(5个类型 × 5个课程 = 25个)
|
||||||
-- start_time / end_time 分布在 2026-07-22 至 2026-07-28
|
-- start_time / end_time 分布在 2026-07-26 至 2026-08-01
|
||||||
-- ============================================
|
-- ============================================
|
||||||
|
|
||||||
-- ---------- 瑜伽课程(5个)----------
|
-- ---------- 瑜伽课程(5个)----------
|
||||||
|
|
||||||
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
||||||
SELECT '哈他瑜伽入门', gt.id, '2026-07-22 08:00:00'::TIMESTAMP, '2026-07-22 09:00:00'::TIMESTAMP, 25, 12, '0', '瑜伽室A', '/static/course/yoga_hatha.jpg', '经典哈他瑜伽,从基础体式入手,配合呼吸引导,帮助初学者建立正确的瑜伽练习基础,感受身心的和谐统一。', 88.00, 'system'
|
SELECT '哈他瑜伽入门', gt.id, '2026-07-26 08:00:00'::TIMESTAMP, '2026-07-26 09:00:00'::TIMESTAMP, 25, 12, '0', '瑜伽室A', '/static/course/yoga_hatha.jpg', '经典哈他瑜伽,从基础体式入手,配合呼吸引导,帮助初学者建立正确的瑜伽练习基础,感受身心的和谐统一。', 88.00, 'system'
|
||||||
FROM group_course_type gt WHERE gt.type_name = '瑜伽';
|
FROM group_course_type gt WHERE gt.type_name = '瑜伽';
|
||||||
|
|
||||||
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
||||||
SELECT '流瑜伽', gt.id, '2026-07-24 10:00:00'::TIMESTAMP, '2026-07-24 11:00:00'::TIMESTAMP, 20, 18, '0', '瑜伽室A', '/static/course/yoga_flow.jpg', '体式之间流畅串联,如行云流水般一气呵成。以拜日式为根基,结合站姿、平衡和扭转,提升力量与柔韧的整合能力。', 98.00, 'system'
|
SELECT '流瑜伽', gt.id, '2026-07-28 10:00:00'::TIMESTAMP, '2026-07-28 11:00:00'::TIMESTAMP, 20, 18, '0', '瑜伽室A', '/static/course/yoga_flow.jpg', '体式之间流畅串联,如行云流水般一气呵成。以拜日式为根基,结合站姿、平衡和扭转,提升力量与柔韧的整合能力。', 98.00, 'system'
|
||||||
FROM group_course_type gt WHERE gt.type_name = '瑜伽';
|
FROM group_course_type gt WHERE gt.type_name = '瑜伽';
|
||||||
|
|
||||||
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
||||||
SELECT '阴瑜伽', gt.id, '2026-07-26 18:00:00'::TIMESTAMP, '2026-07-26 19:15:00'::TIMESTAMP, 20, 8, '0', '瑜伽室B', '/static/course/yoga_yin.jpg', '阴瑜伽以长时间保持体式为特点,深度伸展筋膜和结缔组织,帮助你释放身体深层紧张,缓解一周的工作疲劳。适合所有级别。', 88.00, 'system'
|
SELECT '阴瑜伽', gt.id, '2026-07-30 18:00:00'::TIMESTAMP, '2026-07-30 19:15:00'::TIMESTAMP, 20, 8, '0', '瑜伽室B', '/static/course/yoga_yin.jpg', '阴瑜伽以长时间保持体式为特点,深度伸展筋膜和结缔组织,帮助你释放身体深层紧张,缓解一周的工作疲劳。适合所有级别。', 88.00, 'system'
|
||||||
FROM group_course_type gt WHERE gt.type_name = '瑜伽';
|
FROM group_course_type gt WHERE gt.type_name = '瑜伽';
|
||||||
|
|
||||||
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
||||||
SELECT '阿斯汤加瑜伽', gt.id, '2026-07-23 07:00:00'::TIMESTAMP, '2026-07-23 08:30:00'::TIMESTAMP, 15, 10, '0', '瑜伽室A', '/static/course/yoga_ashtanga.jpg', '阿斯汤加瑜伽以固定的体式序列和独特的呼吸法为核心,节奏紧凑,强度较高,能有效提升力量、柔韧和专注力。建议有一定瑜伽基础者参加。', 128.00, 'system'
|
SELECT '阿斯汤加瑜伽', gt.id, '2026-07-27 07:00:00'::TIMESTAMP, '2026-07-27 08:30:00'::TIMESTAMP, 15, 10, '0', '瑜伽室A', '/static/course/yoga_ashtanga.jpg', '阿斯汤加瑜伽以固定的体式序列和独特的呼吸法为核心,节奏紧凑,强度较高,能有效提升力量、柔韧和专注力。建议有一定瑜伽基础者参加。', 128.00, 'system'
|
||||||
FROM group_course_type gt WHERE gt.type_name = '瑜伽';
|
FROM group_course_type gt WHERE gt.type_name = '瑜伽';
|
||||||
|
|
||||||
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
||||||
SELECT '空中瑜伽', gt.id, '2026-07-25 19:00:00'::TIMESTAMP, '2026-07-25 20:00:00'::TIMESTAMP, 12, 12, '0', '瑜伽室B', '/static/course/yoga_aerial.jpg', '利用悬挂的瑜伽吊床完成各种体式,借助重力让脊柱自然延展,体验"飞翔"般的自由感。深受女性学员喜爱,名额有限请提前预约。', 128.00, 'system'
|
SELECT '空中瑜伽', gt.id, '2026-07-29 19:00:00'::TIMESTAMP, '2026-07-29 20:00:00'::TIMESTAMP, 12, 12, '0', '瑜伽室B', '/static/course/yoga_aerial.jpg', '利用悬挂的瑜伽吊床完成各种体式,借助重力让脊柱自然延展,体验"飞翔"般的自由感。深受女性学员喜爱,名额有限请提前预约。', 128.00, 'system'
|
||||||
FROM group_course_type gt WHERE gt.type_name = '瑜伽';
|
FROM group_course_type gt WHERE gt.type_name = '瑜伽';
|
||||||
|
|
||||||
|
|
||||||
-- ---------- 动感单车课程(5个)----------
|
-- ---------- 动感单车课程(5个)----------
|
||||||
|
|
||||||
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
||||||
SELECT '初级燃脂骑行', gt.id, '2026-07-22 19:00:00'::TIMESTAMP, '2026-07-22 19:45:00'::TIMESTAMP, 30, 22, '0', '单车房', '/static/course/spin_beginner.jpg', '适合新手的入门级骑行课程,教练将带领你掌握正确的骑行姿势和阻力调节技巧。在动感音乐中轻松燃烧300-400卡路里。', 58.00, 'system'
|
SELECT '初级燃脂骑行', gt.id, '2026-07-26 19:00:00'::TIMESTAMP, '2026-07-26 19:45:00'::TIMESTAMP, 30, 22, '0', '单车房', '/static/course/spin_beginner.jpg', '适合新手的入门级骑行课程,教练将带领你掌握正确的骑行姿势和阻力调节技巧。在动感音乐中轻松燃烧300-400卡路里。', 58.00, 'system'
|
||||||
FROM group_course_type gt WHERE gt.type_name = '动感单车';
|
FROM group_course_type gt WHERE gt.type_name = '动感单车';
|
||||||
|
|
||||||
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
||||||
SELECT '节奏爬坡', gt.id, '2026-07-24 18:30:00'::TIMESTAMP, '2026-07-24 19:15:00'::TIMESTAMP, 30, 15, '0', '单车房', '/static/course/spin_climb.jpg', '模拟山路爬坡骑行,通过逐渐增加阻力来挑战你的耐力和意志力。每一段爬坡后都有短暂的平路冲刺,节奏感十足,大汗淋漓的畅快体验。', 68.00, 'system'
|
SELECT '节奏爬坡', gt.id, '2026-07-28 18:30:00'::TIMESTAMP, '2026-07-28 19:15:00'::TIMESTAMP, 30, 15, '0', '单车房', '/static/course/spin_climb.jpg', '模拟山路爬坡骑行,通过逐渐增加阻力来挑战你的耐力和意志力。每一段爬坡后都有短暂的平路冲刺,节奏感十足,大汗淋漓的畅快体验。', 68.00, 'system'
|
||||||
FROM group_course_type gt WHERE gt.type_name = '动感单车';
|
FROM group_course_type gt WHERE gt.type_name = '动感单车';
|
||||||
|
|
||||||
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
||||||
SELECT '极速冲刺', gt.id, '2026-07-26 20:00:00'::TIMESTAMP, '2026-07-26 20:45:00'::TIMESTAMP, 25, 25, '0', '单车房', '/static/course/spin_sprint.jpg', '高强度冲刺为主题的骑行课,多组30秒极限冲刺搭配短暂恢复,彻底引爆你的心肺极限。周五夜间的爆款课程,约满速度极快!', 68.00, 'system'
|
SELECT '极速冲刺', gt.id, '2026-07-30 20:00:00'::TIMESTAMP, '2026-07-30 20:45:00'::TIMESTAMP, 25, 25, '0', '单车房', '/static/course/spin_sprint.jpg', '高强度冲刺为主题的骑行课,多组30秒极限冲刺搭配短暂恢复,彻底引爆你的心肺极限。周五夜间的爆款课程,约满速度极快!', 68.00, 'system'
|
||||||
FROM group_course_type gt WHERE gt.type_name = '动感单车';
|
FROM group_course_type gt WHERE gt.type_name = '动感单车';
|
||||||
|
|
||||||
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
||||||
SELECT '耐力骑行', gt.id, '2026-07-27 10:00:00'::TIMESTAMP, '2026-07-27 11:00:00'::TIMESTAMP, 30, 18, '0', '单车房', '/static/course/spin_endurance.jpg', '60分钟中低强度耐力骑行,适合周末早晨唤醒身体。在稳定的节奏中持续燃脂,配以舒缓的收尾拉伸,开启元气满满的周末。', 58.00, 'system'
|
SELECT '耐力骑行', gt.id, '2026-07-31 10:00:00'::TIMESTAMP, '2026-07-31 11:00:00'::TIMESTAMP, 30, 18, '0', '单车房', '/static/course/spin_endurance.jpg', '60分钟中低强度耐力骑行,适合周末早晨唤醒身体。在稳定的节奏中持续燃脂,配以舒缓的收尾拉伸,开启元气满满的周末。', 58.00, 'system'
|
||||||
FROM group_course_type gt WHERE gt.type_name = '动感单车';
|
FROM group_course_type gt WHERE gt.type_name = '动感单车';
|
||||||
|
|
||||||
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
||||||
SELECT '音乐主题骑行', gt.id, '2026-07-23 19:30:00'::TIMESTAMP, '2026-07-23 20:15:00'::TIMESTAMP, 30, 20, '0', '单车房', '/static/course/spin_music.jpg', '以热门流行音乐为主题的骑行派对!每首歌曲对应一套骑行组合,跟着节拍加速、爬坡、冲刺,在音乐中忘记疲惫。', 68.00, 'system'
|
SELECT '音乐主题骑行', gt.id, '2026-07-27 19:30:00'::TIMESTAMP, '2026-07-27 20:15:00'::TIMESTAMP, 30, 20, '0', '单车房', '/static/course/spin_music.jpg', '以热门流行音乐为主题的骑行派对!每首歌曲对应一套骑行组合,跟着节拍加速、爬坡、冲刺,在音乐中忘记疲惫。', 68.00, 'system'
|
||||||
FROM group_course_type gt WHERE gt.type_name = '动感单车';
|
FROM group_course_type gt WHERE gt.type_name = '动感单车';
|
||||||
|
|
||||||
|
|
||||||
-- ---------- HIIT训练课程(5个)----------
|
-- ---------- HIIT训练课程(5个)----------
|
||||||
|
|
||||||
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
||||||
SELECT 'Tabata燃脂', gt.id, '2026-07-22 07:00:00'::TIMESTAMP, '2026-07-22 07:30:00'::TIMESTAMP, 15, 8, '0', '多功能训练区', '/static/course/hiit_tabata.jpg', '经典的Tabata模式:20秒全力运动 + 10秒休息,循环8轮共4分钟,搭配热身和拉伸,30分钟全身燃爆。早晨空腹训练燃脂效果加倍!', 98.00, 'system'
|
SELECT 'Tabata燃脂', gt.id, '2026-07-26 07:00:00'::TIMESTAMP, '2026-07-26 07:30:00'::TIMESTAMP, 15, 8, '0', '多功能训练区', '/static/course/hiit_tabata.jpg', '经典的Tabata模式:20秒全力运动 + 10秒休息,循环8轮共4分钟,搭配热身和拉伸,30分钟全身燃爆。早晨空腹训练燃脂效果加倍!', 98.00, 'system'
|
||||||
FROM group_course_type gt WHERE gt.type_name = 'HIIT训练';
|
FROM group_course_type gt WHERE gt.type_name = 'HIIT训练';
|
||||||
|
|
||||||
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
||||||
SELECT '全身循环训练', gt.id, '2026-07-24 12:00:00'::TIMESTAMP, '2026-07-24 12:45:00'::TIMESTAMP, 15, 12, '0', '多功能训练区', '/static/course/hiit_circuit.jpg', '午间燃脂利器!8个动作站点轮流进行,涵盖深蹲跳、波比跳、壶铃摇摆、登山跑等,每个动作45秒,休息15秒,3轮循环让你全身湿透。', 98.00, 'system'
|
SELECT '全身循环训练', gt.id, '2026-07-28 12:00:00'::TIMESTAMP, '2026-07-28 12:45:00'::TIMESTAMP, 15, 12, '0', '多功能训练区', '/static/course/hiit_circuit.jpg', '午间燃脂利器!8个动作站点轮流进行,涵盖深蹲跳、波比跳、壶铃摇摆、登山跑等,每个动作45秒,休息15秒,3轮循环让你全身湿透。', 98.00, 'system'
|
||||||
FROM group_course_type gt WHERE gt.type_name = 'HIIT训练';
|
FROM group_course_type gt WHERE gt.type_name = 'HIIT训练';
|
||||||
|
|
||||||
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
||||||
SELECT '核心爆发力', gt.id, '2026-07-26 17:30:00'::TIMESTAMP, '2026-07-26 18:15:00'::TIMESTAMP, 12, 5, '0', '多功能训练区', '/static/course/hiit_core.jpg', '专注于核心肌群的HIIT训练,结合药球砸地、悬垂举腿、俄罗斯转体和平板支撑变式,打造钢铁般的核心力量,提升所有运动表现。', 108.00, 'system'
|
SELECT '核心爆发力', gt.id, '2026-07-30 17:30:00'::TIMESTAMP, '2026-07-30 18:15:00'::TIMESTAMP, 12, 5, '0', '多功能训练区', '/static/course/hiit_core.jpg', '专注于核心肌群的HIIT训练,结合药球砸地、悬垂举腿、俄罗斯转体和平板支撑变式,打造钢铁般的核心力量,提升所有运动表现。', 108.00, 'system'
|
||||||
FROM group_course_type gt WHERE gt.type_name = 'HIIT训练';
|
FROM group_course_type gt WHERE gt.type_name = 'HIIT训练';
|
||||||
|
|
||||||
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
||||||
SELECT '战绳挑战', gt.id, '2026-07-27 08:00:00'::TIMESTAMP, '2026-07-27 08:45:00'::TIMESTAMP, 10, 10, '0', '户外训练区', '/static/course/hiit_battle.jpg', '风靡全球的战绳训练!通过波浪、猛击、旋转等动作模式激活全身肌群,30秒一组高强度间歇,燃脂同时雕刻上肢线条。名额有限,已约满!', 128.00, 'system'
|
SELECT '战绳挑战', gt.id, '2026-07-31 08:00:00'::TIMESTAMP, '2026-07-31 08:45:00'::TIMESTAMP, 10, 10, '0', '户外训练区', '/static/course/hiit_battle.jpg', '风靡全球的战绳训练!通过波浪、猛击、旋转等动作模式激活全身肌群,30秒一组高强度间歇,燃脂同时雕刻上肢线条。名额有限,已约满!', 128.00, 'system'
|
||||||
FROM group_course_type gt WHERE gt.type_name = 'HIIT训练';
|
FROM group_course_type gt WHERE gt.type_name = 'HIIT训练';
|
||||||
|
|
||||||
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
||||||
SELECT '药球HIIT', gt.id, '2026-07-28 10:00:00'::TIMESTAMP, '2026-07-28 10:45:00'::TIMESTAMP, 12, 6, '0', '多功能训练区', '/static/course/hiit_medball.jpg', '以药球为主要工具的HIIT训练。药球砸地、旋转投掷、过头抛接等动作兼具力量和爆发力训练,动作多样不枯燥,周日上午的爆汗之选。', 108.00, 'system'
|
SELECT '药球HIIT', gt.id, '2026-08-01 10:00:00'::TIMESTAMP, '2026-08-01 10:45:00'::TIMESTAMP, 12, 6, '0', '多功能训练区', '/static/course/hiit_medball.jpg', '以药球为主要工具的HIIT训练。药球砸地、旋转投掷、过头抛接等动作兼具力量和爆发力训练,动作多样不枯燥,周日上午的爆汗之选。', 108.00, 'system'
|
||||||
FROM group_course_type gt WHERE gt.type_name = 'HIIT训练';
|
FROM group_course_type gt WHERE gt.type_name = 'HIIT训练';
|
||||||
|
|
||||||
|
|
||||||
-- ---------- 普拉提课程(5个)----------
|
-- ---------- 普拉提课程(5个)----------
|
||||||
|
|
||||||
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
||||||
SELECT '垫上普拉提', gt.id, '2026-07-23 10:00:00'::TIMESTAMP, '2026-07-23 11:00:00'::TIMESTAMP, 20, 14, '0', '普拉提室', '/static/course/pilates_mat.jpg', '经典垫上普拉提,通过精准的动作控制激活深层核心肌群。从呼吸到骨盆稳定,从脊柱逐节卷动到四肢协调,一节课改善你的身体感知。', 108.00, 'system'
|
SELECT '垫上普拉提', gt.id, '2026-07-27 10:00:00'::TIMESTAMP, '2026-07-27 11:00:00'::TIMESTAMP, 20, 14, '0', '普拉提室', '/static/course/pilates_mat.jpg', '经典垫上普拉提,通过精准的动作控制激活深层核心肌群。从呼吸到骨盆稳定,从脊柱逐节卷动到四肢协调,一节课改善你的身体感知。', 108.00, 'system'
|
||||||
FROM group_course_type gt WHERE gt.type_name = '普拉提';
|
FROM group_course_type gt WHERE gt.type_name = '普拉提';
|
||||||
|
|
||||||
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
||||||
SELECT '器械普拉提', gt.id, '2026-07-25 09:00:00'::TIMESTAMP, '2026-07-25 10:00:00'::TIMESTAMP, 10, 8, '0', '普拉提室', '/static/course/pilates_reformer.jpg', '使用专业普拉提核心床(Reformer)进行训练,弹簧阻力提供精准的负荷控制,适合需要针对性改善体态、康复训练或追求高效塑形的学员。', 158.00, 'system'
|
SELECT '器械普拉提', gt.id, '2026-07-29 09:00:00'::TIMESTAMP, '2026-07-29 10:00:00'::TIMESTAMP, 10, 8, '0', '普拉提室', '/static/course/pilates_reformer.jpg', '使用专业普拉提核心床(Reformer)进行训练,弹簧阻力提供精准的负荷控制,适合需要针对性改善体态、康复训练或追求高效塑形的学员。', 158.00, 'system'
|
||||||
FROM group_course_type gt WHERE gt.type_name = '普拉提';
|
FROM group_course_type gt WHERE gt.type_name = '普拉提';
|
||||||
|
|
||||||
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
||||||
SELECT '核心塑形', gt.id, '2026-07-27 14:00:00'::TIMESTAMP, '2026-07-27 15:00:00'::TIMESTAMP, 15, 9, '0', '普拉提室', '/static/course/pilates_sculpt.jpg', '专注于腹部、腰部和臀部的普拉提塑形课程。百次拍击、剪刀腿、肩桥等经典动作持续刺激目标肌群,打造紧致核心线条。', 128.00, 'system'
|
SELECT '核心塑形', gt.id, '2026-07-31 14:00:00'::TIMESTAMP, '2026-07-31 15:00:00'::TIMESTAMP, 15, 9, '0', '普拉提室', '/static/course/pilates_sculpt.jpg', '专注于腹部、腰部和臀部的普拉提塑形课程。百次拍击、剪刀腿、肩桥等经典动作持续刺激目标肌群,打造紧致核心线条。', 128.00, 'system'
|
||||||
FROM group_course_type gt WHERE gt.type_name = '普拉提';
|
FROM group_course_type gt WHERE gt.type_name = '普拉提';
|
||||||
|
|
||||||
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
||||||
SELECT '脊柱健康', gt.id, '2026-07-22 16:00:00'::TIMESTAMP, '2026-07-22 17:00:00'::TIMESTAMP, 15, 6, '0', '普拉提室', '/static/course/pilates_spine.jpg', '专为久坐办公人群设计,通过普拉提脊柱逐节运动改善驼背、圆肩等不良体态。融合猫牛式、脊柱旋转和游泳式,给你的脊柱一次深度保养。', 108.00, 'system'
|
SELECT '脊柱健康', gt.id, '2026-07-26 16:00:00'::TIMESTAMP, '2026-07-26 17:00:00'::TIMESTAMP, 15, 6, '0', '普拉提室', '/static/course/pilates_spine.jpg', '专为久坐办公人群设计,通过普拉提脊柱逐节运动改善驼背、圆肩等不良体态。融合猫牛式、脊柱旋转和游泳式,给你的脊柱一次深度保养。', 108.00, 'system'
|
||||||
FROM group_course_type gt WHERE gt.type_name = '普拉提';
|
FROM group_course_type gt WHERE gt.type_name = '普拉提';
|
||||||
|
|
||||||
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
||||||
SELECT '产后恢复普拉提', gt.id, '2026-07-24 11:00:00'::TIMESTAMP, '2026-07-24 12:00:00'::TIMESTAMP, 10, 4, '0', '普拉提室', '/static/course/pilates_postnatal.jpg', '专为产后妈妈设计的温和修复课程,重点激活盆底肌和腹横肌,循序渐进恢复核心力量。小班教学,教练一对一关注每位学员的姿势质量。', 158.00, 'system'
|
SELECT '产后恢复普拉提', gt.id, '2026-07-28 11:00:00'::TIMESTAMP, '2026-07-28 12:00:00'::TIMESTAMP, 10, 4, '0', '普拉提室', '/static/course/pilates_postnatal.jpg', '专为产后妈妈设计的温和修复课程,重点激活盆底肌和腹横肌,循序渐进恢复核心力量。小班教学,教练一对一关注每位学员的姿势质量。', 158.00, 'system'
|
||||||
FROM group_course_type gt WHERE gt.type_name = '普拉提';
|
FROM group_course_type gt WHERE gt.type_name = '普拉提';
|
||||||
|
|
||||||
|
|
||||||
-- ---------- 搏击操课程(5个)----------
|
-- ---------- 搏击操课程(5个)----------
|
||||||
|
|
||||||
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
||||||
SELECT '有氧搏击基础', gt.id, '2026-07-23 18:00:00'::TIMESTAMP, '2026-07-23 19:00:00'::TIMESTAMP, 25, 20, '0', '操厅A', '/static/course/boxing_basic.jpg', '零基础友好的搏击操入门课。从直拳、摆拳、勾拳到前踢、侧踹,教练分步讲解每个动作的要领,配合动感音乐串联成完整的搏击组合。', 68.00, 'system'
|
SELECT '有氧搏击基础', gt.id, '2026-07-27 18:00:00'::TIMESTAMP, '2026-07-27 19:00:00'::TIMESTAMP, 25, 20, '0', '操厅A', '/static/course/boxing_basic.jpg', '零基础友好的搏击操入门课。从直拳、摆拳、勾拳到前踢、侧踹,教练分步讲解每个动作的要领,配合动感音乐串联成完整的搏击组合。', 68.00, 'system'
|
||||||
FROM group_course_type gt WHERE gt.type_name = '搏击操';
|
FROM group_course_type gt WHERE gt.type_name = '搏击操';
|
||||||
|
|
||||||
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
||||||
SELECT 'Kickboxing进阶', gt.id, '2026-07-25 19:00:00'::TIMESTAMP, '2026-07-25 20:00:00'::TIMESTAMP, 20, 15, '0', '操厅A', '/static/course/boxing_kickboxing.jpg', '融合踢拳技术的进阶搏击课程,增加连击组合、闪躲步法和膝肘技术。强度较基础课明显提升,汗如雨下的同时释放工作压力,宣泄效果一流。', 88.00, 'system'
|
SELECT 'Kickboxing进阶', gt.id, '2026-07-29 19:00:00'::TIMESTAMP, '2026-07-29 20:00:00'::TIMESTAMP, 20, 15, '0', '操厅A', '/static/course/boxing_kickboxing.jpg', '融合踢拳技术的进阶搏击课程,增加连击组合、闪躲步法和膝肘技术。强度较基础课明显提升,汗如雨下的同时释放工作压力,宣泄效果一流。', 88.00, 'system'
|
||||||
FROM group_course_type gt WHERE gt.type_name = '搏击操';
|
FROM group_course_type gt WHERE gt.type_name = '搏击操';
|
||||||
|
|
||||||
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
||||||
SELECT '综合格斗体能', gt.id, '2026-07-27 16:00:00'::TIMESTAMP, '2026-07-27 17:00:00'::TIMESTAMP, 20, 12, '0', '操厅A', '/static/course/boxing_mma.jpg', '融合拳击、泰拳、摔跤元素的综合体能训练。沙袋击打、地面对抗和爆发力训练交替进行,全面提升力量、速度和耐力。周六下午的硬核之选!', 98.00, 'system'
|
SELECT '综合格斗体能', gt.id, '2026-07-31 16:00:00'::TIMESTAMP, '2026-07-31 17:00:00'::TIMESTAMP, 20, 12, '0', '操厅A', '/static/course/boxing_mma.jpg', '融合拳击、泰拳、摔跤元素的综合体能训练。沙袋击打、地面对抗和爆发力训练交替进行,全面提升力量、速度和耐力。周六下午的硬核之选!', 98.00, 'system'
|
||||||
FROM group_course_type gt WHERE gt.type_name = '搏击操';
|
FROM group_course_type gt WHERE gt.type_name = '搏击操';
|
||||||
|
|
||||||
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
||||||
SELECT '燃脂拳击', gt.id, '2026-07-22 12:00:00'::TIMESTAMP, '2026-07-22 13:00:00'::TIMESTAMP, 25, 16, '0', '操厅A', '/static/course/boxing_fatburn.jpg', '午间燃脂拳击课。空击组合与沙袋击打间歇进行,心率维持在高燃脂区间。一节课可消耗500-700卡路里,中午来打拳比喝咖啡更提神!', 68.00, 'system'
|
SELECT '燃脂拳击', gt.id, '2026-07-26 12:00:00'::TIMESTAMP, '2026-07-26 13:00:00'::TIMESTAMP, 25, 16, '0', '操厅A', '/static/course/boxing_fatburn.jpg', '午间燃脂拳击课。空击组合与沙袋击打间歇进行,心率维持在高燃脂区间。一节课可消耗500-700卡路里,中午来打拳比喝咖啡更提神!', 68.00, 'system'
|
||||||
FROM group_course_type gt WHERE gt.type_name = '搏击操';
|
FROM group_course_type gt WHERE gt.type_name = '搏击操';
|
||||||
|
|
||||||
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
|
||||||
SELECT '泰拳基础', gt.id, '2026-07-28 15:00:00'::TIMESTAMP, '2026-07-28 16:00:00'::TIMESTAMP, 15, 7, '0', '操厅A', '/static/course/boxing_muaythai.jpg', '学习泰拳的八肢艺术(双拳、双腿、双膝、双肘),从基本站架到肘膝组合,感受泰拳的刚猛魅力。小班教学保证动作纠正质量。', 98.00, 'system'
|
SELECT '泰拳基础', gt.id, '2026-08-01 15:00:00'::TIMESTAMP, '2026-08-01 16:00:00'::TIMESTAMP, 15, 7, '0', '操厅A', '/static/course/boxing_muaythai.jpg', '学习泰拳的八肢艺术(双拳、双腿、双膝、双肘),从基本站架到肘膝组合,感受泰拳的刚猛魅力。小班教学保证动作纠正质量。', 98.00, 'system'
|
||||||
FROM group_course_type gt WHERE gt.type_name = '搏击操';
|
FROM group_course_type gt WHERE gt.type_name = '搏击操';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- V25: 创建品牌配置表
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 创建 brand_config 表(租户独立品牌配置)
|
||||||
|
CREATE TABLE IF NOT EXISTS brand_config (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
tenant_id VARCHAR(50) NOT NULL,
|
||||||
|
logo_url VARCHAR(512),
|
||||||
|
background_image_url VARCHAR(512),
|
||||||
|
primary_color VARCHAR(20) DEFAULT '#00E676',
|
||||||
|
primary_color_rgb VARCHAR(30),
|
||||||
|
secondary_color VARCHAR(20) DEFAULT '#1A1A1A',
|
||||||
|
secondary_color_rgb VARCHAR(30),
|
||||||
|
font_family VARCHAR(100) DEFAULT 'default',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 创建唯一索引:每个租户只能有一条品牌配置
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_brand_config_tenant_id ON brand_config(tenant_id) WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- 添加注释
|
||||||
|
COMMENT ON TABLE brand_config IS '品牌配置表';
|
||||||
|
COMMENT ON COLUMN brand_config.tenant_id IS '租户ID';
|
||||||
|
COMMENT ON COLUMN brand_config.logo_url IS 'Logo图片URL';
|
||||||
|
COMMENT ON COLUMN brand_config.background_image_url IS '背景图URL';
|
||||||
|
COMMENT ON COLUMN brand_config.primary_color IS '品牌主色调(HEX格式)';
|
||||||
|
COMMENT ON COLUMN brand_config.primary_color_rgb IS '品牌主色调(RGB格式)';
|
||||||
|
COMMENT ON COLUMN brand_config.secondary_color IS '品牌辅助色(HEX格式)';
|
||||||
|
COMMENT ON COLUMN brand_config.secondary_color_rgb IS '品牌辅助色(RGB格式)';
|
||||||
|
COMMENT ON COLUMN brand_config.font_family IS '字体';
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- V26: 新增品牌定制菜单及按钮权限
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 品牌定制菜单(顶级菜单,parent_id=0,排序在轮播图之后)
|
||||||
|
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at)
|
||||||
|
VALUES (29, '品牌定制', 0, 9, 'C', 'brand:config:list', 'brand/index', 1, NOW(), NOW())
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- 品牌定制按钮权限
|
||||||
|
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||||
|
(291, '品牌配置查询', 29, 1, 'F', 'brand:config:query', NULL, 1, NOW(), NOW()),
|
||||||
|
(292, '品牌配色修改', 29, 2, 'F', 'brand:config:edit', NULL, 1, NOW(), NOW()),
|
||||||
|
(293, 'Logo上传', 29, 3, 'F', 'brand:logo:upload', NULL, 1, NOW(), NOW()),
|
||||||
|
(294, '背景图上传', 29, 4, 'F', 'brand:bg:upload', NULL, 1, NOW(), NOW())
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- 为管理员角色(role_id=1)分配品牌定制权限
|
||||||
|
INSERT INTO sys_role_permission (role_id, permission_id)
|
||||||
|
SELECT 1, id FROM sys_permission
|
||||||
|
WHERE permission_code IN ('brand:config:list', 'brand:config:query', 'brand:config:edit', 'brand:logo:upload', 'brand:bg:upload')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_role_permission WHERE role_id = 1 AND permission_id = sys_permission.id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 重置菜单序列
|
||||||
|
SELECT setval('sys_menu_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_menu));
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- V27: 为 brand_config 表添加品牌名和口号字段
|
||||||
|
-- 作者: 张翔
|
||||||
|
-- 日期: 2026-07-23
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
ALTER TABLE brand_config
|
||||||
|
ADD COLUMN IF NOT EXISTS brand_name VARCHAR(100),
|
||||||
|
ADD COLUMN IF NOT EXISTS slogan VARCHAR(200);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN brand_config.brand_name IS '品牌名称';
|
||||||
|
COMMENT ON COLUMN brand_config.slogan IS '品牌口号';
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- 教练时间规则配置表
|
||||||
|
-- 版本: V29
|
||||||
|
-- 描述: 创建教练迟到/缺席时间规则配置表,支持按课程时长区间匹配不同阈值
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS coach_time_rule (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
min_duration INTEGER,
|
||||||
|
max_duration INTEGER,
|
||||||
|
normal_window INTEGER NOT NULL,
|
||||||
|
late_window INTEGER NOT NULL,
|
||||||
|
end_grace INTEGER NOT NULL,
|
||||||
|
is_default BOOLEAN DEFAULT FALSE,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
status CHAR(1) DEFAULT '1',
|
||||||
|
remark VARCHAR(500),
|
||||||
|
create_by VARCHAR(50),
|
||||||
|
update_by VARCHAR(50),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE coach_time_rule IS '教练时间规则配置表';
|
||||||
|
COMMENT ON COLUMN coach_time_rule.id IS '主键ID';
|
||||||
|
COMMENT ON COLUMN coach_time_rule.min_duration IS '课程时长下限(分钟),NULL表示无下限';
|
||||||
|
COMMENT ON COLUMN coach_time_rule.max_duration IS '课程时长上限(分钟),NULL表示无上限';
|
||||||
|
COMMENT ON COLUMN coach_time_rule.normal_window IS '正常开课窗口(分钟),课程开始后此时间内开课为正常';
|
||||||
|
COMMENT ON COLUMN coach_time_rule.late_window IS '迟到/缺席截止窗口(分钟),超过此时间判定为迟到或拒绝开课';
|
||||||
|
COMMENT ON COLUMN coach_time_rule.end_grace IS '结课宽限期(分钟),课程结束后此时间内允许手动结课';
|
||||||
|
COMMENT ON COLUMN coach_time_rule.is_default IS '是否默认规则,无匹配规则时使用';
|
||||||
|
COMMENT ON COLUMN coach_time_rule.sort_order IS '排序优先级';
|
||||||
|
COMMENT ON COLUMN coach_time_rule.status IS '状态(0=停用 1=启用)';
|
||||||
|
COMMENT ON COLUMN coach_time_rule.remark IS '备注';
|
||||||
|
COMMENT ON COLUMN coach_time_rule.create_by IS '创建人';
|
||||||
|
COMMENT ON COLUMN coach_time_rule.update_by IS '更新人';
|
||||||
|
COMMENT ON COLUMN coach_time_rule.created_at IS '创建时间';
|
||||||
|
COMMENT ON COLUMN coach_time_rule.updated_at IS '更新时间';
|
||||||
|
COMMENT ON COLUMN coach_time_rule.deleted_at IS '删除时间(软删除)';
|
||||||
|
|
||||||
|
-- 插入默认规则:等同于原硬编码逻辑(长课时:10分钟正常/30分钟迟到/10分钟结课宽限)
|
||||||
|
INSERT INTO coach_time_rule (min_duration, max_duration, normal_window, late_window, end_grace, is_default, sort_order, status, create_by, update_by, created_at, updated_at)
|
||||||
|
VALUES (NULL, NULL, 10, 30, 10, TRUE, 0, '1', 'system', 'system', NOW(), NOW());
|
||||||
|
|
||||||
|
-- 索引
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_coach_time_rule_status ON coach_time_rule(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_coach_time_rule_is_default ON coach_time_rule(is_default);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_coach_time_rule_deleted_at ON coach_time_rule(deleted_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_coach_time_rule_duration_range ON coach_time_rule(min_duration, max_duration);
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- V30: 教练时间规则配置 - 菜单/权限/角色分配
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 1. 时间规则配置权限
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO sys_permission (permission_name, permission_code, resource, action, description, status, create_by, update_by, created_at, updated_at) VALUES
|
||||||
|
('时间规则查看', 'system:coach:timeRule:view', '/api/coach/time-rules', 'GET', '查看教练时间规则列表', 1, 'system', 'system', NOW(), NOW()),
|
||||||
|
('时间规则创建', 'system:coach:timeRule:create', '/api/coach/time-rules', 'POST', '创建教练时间规则', 1, 'system', 'system', NOW(), NOW()),
|
||||||
|
('时间规则编辑', 'system:coach:timeRule:edit', '/api/coach/time-rules', 'PUT', '编辑教练时间规则', 1, 'system', 'system', NOW(), NOW()),
|
||||||
|
('时间规则删除', 'system:coach:timeRule:delete', '/api/coach/time-rules', 'DELETE', '删除教练时间规则', 1, 'system', 'system', NOW(), NOW());
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 2. 为超级管理员角色分配时间规则权限
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO sys_role_permission (role_id, permission_id, create_by, update_by, created_at, updated_at)
|
||||||
|
SELECT 1, id, 'system', 'system', NOW(), NOW() FROM sys_permission
|
||||||
|
WHERE permission_code LIKE 'system:coach:timeRule:%'
|
||||||
|
AND id NOT IN (SELECT permission_id FROM sys_role_permission WHERE role_id = 1);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 3. 时间规则配置菜单(顶级菜单)
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||||
|
(25, '时间规则配置', 0, 10, 'C', 'system:coach:timeRule:list', 'coach/time-rule/index', 1, NOW(), NOW());
|
||||||
|
|
||||||
|
-- 时间规则配置按钮权限
|
||||||
|
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||||
|
(251, '时间规则查询', 25, 1, 'F', 'system:coach:timeRule:query', NULL, 1, NOW(), NOW()),
|
||||||
|
(252, '时间规则新增', 25, 2, 'F', 'system:coach:timeRule:add', NULL, 1, NOW(), NOW()),
|
||||||
|
(253, '时间规则修改', 25, 3, 'F', 'system:coach:timeRule:edit', NULL, 1, NOW(), NOW()),
|
||||||
|
(254, '时间规则删除', 25, 4, 'F', 'system:coach:timeRule:remove', NULL, 1, NOW(), NOW());
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 4. 重置序列
|
||||||
|
-- ============================================
|
||||||
|
SELECT setval('sys_menu_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_menu));
|
||||||
|
SELECT setval('sys_permission_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_permission));
|
||||||
|
SELECT setval('sys_role_permission_id_seq',(SELECT COALESCE(MAX(id), 1) FROM sys_role_permission));
|
||||||
+41
-4
@@ -4,6 +4,7 @@ import cn.novalon.gym.manage.file.core.domain.SysFile;
|
|||||||
import cn.novalon.gym.manage.file.core.service.ISysFileService;
|
import cn.novalon.gym.manage.file.core.service.ISysFileService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.codec.multipart.FilePart;
|
import org.springframework.http.codec.multipart.FilePart;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -21,9 +22,12 @@ import java.nio.file.Paths;
|
|||||||
public class SysFileHandler {
|
public class SysFileHandler {
|
||||||
|
|
||||||
private final ISysFileService fileService;
|
private final ISysFileService fileService;
|
||||||
|
private final String uploadDir;
|
||||||
|
|
||||||
public SysFileHandler(ISysFileService fileService) {
|
public SysFileHandler(ISysFileService fileService,
|
||||||
|
@Value("${file.upload.dir:/tmp/uploads}") String uploadDir) {
|
||||||
this.fileService = fileService;
|
this.fileService = fileService;
|
||||||
|
this.uploadDir = uploadDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "获取所有文件", description = "获取系统中所有文件列表")
|
@Operation(summary = "获取所有文件", description = "获取系统中所有文件列表")
|
||||||
@@ -88,8 +92,13 @@ public class SysFileHandler {
|
|||||||
@Operation(summary = "根据文件名下载", description = "根据文件名下载文件")
|
@Operation(summary = "根据文件名下载", description = "根据文件名下载文件")
|
||||||
public Mono<ServerResponse> downloadFileByName(ServerRequest request) {
|
public Mono<ServerResponse> downloadFileByName(ServerRequest request) {
|
||||||
String fileName = request.pathVariable("fileName");
|
String fileName = request.pathVariable("fileName");
|
||||||
|
// 支持多段路径(如 brand/logo/xxx.png),提取最后一段作为文件名
|
||||||
|
if (fileName != null && fileName.contains("/")) {
|
||||||
|
fileName = fileName.substring(fileName.lastIndexOf('/') + 1);
|
||||||
|
}
|
||||||
|
final String finalFileName = fileName;
|
||||||
return fileService.getAllFiles()
|
return fileService.getAllFiles()
|
||||||
.filter(file -> file.getFileName().equals(fileName))
|
.filter(file -> file.getFileName().equals(finalFileName))
|
||||||
.next()
|
.next()
|
||||||
.flatMap(file -> {
|
.flatMap(file -> {
|
||||||
try {
|
try {
|
||||||
@@ -127,8 +136,15 @@ public class SysFileHandler {
|
|||||||
@Operation(summary = "根据文件名预览", description = "根据文件名预览文件")
|
@Operation(summary = "根据文件名预览", description = "根据文件名预览文件")
|
||||||
public Mono<ServerResponse> previewFileByName(ServerRequest request) {
|
public Mono<ServerResponse> previewFileByName(ServerRequest request) {
|
||||||
String fileName = request.pathVariable("fileName");
|
String fileName = request.pathVariable("fileName");
|
||||||
|
// 保存原始路径,用于磁盘文件回退(如 brand/logo/xxx.png)
|
||||||
|
final String originalPath = fileName;
|
||||||
|
// 支持多段路径(如 brand/logo/xxx.png),提取最后一段作为文件名
|
||||||
|
if (fileName != null && fileName.contains("/")) {
|
||||||
|
fileName = fileName.substring(fileName.lastIndexOf('/') + 1);
|
||||||
|
}
|
||||||
|
final String finalFileName = fileName;
|
||||||
return fileService.getAllFiles()
|
return fileService.getAllFiles()
|
||||||
.filter(file -> file.getFileName().equals(fileName))
|
.filter(file -> file.getFileName().equals(finalFileName))
|
||||||
.next()
|
.next()
|
||||||
.flatMap(file -> {
|
.flatMap(file -> {
|
||||||
try {
|
try {
|
||||||
@@ -141,7 +157,28 @@ public class SysFileHandler {
|
|||||||
return ServerResponse.notFound().build();
|
return ServerResponse.notFound().build();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.switchIfEmpty(ServerResponse.notFound().build());
|
.switchIfEmpty(Mono.defer(() -> {
|
||||||
|
// 回退:从上传目录直接读取文件(用于品牌图片等未注册 sys_file 的文件)
|
||||||
|
try {
|
||||||
|
Path diskPath = Paths.get(uploadDir, originalPath);
|
||||||
|
if (Files.exists(diskPath) && !Files.isDirectory(diskPath)) {
|
||||||
|
byte[] fileContent = Files.readAllBytes(diskPath);
|
||||||
|
String contentType = "image/" + getFileExtension(originalPath);
|
||||||
|
return ServerResponse.ok()
|
||||||
|
.header("Content-Type", contentType)
|
||||||
|
.bodyValue(fileContent);
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
return ServerResponse.notFound().build();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getFileExtension(String filename) {
|
||||||
|
if (filename == null || !filename.contains(".")) return "jpeg";
|
||||||
|
String ext = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
|
||||||
|
if ("jpg".equals(ext)) return "jpeg";
|
||||||
|
return ext;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "删除文件", description = "删除指定文件")
|
@Operation(summary = "删除文件", description = "删除指定文件")
|
||||||
|
|||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
package cn.novalon.gym.manage.file.core.domain;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class SysFileTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldCreateSysFileWithCorrectProperties() {
|
||||||
|
SysFile file = new SysFile();
|
||||||
|
file.setId(1L);
|
||||||
|
file.setFileName("test.png");
|
||||||
|
file.setFileType("image/png");
|
||||||
|
file.setFileSize(1024L);
|
||||||
|
file.setFilePath("/uploads/test.png");
|
||||||
|
file.setCreatedAt(LocalDateTime.of(2025, 1, 1, 10, 0, 0));
|
||||||
|
|
||||||
|
assertThat(file.getId()).isEqualTo(1L);
|
||||||
|
assertThat(file.getFileName()).isEqualTo("test.png");
|
||||||
|
assertThat(file.getFileType()).isEqualTo("image/png");
|
||||||
|
assertThat(file.getFileSize()).isEqualTo(1024L);
|
||||||
|
assertThat(file.getFilePath()).isEqualTo("/uploads/test.png");
|
||||||
|
assertThat(file.getCreatedAt()).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldHandleNullValues() {
|
||||||
|
SysFile file = new SysFile();
|
||||||
|
|
||||||
|
assertThat(file.getId()).isNull();
|
||||||
|
assertThat(file.getFileName()).isNull();
|
||||||
|
assertThat(file.getFileType()).isNull();
|
||||||
|
assertThat(file.getFileSize()).isNull();
|
||||||
|
assertThat(file.getFilePath()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldSetAndGetAllProperties() {
|
||||||
|
SysFile file = new SysFile();
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
|
||||||
|
file.setId(100L);
|
||||||
|
file.setFileName("document.pdf");
|
||||||
|
file.setFileType("application/pdf");
|
||||||
|
file.setFileSize(20480L);
|
||||||
|
file.setFilePath("/files/2025/document.pdf");
|
||||||
|
file.setCreatedAt(now);
|
||||||
|
file.setDeletedAt(now);
|
||||||
|
|
||||||
|
assertThat(file.getId()).isEqualTo(100L);
|
||||||
|
assertThat(file.getFileSize()).isEqualTo(20480L);
|
||||||
|
assertThat(file.getDeletedAt()).isEqualTo(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -29,7 +29,7 @@ class SysFileHandlerTest {
|
|||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
fileHandler = new SysFileHandler(fileService);
|
fileHandler = new SysFileHandler(fileService, "/tmp/uploads");
|
||||||
testFile = new SysFile();
|
testFile = new SysFile();
|
||||||
testFile.setId(1L);
|
testFile.setId(1L);
|
||||||
testFile.setFileName("test.txt");
|
testFile.setFileName("test.txt");
|
||||||
|
|||||||
+2
@@ -44,11 +44,13 @@ public class JwtAuthenticationFilter extends AbstractGatewayFilterFactory<JwtAut
|
|||||||
|
|
||||||
String username = jwtUtil.getUsernameFromToken(token);
|
String username = jwtUtil.getUsernameFromToken(token);
|
||||||
Long userId = jwtUtil.getUserIdFromToken(token);
|
Long userId = jwtUtil.getUserIdFromToken(token);
|
||||||
|
String tenantId = jwtUtil.getTenantIdFromToken(token);
|
||||||
|
|
||||||
ServerHttpRequest modifiedRequest = request.mutate()
|
ServerHttpRequest modifiedRequest = request.mutate()
|
||||||
.header("X-User-Id", String.valueOf(userId))
|
.header("X-User-Id", String.valueOf(userId))
|
||||||
.header("X-Member-Id", String.valueOf(userId))
|
.header("X-Member-Id", String.valueOf(userId))
|
||||||
.header("X-Username", username)
|
.header("X-Username", username)
|
||||||
|
.header("X-Tenant-Id", tenantId)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
return chain.filter(exchange.mutate().request(modifiedRequest).build());
|
return chain.filter(exchange.mutate().request(modifiedRequest).build());
|
||||||
|
|||||||
+6
@@ -74,6 +74,12 @@ public class JwtUtil {
|
|||||||
return claims.get("userId", Long.class);
|
return claims.get("userId", Long.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getTenantIdFromToken(String token) {
|
||||||
|
Claims claims = parseToken(token);
|
||||||
|
String tenantId = claims.get("tenantId", String.class);
|
||||||
|
return tenantId != null ? tenantId : "default";
|
||||||
|
}
|
||||||
|
|
||||||
public boolean validateToken(String token) {
|
public boolean validateToken(String token) {
|
||||||
try {
|
try {
|
||||||
parseToken(token);
|
parseToken(token);
|
||||||
|
|||||||
+186
@@ -0,0 +1,186 @@
|
|||||||
|
package cn.novalon.gym.manage.notify.core.service.impl;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.notify.core.domain.Banner;
|
||||||
|
import cn.novalon.gym.manage.notify.core.repository.IBannerRepository;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.test.StepVerifier;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class BannerServiceImplTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private IBannerRepository bannerRepository;
|
||||||
|
|
||||||
|
private BannerServiceImpl bannerService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
bannerService = new BannerServiceImpl(bannerRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getAllBanners ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAllBanners_shouldReturnNonDeletedBanners() {
|
||||||
|
Banner banner = createTestBanner(1L, "轮播图1");
|
||||||
|
when(bannerRepository.findByDeletedAtIsNull()).thenReturn(Flux.just(banner));
|
||||||
|
|
||||||
|
Flux<Banner> result = bannerService.getAllBanners();
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNextCount(1)
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(bannerRepository).findByDeletedAtIsNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAllBanners_shouldReturnEmptyWhenNone() {
|
||||||
|
when(bannerRepository.findByDeletedAtIsNull()).thenReturn(Flux.empty());
|
||||||
|
|
||||||
|
Flux<Banner> result = bannerService.getAllBanners();
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getActiveBanners ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getActiveBanners_shouldReturnActiveBanners() {
|
||||||
|
when(bannerRepository.findActiveBanners()).thenReturn(Flux.just(createTestBanner(1L, "活跃")));
|
||||||
|
|
||||||
|
Flux<Banner> result = bannerService.getActiveBanners();
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNextCount(1)
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(bannerRepository).findActiveBanners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getBannerById ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getBannerById_shouldReturnBannerWhenFound() {
|
||||||
|
when(bannerRepository.findById(1L)).thenReturn(Mono.just(createTestBanner(1L, "轮播图1")));
|
||||||
|
|
||||||
|
Mono<Banner> result = bannerService.getBannerById(1L);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNextCount(1)
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getBannerById_shouldReturnEmptyWhenNotFound() {
|
||||||
|
when(bannerRepository.findById(999L)).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
Mono<Banner> result = bannerService.getBannerById(999L);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== createBanner ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createBanner_shouldSaveAndReturn() {
|
||||||
|
Banner banner = createTestBanner(null, "新轮播图");
|
||||||
|
when(bannerRepository.save(any(Banner.class)))
|
||||||
|
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
|
||||||
|
|
||||||
|
Mono<Banner> result = bannerService.createBanner(banner);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNextMatches(b -> b.getTitle().equals("新轮播图") && b.getCreatedAt() != null)
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(bannerRepository).save(banner);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== updateBanner ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateBanner_shouldUpdateAndReturn() {
|
||||||
|
Banner existing = createTestBanner(1L, "旧标题");
|
||||||
|
Banner update = createTestBanner(1L, "新标题");
|
||||||
|
when(bannerRepository.findById(1L)).thenReturn(Mono.just(existing));
|
||||||
|
when(bannerRepository.save(any(Banner.class)))
|
||||||
|
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
|
||||||
|
|
||||||
|
Mono<Banner> result = bannerService.updateBanner(1L, update);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNextMatches(b -> b.getTitle().equals("新标题") && b.getUpdatedAt() != null)
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(bannerRepository).save(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateBanner_shouldReturnEmptyWhenNotFound() {
|
||||||
|
Banner update = createTestBanner(999L, "新标题");
|
||||||
|
when(bannerRepository.findById(999L)).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
Mono<Banner> result = bannerService.updateBanner(999L, update);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(bannerRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== deleteBanner ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteBanner_shouldSoftDelete() {
|
||||||
|
Banner existing = createTestBanner(1L, "轮播图1");
|
||||||
|
when(bannerRepository.findById(1L)).thenReturn(Mono.just(existing));
|
||||||
|
when(bannerRepository.save(any(Banner.class))).thenReturn(Mono.just(existing));
|
||||||
|
|
||||||
|
Mono<Void> result = bannerService.deleteBanner(1L);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(bannerRepository).save(existing);
|
||||||
|
assertThat(existing.getDeletedAt()).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteBanner_shouldCompleteEmptyWhenNotFound() {
|
||||||
|
when(bannerRepository.findById(999L)).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
Mono<Void> result = bannerService.deleteBanner(999L);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== helper ====================
|
||||||
|
|
||||||
|
private Banner createTestBanner(Long id, String title) {
|
||||||
|
Banner banner = new Banner();
|
||||||
|
banner.setId(id);
|
||||||
|
banner.setImageUrl("https://example.com/banner.jpg");
|
||||||
|
banner.setTitle(title);
|
||||||
|
banner.setSubtitle("副标题");
|
||||||
|
banner.setSortOrder(1);
|
||||||
|
banner.setIsActive("1");
|
||||||
|
banner.setCreatedAt(LocalDateTime.now());
|
||||||
|
return banner;
|
||||||
|
}
|
||||||
|
}
|
||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
package cn.novalon.gym.manage.notify.core.service.impl;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.notify.core.domain.SysNotice;
|
||||||
|
import cn.novalon.gym.manage.notify.core.repository.ISysNoticeRepository;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.test.StepVerifier;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class SysNoticeServiceImplTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ISysNoticeRepository noticeRepository;
|
||||||
|
|
||||||
|
private SysNoticeServiceImpl noticeService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
noticeService = new SysNoticeServiceImpl(noticeRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getAllNotices ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAllNotices_shouldReturnNonDeletedNotices() {
|
||||||
|
SysNotice notice = createTestNotice(1L, "公告1");
|
||||||
|
when(noticeRepository.findByDeletedAtIsNull()).thenReturn(Flux.just(notice));
|
||||||
|
|
||||||
|
Flux<SysNotice> result = noticeService.getAllNotices();
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNextMatches(n -> n.getId().equals(1L) && n.getNoticeTitle().equals("公告1"))
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(noticeRepository).findByDeletedAtIsNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAllNotices_shouldReturnEmptyWhenNone() {
|
||||||
|
when(noticeRepository.findByDeletedAtIsNull()).thenReturn(Flux.empty());
|
||||||
|
|
||||||
|
Flux<SysNotice> result = noticeService.getAllNotices();
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getNoticeById ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getNoticeById_shouldReturnNoticeWhenFound() {
|
||||||
|
SysNotice notice = createTestNotice(1L, "公告1");
|
||||||
|
when(noticeRepository.findById(1L)).thenReturn(Mono.just(notice));
|
||||||
|
|
||||||
|
Mono<SysNotice> result = noticeService.getNoticeById(1L);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNextMatches(n -> n.getId().equals(1L))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getNoticeById_shouldReturnEmptyWhenNotFound() {
|
||||||
|
when(noticeRepository.findById(999L)).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
Mono<SysNotice> result = noticeService.getNoticeById(999L);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== createNotice ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createNotice_shouldSaveAndReturnNotice() {
|
||||||
|
SysNotice notice = createTestNotice(null, "新公告");
|
||||||
|
when(noticeRepository.save(any(SysNotice.class)))
|
||||||
|
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
|
||||||
|
|
||||||
|
Mono<SysNotice> result = noticeService.createNotice(notice);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNextMatches(n -> n.getNoticeTitle().equals("新公告"))
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(noticeRepository).save(notice);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== updateNotice ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateNotice_shouldUpdateAndReturn() {
|
||||||
|
SysNotice existing = createTestNotice(1L, "旧标题");
|
||||||
|
SysNotice update = createTestNotice(1L, "新标题");
|
||||||
|
when(noticeRepository.findById(1L)).thenReturn(Mono.just(existing));
|
||||||
|
when(noticeRepository.save(any(SysNotice.class)))
|
||||||
|
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
|
||||||
|
|
||||||
|
Mono<SysNotice> result = noticeService.updateNotice(1L, update);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNextMatches(n -> n.getNoticeTitle().equals("新标题"))
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(noticeRepository).save(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateNotice_shouldReturnEmptyWhenNotFound() {
|
||||||
|
SysNotice update = createTestNotice(999L, "新标题");
|
||||||
|
when(noticeRepository.findById(999L)).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
Mono<SysNotice> result = noticeService.updateNotice(999L, update);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(noticeRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== deleteNotice ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteNotice_shouldSoftDelete() {
|
||||||
|
SysNotice existing = createTestNotice(1L, "公告1");
|
||||||
|
when(noticeRepository.findById(1L)).thenReturn(Mono.just(existing));
|
||||||
|
when(noticeRepository.save(any(SysNotice.class))).thenReturn(Mono.just(existing));
|
||||||
|
|
||||||
|
Mono<Void> result = noticeService.deleteNotice(1L);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(noticeRepository).save(existing);
|
||||||
|
assertThat(existing.getDeletedAt()).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteNotice_shouldCompleteEmptyWhenNotFound() {
|
||||||
|
when(noticeRepository.findById(999L)).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
Mono<Void> result = noticeService.deleteNotice(999L);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(noticeRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== helper ====================
|
||||||
|
|
||||||
|
private SysNotice createTestNotice(Long id, String title) {
|
||||||
|
SysNotice notice = new SysNotice();
|
||||||
|
notice.setId(id);
|
||||||
|
notice.setNoticeTitle(title);
|
||||||
|
notice.setNoticeContent("公告内容");
|
||||||
|
notice.setNoticeType("1");
|
||||||
|
notice.setStatus("1");
|
||||||
|
return notice;
|
||||||
|
}
|
||||||
|
}
|
||||||
+195
@@ -0,0 +1,195 @@
|
|||||||
|
package cn.novalon.gym.manage.notify.core.service.impl;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.notify.core.domain.SysUserMessage;
|
||||||
|
import cn.novalon.gym.manage.notify.core.repository.ISysUserMessageRepository;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.test.StepVerifier;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class SysUserMessageServiceImplTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ISysUserMessageRepository messageRepository;
|
||||||
|
|
||||||
|
private SysUserMessageServiceImpl messageService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
messageService = new SysUserMessageServiceImpl(messageRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getMessagesByUser ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getMessagesByUser_shouldReturnMessages() {
|
||||||
|
List<SysUserMessage> messages = List.of(createTestMessage(1L, 1L, "消息1"), createTestMessage(2L, 1L, "消息2"));
|
||||||
|
when(messageRepository.findByUserIdOrderByCreateTimeDesc(1L)).thenReturn(Flux.fromIterable(messages));
|
||||||
|
|
||||||
|
Flux<SysUserMessage> result = messageService.getMessagesByUser(1L);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNextCount(2)
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getMessagesByUser_shouldReturnEmptyWhenNone() {
|
||||||
|
when(messageRepository.findByUserIdOrderByCreateTimeDesc(999L)).thenReturn(Flux.empty());
|
||||||
|
|
||||||
|
Flux<SysUserMessage> result = messageService.getMessagesByUser(999L);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getUnreadCount ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getUnreadCount_shouldReturnCount() {
|
||||||
|
when(messageRepository.countByUserIdAndIsRead(1L, "0")).thenReturn(Mono.just(5L));
|
||||||
|
|
||||||
|
Mono<Long> result = messageService.getUnreadCount(1L);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNext(5L)
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getUnreadCount_shouldReturnZero() {
|
||||||
|
when(messageRepository.countByUserIdAndIsRead(1L, "0")).thenReturn(Mono.just(0L));
|
||||||
|
|
||||||
|
Mono<Long> result = messageService.getUnreadCount(1L);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNext(0L)
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getUnreadMessages ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getUnreadMessages_shouldReturnUnreadOnly() {
|
||||||
|
when(messageRepository.findByUserIdAndIsReadOrderByCreateTimeDesc(1L, "0"))
|
||||||
|
.thenReturn(Flux.just(createTestMessage(1L, 1L, "未读消息")));
|
||||||
|
|
||||||
|
Flux<SysUserMessage> result = messageService.getUnreadMessages(1L);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNextCount(1)
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== createMessage ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createMessage_shouldSaveAndReturn() {
|
||||||
|
SysUserMessage message = createTestMessage(null, 1L, "新消息");
|
||||||
|
when(messageRepository.save(any(SysUserMessage.class)))
|
||||||
|
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
|
||||||
|
|
||||||
|
Mono<SysUserMessage> result = messageService.createMessage(message);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNextMatches(m -> m.getTitle().equals("新消息"))
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(messageRepository).save(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== markAsRead ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markAsRead_shouldUpdateAndReturnMessage() {
|
||||||
|
SysUserMessage existing = createTestMessage(1L, 1L, "消息");
|
||||||
|
when(messageRepository.findById(1L)).thenReturn(Mono.just(existing));
|
||||||
|
when(messageRepository.save(any(SysUserMessage.class))).thenReturn(Mono.just(existing));
|
||||||
|
|
||||||
|
Mono<SysUserMessage> result = messageService.markAsRead(1L);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNextMatches(m -> m.getIsRead().equals("1"))
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(messageRepository).save(existing);
|
||||||
|
assertThat(existing.getIsRead()).isEqualTo("1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markAsRead_shouldReturnEmptyWhenNotFound() {
|
||||||
|
when(messageRepository.findById(999L)).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
Mono<SysUserMessage> result = messageService.markAsRead(999L);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(messageRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== markAllAsRead ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markAllAsRead_shouldReturnCount() {
|
||||||
|
when(messageRepository.markAllAsReadByUserId(1L)).thenReturn(Mono.just(2L));
|
||||||
|
|
||||||
|
Mono<Long> result = messageService.markAllAsRead(1L);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNext(2L)
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(messageRepository).markAllAsReadByUserId(1L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markAllAsRead_shouldReturnZeroWhenNoUnread() {
|
||||||
|
when(messageRepository.markAllAsReadByUserId(1L)).thenReturn(Mono.just(0L));
|
||||||
|
|
||||||
|
Mono<Long> result = messageService.markAllAsRead(1L);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNext(0L)
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== deleteMessage ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteMessage_shouldDelete() {
|
||||||
|
when(messageRepository.deleteById(1L)).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
Mono<Void> result = messageService.deleteMessage(1L);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
verify(messageRepository).deleteById(1L);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== helper ====================
|
||||||
|
|
||||||
|
private SysUserMessage createTestMessage(Long id, Long userId, String title) {
|
||||||
|
SysUserMessage message = new SysUserMessage();
|
||||||
|
message.setId(id);
|
||||||
|
message.setUserId(userId);
|
||||||
|
message.setTitle(title);
|
||||||
|
message.setContent("测试内容");
|
||||||
|
message.setIsRead("0");
|
||||||
|
message.setCreateTime(LocalDateTime.now());
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
+219
@@ -0,0 +1,219 @@
|
|||||||
|
package cn.novalon.gym.manage.notify.handler;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.notify.core.domain.Banner;
|
||||||
|
import cn.novalon.gym.manage.notify.core.service.IBannerService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.mock.web.reactive.function.server.MockServerRequest;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.test.StepVerifier;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class BannerHandlerTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private IBannerService bannerService;
|
||||||
|
|
||||||
|
private BannerHandler bannerHandler;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
bannerHandler = new BannerHandler(bannerService);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getAllBanners ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAllBanners_shouldReturnOkWithBanners() {
|
||||||
|
List<Banner> banners = List.of(createTestBanner(1L, "轮播图1"), createTestBanner(2L, "轮播图2"));
|
||||||
|
when(bannerService.getAllBanners()).thenReturn(Flux.fromIterable(banners));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder().build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = bannerHandler.getAllBanners(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAllBanners_shouldReturnOkWhenEmpty() {
|
||||||
|
when(bannerService.getAllBanners()).thenReturn(Flux.empty());
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder().build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = bannerHandler.getAllBanners(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getActiveBanners ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getActiveBanners_shouldReturnOkWithActiveBanners() {
|
||||||
|
when(bannerService.getActiveBanners()).thenReturn(Flux.just(createTestBanner(1L, "活跃轮播图")));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder().build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = bannerHandler.getActiveBanners(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getBannerById ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getBannerById_shouldReturnOkWhenFound() {
|
||||||
|
when(bannerService.getBannerById(1L)).thenReturn(Mono.just(createTestBanner(1L, "轮播图1")));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("id", "1")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = bannerHandler.getBannerById(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getBannerById_shouldReturnNotFound() {
|
||||||
|
when(bannerService.getBannerById(999L)).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("id", "999")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = bannerHandler.getBannerById(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.NOT_FOUND))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== createBanner ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createBanner_shouldReturnOkWhenCreated() {
|
||||||
|
Map<String, Object> body = Map.of(
|
||||||
|
"imageUrl", "https://example.com/banner.jpg",
|
||||||
|
"title", "新轮播图",
|
||||||
|
"subtitle", "副标题",
|
||||||
|
"sortOrder", 1,
|
||||||
|
"isActive", true
|
||||||
|
);
|
||||||
|
when(bannerService.createBanner(any(Banner.class))).thenReturn(Mono.just(createTestBanner(1L, "新轮播图")));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.body(Mono.just(body));
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = bannerHandler.createBanner(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== updateBanner ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateBanner_shouldReturnOkWhenUpdated() {
|
||||||
|
Map<String, Object> body = Map.of("title", "更新标题");
|
||||||
|
when(bannerService.updateBanner(eq(1L), any(Banner.class))).thenReturn(Mono.just(createTestBanner(1L, "更新标题")));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("id", "1")
|
||||||
|
.body(Mono.just(body));
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = bannerHandler.updateBanner(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateBanner_shouldReturnNotFound() {
|
||||||
|
Map<String, Object> body = Map.of("title", "更新标题");
|
||||||
|
when(bannerService.updateBanner(eq(999L), any(Banner.class))).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("id", "999")
|
||||||
|
.body(Mono.just(body));
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = bannerHandler.updateBanner(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.NOT_FOUND))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== deleteBanner ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteBanner_shouldReturnNoContent() {
|
||||||
|
Banner banner = createTestBanner(1L, "轮播图1");
|
||||||
|
banner.setDeletedAt(null);
|
||||||
|
when(bannerService.getBannerById(1L)).thenReturn(Mono.just(banner));
|
||||||
|
when(bannerService.deleteBanner(1L)).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("id", "1")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = bannerHandler.deleteBanner(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.NO_CONTENT))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteBanner_shouldReturnNotFound() {
|
||||||
|
when(bannerService.getBannerById(999L)).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("id", "999")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = bannerHandler.deleteBanner(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.NOT_FOUND))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== helper ====================
|
||||||
|
|
||||||
|
private Banner createTestBanner(Long id, String title) {
|
||||||
|
Banner banner = new Banner();
|
||||||
|
banner.setId(id);
|
||||||
|
banner.setImageUrl("https://example.com/banner-" + id + ".jpg");
|
||||||
|
banner.setTitle(title);
|
||||||
|
banner.setSubtitle("副标题");
|
||||||
|
banner.setSortOrder(id.intValue());
|
||||||
|
banner.setIsActive("1");
|
||||||
|
banner.setCreatedAt(LocalDateTime.now());
|
||||||
|
return banner;
|
||||||
|
}
|
||||||
|
}
|
||||||
+228
@@ -0,0 +1,228 @@
|
|||||||
|
package cn.novalon.gym.manage.notify.handler;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.notify.core.domain.SysUserMessage;
|
||||||
|
import cn.novalon.gym.manage.notify.core.service.ISysUserMessageService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.mock.web.reactive.function.server.MockServerRequest;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.test.StepVerifier;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class SysUserMessageHandlerTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ISysUserMessageService messageService;
|
||||||
|
|
||||||
|
private SysUserMessageHandler handler;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
handler = new SysUserMessageHandler(messageService);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getMessagesByUser ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getMessagesByUser_shouldReturnOkWithMessages() {
|
||||||
|
List<SysUserMessage> messages = List.of(createTestMessage(1L, "消息1"), createTestMessage(2L, "消息2"));
|
||||||
|
when(messageService.getMessagesByUser(anyLong())).thenReturn(Flux.fromIterable(messages));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("userId", "1")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = handler.getMessagesByUser(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getMessagesByUser_shouldReturnEmptyListWhenNone() {
|
||||||
|
when(messageService.getMessagesByUser(anyLong())).thenReturn(Flux.empty());
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("userId", "999")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = handler.getMessagesByUser(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getUnreadCount ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getUnreadCount_shouldReturnOkWithCount() {
|
||||||
|
when(messageService.getUnreadCount(anyLong())).thenReturn(Mono.just(5L));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("userId", "1")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = handler.getUnreadCount(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getUnreadCount_shouldReturnZero() {
|
||||||
|
when(messageService.getUnreadCount(anyLong())).thenReturn(Mono.just(0L));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("userId", "1")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = handler.getUnreadCount(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== getUnreadList ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getUnreadList_shouldReturnOk() {
|
||||||
|
when(messageService.getUnreadMessages(anyLong())).thenReturn(Flux.empty());
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("userId", "1")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = handler.getUnreadList(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== createMessage ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createMessage_shouldReturnCreated() {
|
||||||
|
SysUserMessage message = createTestMessage(1L, "新消息");
|
||||||
|
when(messageService.createMessage(any(SysUserMessage.class))).thenReturn(Mono.just(message));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.body(Mono.just(message));
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = handler.createMessage(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== markAsRead ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markAsRead_shouldReturnOk() {
|
||||||
|
SysUserMessage message = createTestMessage(1L, "消息");
|
||||||
|
when(messageService.markAsRead(anyLong())).thenReturn(Mono.just(message));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("id", "1")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = handler.markAsRead(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markAsRead_shouldReturnNotFound() {
|
||||||
|
when(messageService.markAsRead(anyLong())).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("id", "999")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = handler.markAsRead(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.NOT_FOUND))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== markAllAsRead ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markAllAsRead_shouldReturnOk() {
|
||||||
|
when(messageService.markAllAsRead(anyLong())).thenReturn(Mono.just(3L));
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("userId", "1")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = handler.markAllAsRead(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== deleteMessage ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteMessage_shouldReturnOk() {
|
||||||
|
when(messageService.deleteMessage(anyLong())).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("id", "1")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = handler.deleteMessage(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteMessage_shouldReturnNotFound() {
|
||||||
|
when(messageService.deleteMessage(anyLong())).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
MockServerRequest request = MockServerRequest.builder()
|
||||||
|
.pathVariable("id", "999")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Mono<ServerResponse> result = handler.deleteMessage(request);
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== helper ====================
|
||||||
|
|
||||||
|
private SysUserMessage createTestMessage(Long id, String title) {
|
||||||
|
SysUserMessage message = new SysUserMessage();
|
||||||
|
message.setId(id);
|
||||||
|
message.setUserId(1L);
|
||||||
|
message.setTitle(title);
|
||||||
|
message.setContent("测试内容-" + id);
|
||||||
|
message.setIsRead("0");
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -67,7 +67,8 @@ public class SecurityConfig {
|
|||||||
.pathMatchers("/api/payment/notify").permitAll()
|
.pathMatchers("/api/payment/notify").permitAll()
|
||||||
.pathMatchers("/api/payment/refund").permitAll()
|
.pathMatchers("/api/payment/refund").permitAll()
|
||||||
.pathMatchers("/api/files/**").permitAll()
|
.pathMatchers("/api/files/**").permitAll()
|
||||||
.pathMatchers("/api/coach/**").permitAll();
|
.pathMatchers("/api/coach/**").permitAll()
|
||||||
|
.pathMatchers("/api/brand").permitAll();
|
||||||
|
|
||||||
if (isDevOrTest) {
|
if (isDevOrTest) {
|
||||||
spec.pathMatchers("/swagger-ui.html").permitAll()
|
spec.pathMatchers("/swagger-ui.html").permitAll()
|
||||||
|
|||||||
+12
@@ -31,10 +31,16 @@ public class JwtTokenProvider {
|
|||||||
return Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8));
|
return Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认租户ID,后续多租户化时替换为从用户上下文获取
|
||||||
|
*/
|
||||||
|
public static final String DEFAULT_TENANT_ID = "default";
|
||||||
|
|
||||||
public String generateToken(String username, Long userId) {
|
public String generateToken(String username, Long userId) {
|
||||||
Map<String, Object> claims = new HashMap<>();
|
Map<String, Object> claims = new HashMap<>();
|
||||||
claims.put("userId", userId);
|
claims.put("userId", userId);
|
||||||
claims.put("username", username);
|
claims.put("username", username);
|
||||||
|
claims.put("tenantId", DEFAULT_TENANT_ID);
|
||||||
|
|
||||||
return Jwts.builder()
|
return Jwts.builder()
|
||||||
.setClaims(claims)
|
.setClaims(claims)
|
||||||
@@ -50,6 +56,7 @@ public class JwtTokenProvider {
|
|||||||
claims.put("userId", userId);
|
claims.put("userId", userId);
|
||||||
claims.put("username", username);
|
claims.put("username", username);
|
||||||
claims.put("roles", roles);
|
claims.put("roles", roles);
|
||||||
|
claims.put("tenantId", DEFAULT_TENANT_ID);
|
||||||
|
|
||||||
return Jwts.builder()
|
return Jwts.builder()
|
||||||
.setClaims(claims)
|
.setClaims(claims)
|
||||||
@@ -76,6 +83,11 @@ public class JwtTokenProvider {
|
|||||||
return getClaimsFromToken(token).get("userId", Long.class);
|
return getClaimsFromToken(token).get("userId", Long.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getTenantIdFromToken(String token) {
|
||||||
|
String tenantId = getClaimsFromToken(token).get("tenantId", String.class);
|
||||||
|
return tenantId != null ? tenantId : DEFAULT_TENANT_ID;
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public java.util.List<String> getRolesFromToken(String token) {
|
public java.util.List<String> getRolesFromToken(String token) {
|
||||||
Object roles = getClaimsFromToken(token).get("roles");
|
Object roles = getClaimsFromToken(token).get("roles");
|
||||||
|
|||||||
@@ -29,4 +29,32 @@ public class AuthUtil {
|
|||||||
if (jwtTokenProvider.getUserIdFromToken(token) <= 0L) throw new IllegalArgumentException("ID无效");
|
if (jwtTokenProvider.getUserIdFromToken(token) <= 0L) throw new IllegalArgumentException("ID无效");
|
||||||
return jwtTokenProvider.getUserIdFromToken(token);
|
return jwtTokenProvider.getUserIdFromToken(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 JWT Token 中提取当前用户的租户ID
|
||||||
|
*/
|
||||||
|
public String getTenantIdOrThrow(ServerRequest request) {
|
||||||
|
String tenantId = getTenantId(request);
|
||||||
|
if (tenantId == null || tenantId.isBlank()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "无法获取租户ID");
|
||||||
|
}
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 JWT Token 或 Gateway Header 中提取租户ID(不抛异常)
|
||||||
|
*/
|
||||||
|
public String getTenantId(ServerRequest request) {
|
||||||
|
// 优先从 Gateway 转发的 Header 获取
|
||||||
|
String headerTenantId = request.headers().firstHeader("X-Tenant-Id");
|
||||||
|
if (headerTenantId != null && !headerTenantId.isBlank()) {
|
||||||
|
return headerTenantId;
|
||||||
|
}
|
||||||
|
// 回退到 JWT Token 中提取
|
||||||
|
String token = extractToken(request);
|
||||||
|
if (token != null && jwtTokenProvider.validateToken(token)) {
|
||||||
|
return jwtTokenProvider.getTenantIdFromToken(token);
|
||||||
|
}
|
||||||
|
return JwtTokenProvider.DEFAULT_TENANT_ID;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -49,6 +49,8 @@
|
|||||||
<module>gym-auth</module>
|
<module>gym-auth</module>
|
||||||
<module>gym-payment</module>
|
<module>gym-payment</module>
|
||||||
<module>gym-coach</module>
|
<module>gym-coach</module>
|
||||||
|
<module>gym-coach-config</module>
|
||||||
|
<module>gym-brand</module>
|
||||||
</modules>
|
</modules>
|
||||||
|
|
||||||
<dependencyManagement>
|
<dependencyManagement>
|
||||||
@@ -219,6 +221,12 @@
|
|||||||
<artifactId>commons-compress</artifactId>
|
<artifactId>commons-compress</artifactId>
|
||||||
<version>1.21</version>
|
<version>1.21</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- Aliyun OSS SDK -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.aliyun.oss</groupId>
|
||||||
|
<artifactId>aliyun-sdk-oss</artifactId>
|
||||||
|
<version>3.17.4</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</dependencyManagement>
|
</dependencyManagement>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
<script>
|
<script>
|
||||||
|
const brandStore = require('./store/brand')
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
onLaunch: function() {
|
onLaunch: function() {
|
||||||
console.log('Coach App Launch')
|
console.log('Coach App Launch')
|
||||||
|
brandStore.fetchConfig()
|
||||||
},
|
},
|
||||||
onShow: function() {
|
onShow: function() {
|
||||||
console.log('Coach App Show')
|
console.log('Coach App Show')
|
||||||
|
brandStore.fetchConfig()
|
||||||
},
|
},
|
||||||
onHide: function() {
|
onHide: function() {
|
||||||
console.log('Coach App Hide')
|
console.log('Coach App Hide')
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const http = require('../utils/request')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
/**
|
||||||
|
* 获取品牌配置(需 JWT 登录态,自动按 tenantId 返回对应租户配置)
|
||||||
|
* @returns {Promise<Object>} 品牌配置对象
|
||||||
|
*/
|
||||||
|
getBrandConfig() {
|
||||||
|
return http.get('/brand')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -125,6 +125,52 @@ async function getCourseBookings(courseId, token) {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 品牌定制 API ──
|
||||||
|
|
||||||
|
/** 获取品牌配置 */
|
||||||
|
async function getBrandConfig(token) {
|
||||||
|
console.log('[API] 获取品牌配置...');
|
||||||
|
const res = await makeRequest('GET', `${API_BASE}/brand`, null, token);
|
||||||
|
console.log(`[API] 品牌配置: status=${res.status}`);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 违规记录 API ──
|
||||||
|
|
||||||
|
/** 获取所有教练违规统计 */
|
||||||
|
async function getViolationCounts(token) {
|
||||||
|
console.log('[API] 获取违规统计...');
|
||||||
|
const res = await makeRequest('GET', `${API_BASE}/coach/violation-counts`, null, token);
|
||||||
|
console.log(`[API] 违规统计: status=${res.status}`);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取指定教练的违规记录 */
|
||||||
|
async function getCoachViolations(coachId, token) {
|
||||||
|
console.log(`[API] 获取教练违规: coachId=${coachId}`);
|
||||||
|
const res = await makeRequest('GET', `${API_BASE}/coach/${coachId}/violations`, null, token);
|
||||||
|
console.log(`[API] 违规记录: status=${res.status}`);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 通知/消息 API ──
|
||||||
|
|
||||||
|
/** 获取活跃Banner列表 */
|
||||||
|
async function getActiveBanners(token) {
|
||||||
|
console.log('[API] 获取活跃Banners...');
|
||||||
|
const res = await makeRequest('GET', `${API_BASE}/banners/active`, null, token);
|
||||||
|
console.log(`[API] Banners: status=${res.status}`);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取系统通知列表 */
|
||||||
|
async function getSystemNotices(token) {
|
||||||
|
console.log('[API] 获取系统通知...');
|
||||||
|
const res = await makeRequest('GET', `${API_BASE}/notices`, null, token);
|
||||||
|
console.log(`[API] 通知列表: status=${res.status}`);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
makeRequest,
|
makeRequest,
|
||||||
coachLogin,
|
coachLogin,
|
||||||
@@ -133,6 +179,11 @@ module.exports = {
|
|||||||
startCourse,
|
startCourse,
|
||||||
endCourse,
|
endCourse,
|
||||||
getCourseBookings,
|
getCourseBookings,
|
||||||
|
getBrandConfig,
|
||||||
|
getViolationCounts,
|
||||||
|
getCoachViolations,
|
||||||
|
getActiveBanners,
|
||||||
|
getSystemNotices,
|
||||||
isSuccess,
|
isSuccess,
|
||||||
BASE_URL,
|
BASE_URL,
|
||||||
API_BASE
|
API_BASE
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* P3-API - 教练端品牌定制 & 通知消息 & 违规记录 API 测试
|
||||||
|
* 流程:教练登录 → 获取品牌配置 → 获取Banner/通知 → 获取违规记录
|
||||||
|
*
|
||||||
|
* 使用 HTTP API(HMAC-SHA256 签名 + JWT Token)
|
||||||
|
* 前置条件:后端服务 http://192.168.110.64:8084 已启动
|
||||||
|
*/
|
||||||
|
const api = require('../helpers/api-helper');
|
||||||
|
|
||||||
|
const COACH_USERNAME = 'coach_zhang';
|
||||||
|
const COACH_PASSWORD = 'Test@123';
|
||||||
|
|
||||||
|
let authToken = null;
|
||||||
|
let coachId = null;
|
||||||
|
|
||||||
|
describe('P3-API - 教练端品牌定制 & 通知 & 违规记录', () => {
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
// 步骤0:教练登录
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
beforeAll(async () => {
|
||||||
|
console.log('════════════════════════════════════════════');
|
||||||
|
console.log('P3-API - 教练端品牌定制 & 通知 & 违规记录');
|
||||||
|
console.log('════════════════════════════════════════════');
|
||||||
|
|
||||||
|
const result = await api.coachLogin(COACH_USERNAME, COACH_PASSWORD);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result.token).toBeDefined();
|
||||||
|
authToken = result.token;
|
||||||
|
coachId = result.userId;
|
||||||
|
console.log(`[P3-API] 教练登录成功: userId=${coachId}`);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
// 步骤1:品牌定制配置
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
describe('品牌定制配置', () => {
|
||||||
|
test('TC-COACH-BRAND-001: 获取品牌配置', async () => {
|
||||||
|
const res = await api.getBrandConfig(authToken);
|
||||||
|
console.log(`[TC-COACH-BRAND-001] status=${res.status}`);
|
||||||
|
|
||||||
|
if (api.isSuccess(res)) {
|
||||||
|
const brand = res.data?.data || res.data || {};
|
||||||
|
console.log(` 品牌名称: ${brand.brandName || '(未设置)'}`);
|
||||||
|
console.log(` 品牌口号: ${brand.slogan || '(未设置)'}`);
|
||||||
|
console.log(` 主色调: ${brand.primaryColor || '(默认)'}`);
|
||||||
|
console.log(` 辅助色: ${brand.secondaryColor || '(默认)'}`);
|
||||||
|
|
||||||
|
expect(brand).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-COACH-BRAND-002: 品牌颜色为合法HEX值', async () => {
|
||||||
|
const res = await api.getBrandConfig(authToken);
|
||||||
|
if (!api.isSuccess(res)) {
|
||||||
|
console.warn('[TC-COACH-BRAND-002] 获取失败,跳过');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const brand = res.data?.data || res.data || {};
|
||||||
|
if (brand.primaryColor) {
|
||||||
|
expect(/^#[0-9A-Fa-f]{6,8}$/.test(brand.primaryColor)).toBe(true);
|
||||||
|
console.log(` 主色调 HEX 合法: ${brand.primaryColor}`);
|
||||||
|
}
|
||||||
|
if (brand.secondaryColor) {
|
||||||
|
expect(/^#[0-9A-Fa-f]{6,8}$/.test(brand.secondaryColor)).toBe(true);
|
||||||
|
console.log(` 辅助色 HEX 合法: ${brand.secondaryColor}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
// 步骤2:违规记录
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
describe('违规记录', () => {
|
||||||
|
test('TC-VIOLATION-001: 获取所有教练违规统计', async () => {
|
||||||
|
const res = await api.getViolationCounts(authToken);
|
||||||
|
console.log(`[TC-VIOLATION-001] status=${res.status}`);
|
||||||
|
|
||||||
|
if (api.isSuccess(res)) {
|
||||||
|
const counts = res.data?.data || res.data || [];
|
||||||
|
console.log(` 违规统计条目数: ${Array.isArray(counts) ? counts.length : 'N/A'}`);
|
||||||
|
|
||||||
|
if (Array.isArray(counts) && counts.length > 0) {
|
||||||
|
counts.slice(0, 3).forEach(c => {
|
||||||
|
console.log(` - coachName=${c.coachName || c.name}, violations=${c.violationCount || c.count}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-VIOLATION-002: 获取当前教练违规记录', async () => {
|
||||||
|
const res = await api.getCoachViolations(coachId, authToken);
|
||||||
|
console.log(`[TC-VIOLATION-002] status=${res.status}`);
|
||||||
|
|
||||||
|
if (api.isSuccess(res)) {
|
||||||
|
const violations = res.data?.data || res.data || [];
|
||||||
|
const records = Array.isArray(violations) ? violations : (violations.content || violations.records || []);
|
||||||
|
console.log(` 教练 ${coachId} 违规记录数: ${records.length}`);
|
||||||
|
|
||||||
|
if (records.length > 0) {
|
||||||
|
records.slice(0, 3).forEach(v => {
|
||||||
|
console.log(` - type=${v.type || v.violationType}, time=${v.time || v.createTime}, desc=${(v.description || '').substring(0, 40)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-VIOLATION-003: 不存在教练ID应返回空', async () => {
|
||||||
|
const res = await api.getCoachViolations(99999, authToken);
|
||||||
|
console.log(`[TC-VIOLATION-003] 不存在教练: status=${res.status}`);
|
||||||
|
|
||||||
|
if (api.isSuccess(res)) {
|
||||||
|
const violations = res.data?.data || res.data || [];
|
||||||
|
const records = Array.isArray(violations) ? violations : [];
|
||||||
|
expect(records.length).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
// 步骤3:Banner & 系统通知
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
describe('Banner & 系统通知', () => {
|
||||||
|
test('TC-COACH-NOTIFY-001: 获取活跃Banner', async () => {
|
||||||
|
const res = await api.getActiveBanners(authToken);
|
||||||
|
console.log(`[TC-COACH-NOTIFY-001] status=${res.status}`);
|
||||||
|
|
||||||
|
if (api.isSuccess(res)) {
|
||||||
|
const banners = res.data?.data || res.data || [];
|
||||||
|
const records = Array.isArray(banners) ? banners : (banners.records || []);
|
||||||
|
console.log(` 活跃Banner数量: ${records.length}`);
|
||||||
|
expect(records.length).toBeGreaterThanOrEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-COACH-NOTIFY-002: 获取系统通知', async () => {
|
||||||
|
const res = await api.getSystemNotices(authToken);
|
||||||
|
console.log(`[TC-COACH-NOTIFY-002] status=${res.status}`);
|
||||||
|
|
||||||
|
if (api.isSuccess(res)) {
|
||||||
|
const notices = res.data?.data || res.data || [];
|
||||||
|
const records = Array.isArray(notices) ? notices : (notices.records || []);
|
||||||
|
console.log(` 系统通知数量: ${records.length}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
// 步骤4:总结
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
describe('总结', () => {
|
||||||
|
test('TC-P3-COACH-SUMMARY: 教练端品牌/通知/违规测试汇总', () => {
|
||||||
|
console.log('');
|
||||||
|
console.log('════════════════════════════════════════════');
|
||||||
|
console.log('P3-API - 教练端品牌 & 通知 & 违规记录测试结束');
|
||||||
|
console.log('════════════════════════════════════════════');
|
||||||
|
console.log(' 已测试:');
|
||||||
|
console.log(' 1. 品牌配置 (名称/颜色)');
|
||||||
|
console.log(' 2. 所有教练违规统计');
|
||||||
|
console.log(' 3. 当前教练违规记录');
|
||||||
|
console.log(' 4. 不存在教练违规边界');
|
||||||
|
console.log(' 5. 活跃Banner列表');
|
||||||
|
console.log(' 6. 系统通知列表');
|
||||||
|
console.log('════════════════════════════════════════════');
|
||||||
|
|
||||||
|
expect(coachId).toBeDefined();
|
||||||
|
expect(authToken).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="detail-page">
|
<view class="detail-page">
|
||||||
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
|
<view class="header-wrap" :style="headerStyle">
|
||||||
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
|
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
|
||||||
<text class="back-btn" @click="goBack">←</text>
|
<text class="back-btn" @click="goBack">←</text>
|
||||||
<text class="nav-title">课程详情</text>
|
<text class="nav-title">课程详情</text>
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<view class="cover-area">
|
<view class="cover-area">
|
||||||
<image v-if="course.coverImage && !loading" class="cover-img" :src="course.coverImage" mode="aspectFill"
|
<image v-if="course.coverImage && !loading" class="cover-img" :src="course.coverImage" mode="aspectFill"
|
||||||
@error="course.coverError = true" />
|
@error="course.coverError = true" />
|
||||||
<view v-if="!course.coverImage || course.coverError || loading" class="cover-bg">
|
<view v-if="!course.coverImage || course.coverError || loading" class="cover-bg" :style="{ background: coverGradient }">
|
||||||
<text class="cover-text">{{ (course.typeName ? course.typeName.slice(0, 2) : '团课') }}</text>
|
<text class="cover-text">{{ (course.typeName ? course.typeName.slice(0, 2) : '团课') }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="cover-status" :class="courseStatusClass" v-if="!loading">
|
<view class="cover-status" :class="courseStatusClass" v-if="!loading">
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="info-item" v-if="course.storedValueAmount">
|
<view class="info-item" v-if="course.storedValueAmount">
|
||||||
<text class="info-label">消耗</text>
|
<text class="info-label">消耗</text>
|
||||||
<text class="info-value price-text">¥{{ course.storedValueAmount }}</text>
|
<text class="info-value price-text" :style="{ color: brandConfig.primaryColor }">¥{{ course.storedValueAmount }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -63,6 +63,7 @@
|
|||||||
v-if="course.status == 0"
|
v-if="course.status == 0"
|
||||||
class="action-btn start-btn"
|
class="action-btn start-btn"
|
||||||
:class="{ disabled: !canStartCourse }"
|
:class="{ disabled: !canStartCourse }"
|
||||||
|
:style="startBtnStyle"
|
||||||
:loading="actionLoading"
|
:loading="actionLoading"
|
||||||
:disabled="actionLoading || !canStartCourse"
|
:disabled="actionLoading || !canStartCourse"
|
||||||
@click="handleStartCourse"
|
@click="handleStartCourse"
|
||||||
@@ -100,6 +101,7 @@
|
|||||||
<script>
|
<script>
|
||||||
const coachApi = require('../../api/coach')
|
const coachApi = require('../../api/coach')
|
||||||
const store = require('../../store/index')
|
const store = require('../../store/index')
|
||||||
|
const brandStore = require('../../store/brand')
|
||||||
const { resolveCoverUrl } = require('../../utils/request')
|
const { resolveCoverUrl } = require('../../utils/request')
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -113,11 +115,18 @@
|
|||||||
actionLoading: false,
|
actionLoading: false,
|
||||||
course: {},
|
course: {},
|
||||||
now: Date.now(),
|
now: Date.now(),
|
||||||
realMemberCount: 0
|
realMemberCount: 0,
|
||||||
|
brandConfig: brandStore.config
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
duration() {
|
headerStyle() { return 'padding-top:' + this.statusBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor },
|
||||||
|
startBtnStyle() {
|
||||||
|
if (!this.canStartCourse) return ''
|
||||||
|
return 'background-color:' + this.brandConfig.primaryColor + ';color:' + this.brandConfig.secondaryColor
|
||||||
|
},
|
||||||
|
coverGradient() { return 'linear-gradient(135deg, ' + this.brandConfig.primaryColor + ', #00BFA5)' },
|
||||||
|
duration() {
|
||||||
if (!this.course.startTime || !this.course.endTime) return '--'
|
if (!this.course.startTime || !this.course.endTime) return '--'
|
||||||
const start = new Date(this.course.startTime).getTime()
|
const start = new Date(this.course.startTime).getTime()
|
||||||
const end = new Date(this.course.endTime).getTime()
|
const end = new Date(this.course.endTime).getTime()
|
||||||
@@ -158,6 +167,11 @@
|
|||||||
this.courseId = options.id
|
this.courseId = options.id
|
||||||
this.loadDetail()
|
this.loadDetail()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uni.$on('brand:updated', (config) => { this.brandConfig = config })
|
||||||
|
},
|
||||||
|
onUnload() {
|
||||||
|
uni.$off('brand:updated')
|
||||||
},
|
},
|
||||||
onShow() {
|
onShow() {
|
||||||
if (!store.isLoggedIn) {
|
if (!store.isLoggedIn) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="course-list-page">
|
<view class="course-list-page">
|
||||||
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
|
<view class="header-wrap" :style="headerStyle">
|
||||||
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
|
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
|
||||||
<text class="back-btn" @click="goBack">←</text>
|
<text class="back-btn" @click="goBack">←</text>
|
||||||
<text class="nav-title">我的团课</text>
|
<text class="nav-title">我的团课</text>
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
:key="idx"
|
:key="idx"
|
||||||
class="tab-item"
|
class="tab-item"
|
||||||
:class="{ active: currentTab === tab.value }"
|
:class="{ active: currentTab === tab.value }"
|
||||||
|
:style="currentTab === tab.value ? tabActiveStyle : ''"
|
||||||
@click="switchTab(tab.value)"
|
@click="switchTab(tab.value)"
|
||||||
>
|
>
|
||||||
<text>{{ tab.label }}</text>
|
<text>{{ tab.label }}</text>
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
<view v-for="course in filteredCourses" :key="course.id" class="course-card" :class="{ grayed: !course._isToday }" @click="goToDetail(course.id)">
|
<view v-for="course in filteredCourses" :key="course.id" class="course-card" :class="{ grayed: !course._isToday }" @click="goToDetail(course.id)">
|
||||||
<view class="card-header">
|
<view class="card-header">
|
||||||
<text class="course-name">{{ course.courseName }}</text>
|
<text class="course-name">{{ course.courseName }}</text>
|
||||||
<view class="status-tag" :class="course._statusClass">
|
<view class="status-tag" :class="course._statusClass" :style="course._statusClass === 'normal' ? statusNormalStyle : ''">
|
||||||
<text>{{ course._statusLabel }}</text>
|
<text>{{ course._statusLabel }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -62,6 +63,7 @@
|
|||||||
<script>
|
<script>
|
||||||
const coachApi = require('../../api/coach')
|
const coachApi = require('../../api/coach')
|
||||||
const store = require('../../store/index')
|
const store = require('../../store/index')
|
||||||
|
const brandStore = require('../../store/brand')
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
@@ -78,10 +80,14 @@
|
|||||||
{ label: '待开课', value: 'pending' },
|
{ label: '待开课', value: 'pending' },
|
||||||
{ label: '进行中', value: 'in_progress' },
|
{ label: '进行中', value: 'in_progress' },
|
||||||
{ label: '已结束', value: 'ended' }
|
{ label: '已结束', value: 'ended' }
|
||||||
]
|
],
|
||||||
|
brandConfig: brandStore.config
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
headerStyle() { return 'padding-top:' + this.statusBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor },
|
||||||
|
tabActiveStyle() { return 'background-color:' + this.brandConfig.primaryColor + ';color:' + this.brandConfig.secondaryColor },
|
||||||
|
statusNormalStyle() { return 'background-color:rgba(' + this.brandConfig.primaryColorRgb + ',0.15);color:' + this.brandConfig.primaryColor },
|
||||||
filteredCourses() {
|
filteredCourses() {
|
||||||
switch (this.currentTab) {
|
switch (this.currentTab) {
|
||||||
case 'pending':
|
case 'pending':
|
||||||
@@ -109,14 +115,24 @@
|
|||||||
this.navBarHeight = navBarHeight
|
this.navBarHeight = navBarHeight
|
||||||
// 状态栏 + 导航栏 + tab行高度
|
// 状态栏 + 导航栏 + tab行高度
|
||||||
this.totalHeaderHeight = statusBarHeight + navBarHeight + 44
|
this.totalHeaderHeight = statusBarHeight + navBarHeight + 44
|
||||||
|
|
||||||
|
uni.$on('brand:updated', (config) => { this.brandConfig = config })
|
||||||
|
},
|
||||||
|
onUnload() {
|
||||||
|
uni.$off('brand:updated')
|
||||||
},
|
},
|
||||||
onShow() {
|
onShow() {
|
||||||
|
brandStore.fetchConfig()
|
||||||
if (!store.isLoggedIn) {
|
if (!store.isLoggedIn) {
|
||||||
uni.reLaunch({ url: '/pages/login/login' })
|
uni.reLaunch({ url: '/pages/login/login' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.loadCourses()
|
this.loadCourses()
|
||||||
},
|
},
|
||||||
|
onPullDownRefresh() {
|
||||||
|
brandStore.fetchConfig()
|
||||||
|
this.loadCourses().finally(() => { uni.stopPullDownRefresh() })
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
goBack() { uni.navigateBack() },
|
goBack() { uni.navigateBack() },
|
||||||
goToDetail(id) {
|
goToDetail(id) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="home-page">
|
<view class="home-page">
|
||||||
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
|
<view class="header-wrap" :style="headerStyle">
|
||||||
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
|
<view class="nav-bar" :style="navStyle">
|
||||||
<text class="nav-title">◆ Novalon 教练端</text>
|
<text class="nav-title">◆ Novalon 教练端</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -9,10 +9,10 @@
|
|||||||
<scroll-view class="content" scroll-y :style="{ height: 'calc(100vh - ' + totalHeaderHeight + 'px)' }">
|
<scroll-view class="content" scroll-y :style="{ height: 'calc(100vh - ' + totalHeaderHeight + 'px)' }">
|
||||||
<view class="content-inner">
|
<view class="content-inner">
|
||||||
<!-- 教练欢迎卡片 -->
|
<!-- 教练欢迎卡片 -->
|
||||||
<view class="greeting-card">
|
<view class="greeting-card" :style="{ backgroundColor: brandConfig.secondaryColor }">
|
||||||
<view class="greeting-text">
|
<view class="greeting-text">
|
||||||
<text class="greeting-label">{{ greetingLabel }}</text>
|
<text class="greeting-label">{{ greetingLabel }}</text>
|
||||||
<text class="coach-name">{{ coachName }} 教练</text>
|
<text class="coach-name" :style="{ color: brandConfig.primaryColor }">{{ coachName }} 教练</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="greeting-desc">
|
<view class="greeting-desc">
|
||||||
<text>祝您今日授课顺利</text>
|
<text>祝您今日授课顺利</text>
|
||||||
@@ -22,17 +22,17 @@
|
|||||||
<!-- 今日统计 -->
|
<!-- 今日统计 -->
|
||||||
<view class="stats-card">
|
<view class="stats-card">
|
||||||
<view class="stat-item">
|
<view class="stat-item">
|
||||||
<text class="stat-number">{{ todayStats.pending }}</text>
|
<text class="stat-number" :style="{ color: brandConfig.primaryColor }">{{ todayStats.pending }}</text>
|
||||||
<text class="stat-label">待开课</text>
|
<text class="stat-label">待开课</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="stat-divider"></view>
|
<view class="stat-divider"></view>
|
||||||
<view class="stat-item">
|
<view class="stat-item">
|
||||||
<text class="stat-number">{{ todayStats.inProgress }}</text>
|
<text class="stat-number" :style="{ color: brandConfig.primaryColor }">{{ todayStats.inProgress }}</text>
|
||||||
<text class="stat-label">进行中</text>
|
<text class="stat-label">进行中</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="stat-divider"></view>
|
<view class="stat-divider"></view>
|
||||||
<view class="stat-item">
|
<view class="stat-item">
|
||||||
<text class="stat-number">{{ todayStats.ended }}</text>
|
<text class="stat-number" :style="{ color: brandConfig.primaryColor }">{{ todayStats.ended }}</text>
|
||||||
<text class="stat-label">已结束</text>
|
<text class="stat-label">已结束</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -43,15 +43,15 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="entry-grid">
|
<view class="entry-grid">
|
||||||
<view class="entry-card" @click="goToCourseList">
|
<view class="entry-card" @click="goToCourseList">
|
||||||
<view class="entry-icon-wrap green-bg">
|
<view class="entry-icon-wrap green-bg" :style="{ backgroundColor: 'rgba(' + brandConfig.primaryColorRgb + ',0.15)' }">
|
||||||
<text class="entry-icon">☰</text>
|
<text class="entry-icon" :style="{ color: brandConfig.primaryColor }">☰</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="entry-name">我的团课</text>
|
<text class="entry-name">我的团课</text>
|
||||||
<text class="entry-desc">查看和管理课程</text>
|
<text class="entry-desc">查看和管理课程</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="entry-card" @click="goToProfile">
|
<view class="entry-card" @click="goToProfile">
|
||||||
<view class="entry-icon-wrap dark-bg">
|
<view class="entry-icon-wrap dark-bg" :style="{ backgroundColor: 'rgba(' + brandConfig.secondaryColorRgb + ',0.1)' }">
|
||||||
<text class="entry-icon">☺</text>
|
<text class="entry-icon" :style="{ color: brandConfig.primaryColor }">☺</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="entry-name">个人中心</text>
|
<text class="entry-name">个人中心</text>
|
||||||
<text class="entry-desc">信息与违规记录</text>
|
<text class="entry-desc">信息与违规记录</text>
|
||||||
@@ -67,6 +67,7 @@
|
|||||||
<script>
|
<script>
|
||||||
const store = require('../../store/index')
|
const store = require('../../store/index')
|
||||||
const coachApi = require('../../api/coach')
|
const coachApi = require('../../api/coach')
|
||||||
|
const brandStore = require('../../store/brand')
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
@@ -79,10 +80,13 @@
|
|||||||
pending: 0,
|
pending: 0,
|
||||||
inProgress: 0,
|
inProgress: 0,
|
||||||
ended: 0
|
ended: 0
|
||||||
}
|
},
|
||||||
|
brandConfig: brandStore.config
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
headerStyle() { return 'padding-top:' + this.statusBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor },
|
||||||
|
navStyle() { return 'height:' + this.navBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor },
|
||||||
greetingLabel() {
|
greetingLabel() {
|
||||||
const hour = new Date().getHours()
|
const hour = new Date().getHours()
|
||||||
if (hour < 6) return '夜深了,'
|
if (hour < 6) return '夜深了,'
|
||||||
@@ -104,6 +108,11 @@
|
|||||||
this.statusBarHeight = statusBarHeight
|
this.statusBarHeight = statusBarHeight
|
||||||
this.navBarHeight = navBarHeight
|
this.navBarHeight = navBarHeight
|
||||||
this.totalHeaderHeight = statusBarHeight + navBarHeight
|
this.totalHeaderHeight = statusBarHeight + navBarHeight
|
||||||
|
|
||||||
|
uni.$on('brand:updated', (config) => { this.brandConfig = config })
|
||||||
|
},
|
||||||
|
onUnload() {
|
||||||
|
uni.$off('brand:updated')
|
||||||
},
|
},
|
||||||
onShow() {
|
onShow() {
|
||||||
if (!store.isLoggedIn) {
|
if (!store.isLoggedIn) {
|
||||||
@@ -114,7 +123,8 @@
|
|||||||
this.loadStats()
|
this.loadStats()
|
||||||
},
|
},
|
||||||
onPullDownRefresh() {
|
onPullDownRefresh() {
|
||||||
this.loadStats().finally(() => { uni.stopPullDownRefresh() })
|
Promise.all([this.loadStats(), brandStore.fetchConfig()])
|
||||||
|
.finally(() => { uni.stopPullDownRefresh() })
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
goToCourseList() {
|
goToCourseList() {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="login-page" :style="{ paddingTop: statusBarHeight + 'px' }">
|
<view class="login-page" :style="{ paddingTop: statusBarHeight + 'px', backgroundColor: brandConfig.secondaryColor }">
|
||||||
<view class="brand-area">
|
<view class="brand-area">
|
||||||
<view class="logo-icon">
|
<view class="logo-wrap" :style="{ backgroundColor: brandConfig.logoUrl ? 'transparent' : 'rgba(' + brandConfig.primaryColorRgb + ',0.15)' }">
|
||||||
<text class="logo-symbol">◆</text>
|
<image v-if="brandConfig.logoUrl" class="logo-img" :src="brandConfig.logoUrl" mode="aspectFit" />
|
||||||
|
<text v-else class="logo-symbol" :style="{ color: brandConfig.primaryColor }">◆</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="app-name">Novalon 教练端</text>
|
<text class="app-name">{{ brandConfig.brandName }}</text>
|
||||||
<text class="app-slogan">高效管理 · 轻松授课</text>
|
<text class="app-slogan" v-if="brandConfig.slogan">{{ brandConfig.slogan }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="login-form">
|
<view class="login-form">
|
||||||
@@ -33,13 +34,13 @@
|
|||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<button class="login-btn" :loading="loading" :disabled="loading || !username || !password" @click="handleLogin">
|
<button class="login-btn" :style="loginBtnStyle" :loading="loading" :disabled="loading || !username || !password" @click="handleLogin">
|
||||||
{{ loading ? '登录中...' : '登 录' }}
|
{{ loading ? '登录中...' : '登 录' }}
|
||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="footer-tip">
|
<view class="footer-tip">
|
||||||
<text>Novalon 健身房管理系统</text>
|
<text>{{ brandConfig.brandName }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
<script>
|
<script>
|
||||||
const coachApi = require('../../api/coach')
|
const coachApi = require('../../api/coach')
|
||||||
const store = require('../../store/index')
|
const store = require('../../store/index')
|
||||||
|
const brandStore = require('../../store/brand')
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
@@ -55,9 +57,13 @@
|
|||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
showPassword: false,
|
showPassword: false,
|
||||||
loading: false
|
loading: false,
|
||||||
|
brandConfig: brandStore.config
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
loginBtnStyle() { return 'background-color:' + this.brandConfig.primaryColor + ';color:' + this.brandConfig.secondaryColor }
|
||||||
|
},
|
||||||
onLoad() {
|
onLoad() {
|
||||||
const systemInfo = uni.getSystemInfoSync()
|
const systemInfo = uni.getSystemInfoSync()
|
||||||
this.statusBarHeight = systemInfo.statusBarHeight || 20
|
this.statusBarHeight = systemInfo.statusBarHeight || 20
|
||||||
@@ -65,6 +71,11 @@
|
|||||||
if (store.isLoggedIn) {
|
if (store.isLoggedIn) {
|
||||||
uni.reLaunch({ url: '/pages/index/index' })
|
uni.reLaunch({ url: '/pages/index/index' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uni.$on('brand:updated', (config) => { this.brandConfig = config })
|
||||||
|
},
|
||||||
|
onUnload() {
|
||||||
|
uni.$off('brand:updated')
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async handleLogin() {
|
async handleLogin() {
|
||||||
@@ -113,7 +124,7 @@
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.login-page {
|
.login-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #F5F7FA;
|
background: #1A1A1A;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -128,29 +139,18 @@
|
|||||||
margin-top: 80px;
|
margin-top: 80px;
|
||||||
margin-bottom: 60px;
|
margin-bottom: 60px;
|
||||||
}
|
}
|
||||||
.logo-icon {
|
.logo-wrap { width: 120px; height: 120px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-bottom: 24px; overflow: hidden; }
|
||||||
width: 80px;
|
.logo-img { width: 100%; height: 100%; }
|
||||||
height: 80px;
|
.logo-symbol { font-size: 52px; color: #00C853; }
|
||||||
background: rgba(0,230,118,0.15);
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.logo-symbol {
|
|
||||||
font-size: 36px;
|
|
||||||
color: #00C853;
|
|
||||||
}
|
|
||||||
.app-name {
|
.app-name {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1E1E1E;
|
color: #FFFFFF;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
.app-slogan {
|
.app-slogan {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #7A7E84;
|
color: rgba(255,255,255,0.6);
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
}
|
}
|
||||||
@@ -164,28 +164,27 @@
|
|||||||
.input-group {
|
.input-group {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 52px;
|
height: 52px;
|
||||||
background: #FFFFFF;
|
background: rgba(255,255,255,0.1);
|
||||||
border-radius: 40px;
|
border-radius: 40px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 18px;
|
padding: 0 18px;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
|
||||||
}
|
}
|
||||||
.input-icon {
|
.input-icon {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
color: #7A7E84;
|
color: rgba(255,255,255,0.5);
|
||||||
}
|
}
|
||||||
.login-input {
|
.login-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
color: #1E1E1E;
|
color: #FFFFFF;
|
||||||
}
|
}
|
||||||
.toggle-pwd {
|
.toggle-pwd {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: #7A7E84;
|
color: rgba(255,255,255,0.5);
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +211,6 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 60px;
|
bottom: 60px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #BDBDBD;
|
color: rgba(255,255,255,0.3);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="profile-page">
|
<view class="profile-page">
|
||||||
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
|
<view class="header-wrap" :style="headerStyle">
|
||||||
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
|
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
|
||||||
<text class="back-btn" @click="goBack">←</text>
|
<text class="back-btn" @click="goBack">←</text>
|
||||||
<text class="nav-title">个人中心</text>
|
<text class="nav-title">个人中心</text>
|
||||||
@@ -12,13 +12,13 @@
|
|||||||
<!-- 教练信息卡片 -->
|
<!-- 教练信息卡片 -->
|
||||||
<view class="user-card">
|
<view class="user-card">
|
||||||
<view class="user-header">
|
<view class="user-header">
|
||||||
<view class="avatar">
|
<view class="avatar" :style="{ backgroundColor: 'rgba(' + brandConfig.primaryColorRgb + ',0.15)' }">
|
||||||
<text class="avatar-text">CC</text>
|
<text class="avatar-text" :style="{ color: brandConfig.primaryColor }">CC</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="user-info">
|
<view class="user-info">
|
||||||
<text class="user-name">{{ coachInfo.username }}</text>
|
<text class="user-name">{{ coachInfo.username }}</text>
|
||||||
<view class="user-tag">
|
<view class="user-tag">
|
||||||
<text>教练</text>
|
<text :style="{ color: brandConfig.primaryColor }">教练</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -47,28 +47,28 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="perf-item" @click="showFormula('attendance')">
|
<view class="perf-item" @click="showFormula('attendance')">
|
||||||
<text class="perf-number">{{ performance.attendanceRate != null ? performance.attendanceRate + '%' : 'N/A' }}</text>
|
<text class="perf-number">{{ performance.attendanceRate != null ? performance.attendanceRate + '%' : 'N/A' }}</text>
|
||||||
<text class="perf-label">出勤率 ▸</text>
|
<text class="perf-label">学员出勤率 ▸</text>
|
||||||
<view v-if="performance.attendanceRate != null" class="perf-bar-wrap">
|
<view v-if="performance.attendanceRate != null" class="perf-bar-wrap">
|
||||||
<view class="perf-bar" :style="{ width: performance.attendanceRate + '%', background: getRateColor(performance.attendanceRate) }"></view>
|
<view class="perf-bar" :style="attendanceBarStyle"></view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="perf-item" @click="showFormula('fullness')">
|
<view class="perf-item" @click="showFormula('fullness')">
|
||||||
<text class="perf-number">{{ performance.fullnessRate != null ? performance.fullnessRate + '%' : 'N/A' }}</text>
|
<text class="perf-number">{{ performance.fullnessRate != null ? performance.fullnessRate + '%' : 'N/A' }}</text>
|
||||||
<text class="perf-label">满员率 ▸</text>
|
<text class="perf-label">满员率 ▸</text>
|
||||||
<view v-if="performance.fullnessRate != null" class="perf-bar-wrap">
|
<view v-if="performance.fullnessRate != null" class="perf-bar-wrap">
|
||||||
<view class="perf-bar" :style="{ width: performance.fullnessRate + '%', background: getRateColor(performance.fullnessRate) }"></view>
|
<view class="perf-bar" :style="fullnessBarStyle"></view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="score-row" @click="showFormula('composite')">
|
<view class="score-row" @click="showFormula('composite')">
|
||||||
<text class="score-label">综合评分 ▸</text>
|
<text class="score-label">综合评分 ▸</text>
|
||||||
<text class="score-badge" :class="scoreClass">{{ scoreText }}</text>
|
<text class="score-badge" :class="scoreClass" :style="scoreClass === 'score-great' ? scoreGreatStyle : ''">{{ scoreText }}</text>
|
||||||
<text v-if="scoreText !== '--'" class="score-icon">★</text>
|
<text v-if="scoreText !== '--'" class="score-icon">★</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 违规统计 -->
|
<!-- 违规统计 -->
|
||||||
<view v-if="violations.length > 0" class="stats-card">
|
<view v-if="violations.length > 0" class="stats-card" :style="{ backgroundColor: brandConfig.secondaryColor }">
|
||||||
<view class="stat-item">
|
<view class="stat-item">
|
||||||
<text class="stat-number">{{ violations.length }}</text>
|
<text class="stat-number">{{ violations.length }}</text>
|
||||||
<text class="stat-label">违规次数</text>
|
<text class="stat-label">违规次数</text>
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
<text class="formula-example">{{ formulaExample }}</text>
|
<text class="formula-example">{{ formulaExample }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="formula-footer">
|
<view class="formula-footer">
|
||||||
<button class="formula-btn" @click="closeFormula">知道了</button>
|
<button class="formula-btn" :style="{ backgroundColor: brandConfig.primaryColor }" @click="closeFormula">知道了</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -124,6 +124,7 @@
|
|||||||
<script>
|
<script>
|
||||||
const store = require('../../store/index')
|
const store = require('../../store/index')
|
||||||
const coachApi = require('../../api/coach')
|
const coachApi = require('../../api/coach')
|
||||||
|
const brandStore = require('../../store/brand')
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
@@ -150,9 +151,16 @@
|
|||||||
formulaVisible: false,
|
formulaVisible: false,
|
||||||
formulaTitle: '',
|
formulaTitle: '',
|
||||||
formulaContent: '',
|
formulaContent: '',
|
||||||
formulaExample: ''
|
formulaExample: '',
|
||||||
|
brandConfig: brandStore.config
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
headerStyle() { return 'padding-top:' + this.statusBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor },
|
||||||
|
scoreGreatStyle() { return 'color:' + this.brandConfig.primaryColor + ';background-color:rgba(' + this.brandConfig.primaryColorRgb + ',0.15)' },
|
||||||
|
attendanceBarStyle() { return { width: this.performance.attendanceRate + '%', background: this.getRateColor(this.performance.attendanceRate) } },
|
||||||
|
fullnessBarStyle() { return { width: this.performance.fullnessRate + '%', background: this.getRateColor(this.performance.fullnessRate) } }
|
||||||
|
},
|
||||||
onLoad() {
|
onLoad() {
|
||||||
const systemInfo = uni.getSystemInfoSync()
|
const systemInfo = uni.getSystemInfoSync()
|
||||||
const statusBarHeight = systemInfo.statusBarHeight || 20
|
const statusBarHeight = systemInfo.statusBarHeight || 20
|
||||||
@@ -166,8 +174,14 @@
|
|||||||
this.statusBarHeight = statusBarHeight
|
this.statusBarHeight = statusBarHeight
|
||||||
this.navBarHeight = navBarHeight
|
this.navBarHeight = navBarHeight
|
||||||
this.totalHeaderHeight = statusBarHeight + navBarHeight
|
this.totalHeaderHeight = statusBarHeight + navBarHeight
|
||||||
|
|
||||||
|
uni.$on('brand:updated', (config) => { this.brandConfig = config })
|
||||||
|
},
|
||||||
|
onUnload() {
|
||||||
|
uni.$off('brand:updated')
|
||||||
},
|
},
|
||||||
onShow() {
|
onShow() {
|
||||||
|
brandStore.fetchConfig()
|
||||||
if (!store.isLoggedIn) {
|
if (!store.isLoggedIn) {
|
||||||
uni.reLaunch({ url: '/pages/login/login' })
|
uni.reLaunch({ url: '/pages/login/login' })
|
||||||
return
|
return
|
||||||
@@ -178,6 +192,12 @@
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
goBack() { uni.navigateBack() },
|
goBack() { uni.navigateBack() },
|
||||||
|
getRateColor(rate) {
|
||||||
|
if (rate == null) return '#CCCCCC'
|
||||||
|
if (rate >= 80) return this.brandConfig.primaryColor
|
||||||
|
if (rate >= 60) return '#FF9800'
|
||||||
|
return '#F44336'
|
||||||
|
},
|
||||||
|
|
||||||
async loadPerformance() {
|
async loadPerformance() {
|
||||||
try {
|
try {
|
||||||
@@ -210,17 +230,17 @@
|
|||||||
|
|
||||||
showFormula(type) {
|
showFormula(type) {
|
||||||
if (type === 'attendance') {
|
if (type === 'attendance') {
|
||||||
this.formulaTitle = '出勤率计算方式'
|
this.formulaTitle = '学员出勤率计算方式'
|
||||||
this.formulaContent = '出勤率 = 出席人次 ÷ 非取消预约总数 × 100%'
|
this.formulaContent = '学员出勤率 = 出席人次 ÷ 预约总数 × 100%'
|
||||||
this.formulaExample = '示例:本月有 10 人预约了你的课程,其中 2 人取消预约,8 人中实际出席 7 人\n\n出勤率 = 7 ÷ (10 - 2) × 100% = 87.5%'
|
this.formulaExample = '示例:本月有 10 人预约了你的课程,实际出席 7 人\n\n学员出勤率 = 7 ÷ 10 × 100% = 70%'
|
||||||
} else if (type === 'fullness') {
|
} else if (type === 'fullness') {
|
||||||
this.formulaTitle = '满员率计算方式'
|
this.formulaTitle = '满员率计算方式'
|
||||||
this.formulaContent = '满员率 = 各课程(出席人数 ÷ 课程最大容量)的平均值 × 100%'
|
this.formulaContent = '满员率 = 各课程(出席人数 ÷ 课程最大容量)的平均值 × 100%'
|
||||||
this.formulaExample = '示例:本月你完成了 2 节课\n- 课程 A:容量 20 人,实际出席 12 人 → 60%\n- 课程 B:容量 15 人,实际出席 9 人 → 60%\n\n满员率 = (60% + 60%) ÷ 2 = 60%'
|
this.formulaExample = '示例:本月你完成了 2 节课\n- 课程 A:容量 20 人,实际出席 12 人 → 60%\n- 课程 B:容量 15 人,实际出席 9 人 → 60%\n\n满员率 = (60% + 60%) ÷ 2 = 60%'
|
||||||
} else if (type === 'composite') {
|
} else if (type === 'composite') {
|
||||||
this.formulaTitle = '综合评分计算方式'
|
this.formulaTitle = '综合评分计算方式'
|
||||||
this.formulaContent = '综合评分 = 授课量评分 × 40% + 出勤率评分 × 30% + 满员率评分 × 30%'
|
this.formulaContent = '综合评分 = 授课量评分 × 35% + 学员出勤率评分 × 25% + 满员率评分 × 25% + 违规评分 × 15%'
|
||||||
this.formulaExample = '计算步骤:\n1. 授课量归一化:你的授课量 ÷ 全部教练最高授课量 × 100\n2. 出勤率与满员率各取实际百分值\n3. 加权求和\n\n示例:\n授课量评分 80 × 0.4 = 32\n出勤率 85 × 0.3 = 25.5\n满员率 60 × 0.3 = 18\n\n综合评分 = 32 + 25.5 + 18 = 75.5'
|
this.formulaExample = '计算步骤:\n1. 授课量百分位排名归一化:你的授课量排名 / (总教练数-1) × 100\n2. 学员出勤率与满员率各取实际百分值\n3. 违规分:max(0, 100 - 违规次数 × 20)\n4. 加权求和\n\n示例:\n授课量评分 80 × 0.35 = 28\n学员出勤率 85 × 0.25 = 21.25\n满员率 60 × 0.25 = 15\n违规分 80 × 0.15 = 12\n\n综合评分 = 28 + 21.25 + 15 + 12 = 76.25'
|
||||||
}
|
}
|
||||||
this.formulaVisible = true
|
this.formulaVisible = true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
const brandApi = require('../api/brand')
|
||||||
|
|
||||||
|
const BRAND_CACHE_KEY = 'brand_config_cache'
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG = {
|
||||||
|
brandName: 'Novalon教练端',
|
||||||
|
slogan: '高效管理 · 轻松授课',
|
||||||
|
primaryColor: '#00E676',
|
||||||
|
primaryColorRgb: '0,230,118',
|
||||||
|
secondaryColor: '#1A1A1A',
|
||||||
|
secondaryColorRgb: '26,26,26',
|
||||||
|
logoUrl: '',
|
||||||
|
backgroundImageUrl: '',
|
||||||
|
updatedAt: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 品牌方案 Store
|
||||||
|
*
|
||||||
|
* 响应式策略:fetchConfig 更新后通过 uni.$emit('brand:updated', config) 广播,
|
||||||
|
* 页面通过 uni.$on 监听并在 data 中维护副本,确保 UI 自动刷新。
|
||||||
|
*/
|
||||||
|
const brandStore = {
|
||||||
|
_config: null,
|
||||||
|
_loaded: false,
|
||||||
|
|
||||||
|
/** 获取当前品牌配置(优先内存,其次缓存,最后默认值) */
|
||||||
|
get config() {
|
||||||
|
if (this._config) return this._config
|
||||||
|
try {
|
||||||
|
const cached = uni.getStorageSync(BRAND_CACHE_KEY)
|
||||||
|
if (cached && cached.primaryColor) {
|
||||||
|
this._config = cached
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
return { ...DEFAULT_CONFIG }
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 是否已从后端加载完成 */
|
||||||
|
get isLoaded() {
|
||||||
|
return this._loaded
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从后端拉取品牌配置
|
||||||
|
* 策略:对比 updatedAt,有变化时更新缓存并广播事件通知全部页面
|
||||||
|
* @returns {Promise<boolean>} 是否有变化
|
||||||
|
*/
|
||||||
|
async fetchConfig() {
|
||||||
|
try {
|
||||||
|
const res = await brandApi.getBrandConfig()
|
||||||
|
if (res) {
|
||||||
|
const newConfig = {
|
||||||
|
brandName: res.brandName || DEFAULT_CONFIG.brandName,
|
||||||
|
slogan: res.slogan || DEFAULT_CONFIG.slogan,
|
||||||
|
primaryColor: res.primaryColor || DEFAULT_CONFIG.primaryColor,
|
||||||
|
primaryColorRgb: res.primaryColorRgb || DEFAULT_CONFIG.primaryColorRgb,
|
||||||
|
secondaryColor: res.secondaryColor || DEFAULT_CONFIG.secondaryColor,
|
||||||
|
secondaryColorRgb: res.secondaryColorRgb || DEFAULT_CONFIG.secondaryColorRgb,
|
||||||
|
logoUrl: res.logoUrl || '',
|
||||||
|
backgroundImageUrl: res.backgroundImageUrl || '',
|
||||||
|
updatedAt: res.updatedAt || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._config || this._config.updatedAt !== newConfig.updatedAt) {
|
||||||
|
this._config = newConfig
|
||||||
|
try {
|
||||||
|
uni.setStorageSync(BRAND_CACHE_KEY, newConfig)
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
this._loaded = true
|
||||||
|
// 广播事件通知所有页面刷新
|
||||||
|
uni.$emit('brand:updated', newConfig)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[BrandStore] 获取品牌配置失败:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
this._loaded = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = brandStore
|
||||||
@@ -1,10 +1,24 @@
|
|||||||
<script>
|
<script>
|
||||||
|
const brandStore = require('./store/brand')
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
onLaunch: function() {
|
onLaunch: function() {
|
||||||
console.log('App Launch')
|
console.log('App Launch')
|
||||||
|
// 冷启动时拉取品牌配置(以缓存为准立即显示,后台拉取最新)
|
||||||
|
brandStore.fetchConfig().then(changed => {
|
||||||
|
if (changed) {
|
||||||
|
brandStore.applyTabBar()
|
||||||
|
}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
onShow: function() {
|
onShow: function() {
|
||||||
console.log('App Show')
|
console.log('App Show')
|
||||||
|
// 每次回到前台拉取最新配置
|
||||||
|
brandStore.fetchConfig().then(changed => {
|
||||||
|
if (changed) {
|
||||||
|
brandStore.applyTabBar()
|
||||||
|
}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
onHide: function() {
|
onHide: function() {
|
||||||
console.log('App Hide')
|
console.log('App Hide')
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const http = require('../utils/request')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
/**
|
||||||
|
* 获取品牌配置(需 JWT 登录态,自动按 tenantId 返回对应租户配置)
|
||||||
|
* @returns {Promise<Object>} 品牌配置对象
|
||||||
|
*/
|
||||||
|
getBrandConfig() {
|
||||||
|
return http.get('/brand')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -173,6 +173,90 @@ async function signIn(courseId, memberId, token) {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 品牌定制 API ──
|
||||||
|
|
||||||
|
/** 获取品牌配置 (需auth) */
|
||||||
|
async function getBrandConfig(token) {
|
||||||
|
console.log('[API] 获取品牌配置...');
|
||||||
|
const res = await makeRequest('GET', `${API_BASE}/brand`, null, token);
|
||||||
|
console.log(`[API] 品牌配置: status=${res.status}`);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 通知/消息 API ──
|
||||||
|
|
||||||
|
/** 获取活跃Banner列表 */
|
||||||
|
async function getActiveBanners(token) {
|
||||||
|
console.log('[API] 获取活跃Banners...');
|
||||||
|
const res = await makeRequest('GET', `${API_BASE}/banners/active`, null, token);
|
||||||
|
console.log(`[API] Banners: status=${res.status}`);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取系统通知列表 */
|
||||||
|
async function getSystemNotices(token) {
|
||||||
|
console.log('[API] 获取系统通知...');
|
||||||
|
const res = await makeRequest('GET', `${API_BASE}/notices`, null, token);
|
||||||
|
console.log(`[API] 通知列表: status=${res.status}`);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取用户消息列表 */
|
||||||
|
async function getUserMessages(userId, token) {
|
||||||
|
console.log(`[API] 获取用户消息: userId=${userId}`);
|
||||||
|
const res = await makeRequest('GET', `${API_BASE}/messages/user/${userId}`, null, token);
|
||||||
|
console.log(`[API] 用户消息: status=${res.status}`);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取用户未读消息数 */
|
||||||
|
async function getUnreadMessageCount(userId, token) {
|
||||||
|
console.log(`[API] 获取未读消息数: userId=${userId}`);
|
||||||
|
const res = await makeRequest('GET', `${API_BASE}/messages/user/${userId}/unread`, null, token);
|
||||||
|
console.log(`[API] 未读数: status=${res.status}`);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标记单条消息已读 */
|
||||||
|
async function markMessageAsRead(messageId, token) {
|
||||||
|
console.log(`[API] 标记消息已读: id=${messageId}`);
|
||||||
|
const res = await makeRequest('PUT', `${API_BASE}/messages/${messageId}/read`, null, token);
|
||||||
|
console.log(`[API] 标记已读: status=${res.status}`);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 团课高级搜索 API ──
|
||||||
|
|
||||||
|
/** 按课程类型筛选 */
|
||||||
|
async function searchCoursesByType(typeId, token) {
|
||||||
|
console.log(`[API] 按类型搜索: typeId=${typeId}`);
|
||||||
|
const res = await makeRequest('POST', `${API_BASE}/groupCourse/page`, {
|
||||||
|
page: 0, size: 50, courseTypeId: typeId
|
||||||
|
}, token);
|
||||||
|
console.log(`[API] 类型筛选: status=${res.status}, total=${res.data?.data?.totalElements || '?'}`);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按标签筛选 */
|
||||||
|
async function searchCoursesByLabel(labelId, token) {
|
||||||
|
console.log(`[API] 按标签搜索: labelId=${labelId}`);
|
||||||
|
const res = await makeRequest('POST', `${API_BASE}/groupCourse/page`, {
|
||||||
|
page: 0, size: 50, labelId: labelId
|
||||||
|
}, token);
|
||||||
|
console.log(`[API] 标签筛选: status=${res.status}, total=${res.data?.data?.totalElements || '?'}`);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 复筛: 类型+标签 */
|
||||||
|
async function searchCoursesCombined(typeId, labelId, token) {
|
||||||
|
console.log(`[API] 组合搜索: typeId=${typeId}, labelId=${labelId}`);
|
||||||
|
const res = await makeRequest('POST', `${API_BASE}/groupCourse/page`, {
|
||||||
|
page: 0, size: 50, courseTypeId: typeId, labelId: labelId
|
||||||
|
}, token);
|
||||||
|
console.log(`[API] 组合筛选: status=${res.status}, total=${res.data?.data?.totalElements || '?'}`);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
makeRequest,
|
makeRequest,
|
||||||
adminLogin,
|
adminLogin,
|
||||||
@@ -185,6 +269,15 @@ module.exports = {
|
|||||||
bookCourse,
|
bookCourse,
|
||||||
getMemberBookings,
|
getMemberBookings,
|
||||||
signIn,
|
signIn,
|
||||||
|
getBrandConfig,
|
||||||
|
getActiveBanners,
|
||||||
|
getSystemNotices,
|
||||||
|
getUserMessages,
|
||||||
|
getUnreadMessageCount,
|
||||||
|
markMessageAsRead,
|
||||||
|
searchCoursesByType,
|
||||||
|
searchCoursesByLabel,
|
||||||
|
searchCoursesCombined,
|
||||||
isSuccess,
|
isSuccess,
|
||||||
BASE_URL,
|
BASE_URL,
|
||||||
API_BASE
|
API_BASE
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* P4-API - 会员端团课高级搜索 API 测试
|
||||||
|
* 流程:会员登录 → 按课程类型筛选 → 按标签筛选 → 类型+标签组合筛选 → 验证结果
|
||||||
|
*
|
||||||
|
* 使用 HTTP API(HMAC-SHA256 签名 + JWT Token)
|
||||||
|
* 前置条件:后端服务 http://192.168.110.64:8084 已启动
|
||||||
|
*/
|
||||||
|
const api = require('../helpers/api-helper');
|
||||||
|
|
||||||
|
let authToken = null;
|
||||||
|
let memberId = null;
|
||||||
|
let typeId = null;
|
||||||
|
let labelId = null;
|
||||||
|
|
||||||
|
describe('P4-API - 团课高级搜索', () => {
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
// 步骤0:登录 & 准备筛选条件
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
beforeAll(async () => {
|
||||||
|
console.log('════════════════════════════════════════════');
|
||||||
|
console.log('P4-API - 团课高级搜索测试');
|
||||||
|
console.log('════════════════════════════════════════════');
|
||||||
|
|
||||||
|
const result = await api.memberLogin();
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
authToken = result.accessToken;
|
||||||
|
memberId = result.memberId;
|
||||||
|
console.log(`[P4-API] 会员登录成功: memberId=${memberId}`);
|
||||||
|
|
||||||
|
// 先获取课程类型列表,找第一个类型ID
|
||||||
|
const typeRes = await api.getCourseTypes();
|
||||||
|
if (api.isSuccess(typeRes)) {
|
||||||
|
const types = typeRes.data?.data || typeRes.data || [];
|
||||||
|
const records = Array.isArray(types) ? types : (types.records || types.content || []);
|
||||||
|
if (records.length > 0) {
|
||||||
|
typeId = records[0].id;
|
||||||
|
console.log(`[P4-API] 选择课程类型: id=${typeId}, name=${records[0].typeName || records[0].name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试从课程列表获取第一个标签ID
|
||||||
|
try {
|
||||||
|
const searchRes = await api.searchCourses('', authToken);
|
||||||
|
if (api.isSuccess(searchRes)) {
|
||||||
|
const content = searchRes.data?.data?.content || searchRes.data?.content || searchRes.data || [];
|
||||||
|
const courses = Array.isArray(content) ? content : (content.records || []);
|
||||||
|
// 从第一个课程取标签
|
||||||
|
for (const course of courses) {
|
||||||
|
const labels = course.labels || course.labelList || [];
|
||||||
|
if (Array.isArray(labels) && labels.length > 0) {
|
||||||
|
const firstLabel = labels[0];
|
||||||
|
labelId = typeof firstLabel === 'object' ? firstLabel.id : firstLabel;
|
||||||
|
const name = typeof firstLabel === 'object' ? firstLabel.labelName : '';
|
||||||
|
console.log(`[P4-API] 选择标签: id=${labelId}, name=${name}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[P4-API] 获取标签失败:', e.message);
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
// 步骤1:按课程类型筛选
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
describe('按课程类型筛选', () => {
|
||||||
|
test('TC-SEARCH-001: 按类型筛选应返回正确结果', async () => {
|
||||||
|
if (!typeId) {
|
||||||
|
console.warn('[TC-SEARCH-001] 无类型ID,跳过');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api.searchCoursesByType(typeId, authToken);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const content = res.data?.data?.content || res.data?.content || res.data || [];
|
||||||
|
const courses = Array.isArray(content) ? content : (content.records || []);
|
||||||
|
console.log(`[TC-SEARCH-001] 类型筛选结果: ${courses.length} 个课程`);
|
||||||
|
|
||||||
|
// 验证所有返回课程的类型匹配
|
||||||
|
if (courses.length > 0) {
|
||||||
|
courses.slice(0, 3).forEach(c => {
|
||||||
|
console.log(` - ${c.courseName || c.name} (typeId=${c.courseTypeId || c.typeId})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(courses.length).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
// 步骤2:按标签筛选
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
describe('按标签筛选', () => {
|
||||||
|
test('TC-SEARCH-002: 按标签筛选应返回正确结果', async () => {
|
||||||
|
if (!labelId) {
|
||||||
|
console.warn('[TC-SEARCH-002] 无标签ID,跳过');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api.searchCoursesByLabel(labelId, authToken);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const content = res.data?.data?.content || res.data?.content || res.data || [];
|
||||||
|
const courses = Array.isArray(content) ? content : (content.records || []);
|
||||||
|
console.log(`[TC-SEARCH-002] 标签筛选结果: ${courses.length} 个课程`);
|
||||||
|
|
||||||
|
if (courses.length > 0) {
|
||||||
|
courses.slice(0, 3).forEach(c => {
|
||||||
|
const labels = (c.labels || c.labelList || []).map(l =>
|
||||||
|
typeof l === 'object' ? l.labelName : l
|
||||||
|
);
|
||||||
|
console.log(` - ${c.courseName || c.name} (labels: [${labels.join(', ')}])`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
// 步骤3:类型+标签组合筛选
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
describe('类型+标签组合筛选', () => {
|
||||||
|
test('TC-SEARCH-003: 组合筛选应返回交集结果', async () => {
|
||||||
|
if (!typeId || !labelId) {
|
||||||
|
console.warn('[TC-SEARCH-003] 缺少类型或标签ID,跳过');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api.searchCoursesCombined(typeId, labelId, authToken);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const content = res.data?.data?.content || res.data?.content || res.data || [];
|
||||||
|
const courses = Array.isArray(content) ? content : (content.records || []);
|
||||||
|
console.log(`[TC-SEARCH-003] 组合筛选结果: ${courses.length} 个课程`);
|
||||||
|
|
||||||
|
if (courses.length > 0) {
|
||||||
|
courses.slice(0, 3).forEach(c => {
|
||||||
|
console.log(` - ${c.courseName || c.name} (id=${c.id})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
// 步骤4:边界场景
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
describe('边界场景', () => {
|
||||||
|
test('TC-SEARCH-004: 搜索不存在类型返回200(后端可能忽略无效筛选)', async () => {
|
||||||
|
const res = await api.searchCoursesByType(99999, authToken);
|
||||||
|
// 应确保API正常响应,即使筛选参数无效也不应该崩溃
|
||||||
|
console.log(`[TC-SEARCH-004] 不存在类型: status=${res.status}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-SEARCH-005: 搜索不存在标签返回200(后端可能忽略无效筛选)', async () => {
|
||||||
|
const res = await api.searchCoursesByLabel(99999, authToken);
|
||||||
|
console.log(`[TC-SEARCH-005] 不存在标签: status=${res.status}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-SEARCH-006: 空关键词搜索可返回全部课程', async () => {
|
||||||
|
const res = await api.searchCourses('', authToken);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const content = res.data?.data?.content || res.data?.content || res.data || [];
|
||||||
|
const courses = Array.isArray(content) ? content : (content.records || []);
|
||||||
|
console.log(`[TC-SEARCH-006] 空搜索返回: ${courses.length} 个课程`);
|
||||||
|
expect(courses.length).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
// 步骤5:总结
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
describe('总结', () => {
|
||||||
|
test('TC-P4-SUMMARY: 团课高级搜索汇总', () => {
|
||||||
|
console.log('');
|
||||||
|
console.log('════════════════════════════════════════════');
|
||||||
|
console.log('P4-API - 团课高级搜索测试结束');
|
||||||
|
console.log('════════════════════════════════════════════');
|
||||||
|
console.log(' 已测试:');
|
||||||
|
console.log(` 1. 按类型筛选 (typeId=${typeId || 'N/A'})`);
|
||||||
|
console.log(` 2. 按标签筛选 (labelId=${labelId || 'N/A'})`);
|
||||||
|
console.log(` 3. 类型+标签组合筛选`);
|
||||||
|
console.log(' 4. 不存在类型/标签边界');
|
||||||
|
console.log(' 5. 空关键词搜索');
|
||||||
|
console.log('════════════════════════════════════════════');
|
||||||
|
|
||||||
|
expect(memberId).toBeDefined();
|
||||||
|
expect(authToken).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* P3-API - 会员端品牌定制 & 通知消息 API 测试
|
||||||
|
* 流程:会员登录 → 获取品牌配置 → 获取Banner/通知
|
||||||
|
*
|
||||||
|
* 使用 HTTP API(HMAC-SHA256 签名 + JWT Token)
|
||||||
|
* 前置条件:后端服务 http://192.168.110.64:8084 已启动
|
||||||
|
*/
|
||||||
|
const api = require('../helpers/api-helper');
|
||||||
|
|
||||||
|
let authToken = null;
|
||||||
|
let memberId = null;
|
||||||
|
|
||||||
|
describe('P3-API - 会员端品牌定制 & 通知消息', () => {
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
// 步骤0:会员登录
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
beforeAll(async () => {
|
||||||
|
console.log('════════════════════════════════════════════');
|
||||||
|
console.log('P3-API - 品牌定制 & 通知消息测试');
|
||||||
|
console.log('════════════════════════════════════════════');
|
||||||
|
|
||||||
|
const result = await api.memberLogin();
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
authToken = result.accessToken;
|
||||||
|
memberId = result.memberId;
|
||||||
|
console.log(`[P3-API] 会员登录成功: memberId=${memberId}`);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
// 步骤1:品牌定制配置
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
describe('品牌定制配置', () => {
|
||||||
|
test('TC-BRAND-001: 获取品牌配置', async () => {
|
||||||
|
const res = await api.getBrandConfig(authToken);
|
||||||
|
console.log(`[TC-BRAND-001] status=${res.status}`);
|
||||||
|
|
||||||
|
if (api.isSuccess(res)) {
|
||||||
|
const brand = res.data?.data || res.data || {};
|
||||||
|
console.log(` 品牌名称: ${brand.brandName || '(未设置)'}`);
|
||||||
|
console.log(` 品牌口号: ${brand.slogan || '(未设置)'}`);
|
||||||
|
console.log(` 主色调: ${brand.primaryColor || '(默认)'}`);
|
||||||
|
console.log(` 辅助色: ${brand.secondaryColor || '(默认)'}`);
|
||||||
|
console.log(` Logo URL: ${brand.logoUrl || '(默认)'}`);
|
||||||
|
console.log(` 背景URL: ${brand.backgroundUrl || '(默认)'}`);
|
||||||
|
|
||||||
|
// 品牌名称应存在(至少默认值)
|
||||||
|
expect(brand).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-BRAND-002: 验证品牌颜色为合法HEX值', async () => {
|
||||||
|
const res = await api.getBrandConfig(authToken);
|
||||||
|
if (!api.isSuccess(res)) {
|
||||||
|
console.warn('[TC-BRAND-002] 获取品牌配置失败,跳过');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const brand = res.data?.data || res.data || {};
|
||||||
|
const primaryColor = brand.primaryColor;
|
||||||
|
const secondaryColor = brand.secondaryColor;
|
||||||
|
|
||||||
|
if (primaryColor) {
|
||||||
|
// 验证是合法 HEX (#RRGGBB 或 #RRGGBBAA)
|
||||||
|
expect(/^#[0-9A-Fa-f]{6,8}$/.test(primaryColor)).toBe(true);
|
||||||
|
console.log(` 主色调 HEX 合法: ${primaryColor}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secondaryColor) {
|
||||||
|
expect(/^#[0-9A-Fa-f]{6,8}$/.test(secondaryColor)).toBe(true);
|
||||||
|
console.log(` 辅助色 HEX 合法: ${secondaryColor}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-BRAND-003: Logo URL 应为有效路径或默认占位符', async () => {
|
||||||
|
const res = await api.getBrandConfig(authToken);
|
||||||
|
if (!api.isSuccess(res)) {
|
||||||
|
console.warn('[TC-BRAND-003] 获取品牌配置失败,跳过');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const brand = res.data?.data || res.data || {};
|
||||||
|
const logoUrl = brand.logoUrl;
|
||||||
|
|
||||||
|
if (logoUrl) {
|
||||||
|
// 可以是完整URL、相对路径、或base64
|
||||||
|
const isValid = typeof logoUrl === 'string' && logoUrl.length > 0;
|
||||||
|
expect(isValid).toBe(true);
|
||||||
|
console.log(` Logo URL: ${logoUrl.substring(0, 80)}...`);
|
||||||
|
} else {
|
||||||
|
console.log(' Logo URL 为空(使用默认值)');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
// 步骤2:Banner & 系统通知
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
describe('Banner & 系统通知', () => {
|
||||||
|
test('TC-NOTIFY-001: 获取活跃Banner列表', async () => {
|
||||||
|
const res = await api.getActiveBanners(authToken);
|
||||||
|
console.log(`[TC-NOTIFY-001] status=${res.status}`);
|
||||||
|
|
||||||
|
if (api.isSuccess(res)) {
|
||||||
|
const banners = res.data?.data || res.data || [];
|
||||||
|
const records = Array.isArray(banners) ? banners : (banners.records || []);
|
||||||
|
console.log(` 活跃Banner数量: ${records.length}`);
|
||||||
|
|
||||||
|
if (records.length > 0) {
|
||||||
|
const b = records[0];
|
||||||
|
console.log(` 首条Banner: title="${b.title}", imageUrl=${(b.imageUrl || '').substring(0, 50)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-NOTIFY-002: 获取系统通知列表', async () => {
|
||||||
|
const res = await api.getSystemNotices(authToken);
|
||||||
|
console.log(`[TC-NOTIFY-002] status=${res.status}`);
|
||||||
|
|
||||||
|
if (api.isSuccess(res)) {
|
||||||
|
const notices = res.data?.data || res.data || [];
|
||||||
|
const records = Array.isArray(notices) ? notices : (notices.records || []);
|
||||||
|
console.log(` 系统通知数量: ${records.length}`);
|
||||||
|
|
||||||
|
if (records.length > 0) {
|
||||||
|
const n = records[0];
|
||||||
|
console.log(` 首条通知: title="${n.noticeTitle || n.title}", status=${n.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-NOTIFY-003: 获取用户消息列表', async () => {
|
||||||
|
const res = await api.getUserMessages(memberId, authToken);
|
||||||
|
console.log(`[TC-NOTIFY-003] status=${res.status}`);
|
||||||
|
|
||||||
|
if (api.isSuccess(res)) {
|
||||||
|
const messages = res.data?.data || res.data || [];
|
||||||
|
const records = Array.isArray(messages) ? messages : (messages.records || []);
|
||||||
|
console.log(` 消息数量: ${records.length}`);
|
||||||
|
}
|
||||||
|
// 消息可能为空,不强制要求有数据
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TC-NOTIFY-004: 获取未读消息数', async () => {
|
||||||
|
const res = await api.getUnreadMessageCount(memberId, authToken);
|
||||||
|
console.log(`[TC-NOTIFY-004] status=${res.status}`);
|
||||||
|
|
||||||
|
if (api.isSuccess(res)) {
|
||||||
|
const count = Number(res.data?.data ?? res.data ?? 0);
|
||||||
|
console.log(` 未读消息数: ${count}`);
|
||||||
|
expect(typeof count).toBe('number');
|
||||||
|
expect(count).toBeGreaterThanOrEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
// 步骤3:总结
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
describe('总结', () => {
|
||||||
|
test('TC-P3-SUMMARY: 品牌定制 & 通知消息测试汇总', () => {
|
||||||
|
console.log('');
|
||||||
|
console.log('════════════════════════════════════════════');
|
||||||
|
console.log('P3-API - 品牌定制 & 通知消息测试结束');
|
||||||
|
console.log('════════════════════════════════════════════');
|
||||||
|
console.log(' 已测试:');
|
||||||
|
console.log(' 1. 品牌配置获取 (名称/颜色/Logo/背景)');
|
||||||
|
console.log(' 2. 颜色HEX格式校验');
|
||||||
|
console.log(' 3. Logo URL有效性');
|
||||||
|
console.log(' 4. 活跃Banner列表');
|
||||||
|
console.log(' 5. 系统通知列表');
|
||||||
|
console.log(' 6. 用户消息列表');
|
||||||
|
console.log(' 7. 未读消息数量');
|
||||||
|
console.log('════════════════════════════════════════════');
|
||||||
|
|
||||||
|
expect(memberId).toBeDefined();
|
||||||
|
expect(authToken).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="detail-root">
|
<view class="detail-root">
|
||||||
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
|
<view class="header-wrap" :style="headerStyle">
|
||||||
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
|
<view class="nav-bar" :style="navStyle">
|
||||||
<text class="back-btn" @click="goBack">‹</text>
|
<text class="back-btn" @click="goBack">‹</text>
|
||||||
<text class="nav-title">课程详情</text>
|
<text class="nav-title">课程详情</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
<view class="cover-area">
|
<view class="cover-area">
|
||||||
<image v-if="course.coverImage" class="cover-img" :src="course.coverImage" mode="aspectFill"
|
<image v-if="course.coverImage" class="cover-img" :src="course.coverImage" mode="aspectFill"
|
||||||
@error="course.coverError = true" />
|
@error="course.coverError = true" />
|
||||||
<view v-if="!course.coverImage || course.coverError" class="cover-bg">
|
<view v-if="!course.coverImage || course.coverError" class="cover-bg" :style="{ background: coverGradient }">
|
||||||
<text class="cover-text">{{ course.typeName ? course.typeName.slice(0, 2) : '课程' }}</text>
|
<text class="cover-text">{{ course.typeName ? course.typeName.slice(0, 2) : '课程' }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="status-tag" :class="course.statusClass" v-if="course.statusText">{{ course.statusText }}</view>
|
<view class="status-tag" :class="course.statusClass" v-if="course.statusText">{{ course.statusText }}</view>
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
<text class="bottom-desc">/ 次</text>
|
<text class="bottom-desc">/ 次</text>
|
||||||
</view>
|
</view>
|
||||||
<button class="booking-btn" :class="{ disabled: !course.canBook }" :disabled="!course.canBook"
|
<button class="booking-btn" :class="{ disabled: !course.canBook }" :disabled="!course.canBook"
|
||||||
@click="handleBooking">
|
@click="handleBooking" :style="course.canBook ? bookingBtnStyle : ''">
|
||||||
{{ course.btnText }}
|
{{ course.btnText }}
|
||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
@@ -128,6 +128,7 @@
|
|||||||
const courseApi = require('../../api/course')
|
const courseApi = require('../../api/course')
|
||||||
const bookingApi = require('../../api/booking')
|
const bookingApi = require('../../api/booking')
|
||||||
const store = require('../../store/index')
|
const store = require('../../store/index')
|
||||||
|
const brandStore = require('../../store/brand')
|
||||||
const { resolveCoverUrl } = require('../../utils/request')
|
const { resolveCoverUrl } = require('../../utils/request')
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -166,7 +167,8 @@
|
|||||||
coverImage: '',
|
coverImage: '',
|
||||||
coverError: false
|
coverError: false
|
||||||
},
|
},
|
||||||
realMemberCount: 0
|
realMemberCount: 0,
|
||||||
|
brandConfig: brandStore.config
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLoad(options) {
|
onLoad(options) {
|
||||||
@@ -183,6 +185,24 @@
|
|||||||
this.navBarHeight = navBarHeight
|
this.navBarHeight = navBarHeight
|
||||||
this.totalHeaderHeight = statusBarHeight + navBarHeight
|
this.totalHeaderHeight = statusBarHeight + navBarHeight
|
||||||
if (options.id) this.loadCourseDetail(options.id)
|
if (options.id) this.loadCourseDetail(options.id)
|
||||||
|
uni.$on('brand:updated', (config) => { this.brandConfig = config })
|
||||||
|
},
|
||||||
|
onUnload() {
|
||||||
|
uni.$off('brand:updated')
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
coverGradient() {
|
||||||
|
return 'linear-gradient(135deg, ' + this.brandConfig.primaryColor + ', ' + this.brandConfig.secondaryColor + ')'
|
||||||
|
},
|
||||||
|
headerStyle() {
|
||||||
|
return 'padding-top:' + this.statusBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor
|
||||||
|
},
|
||||||
|
navStyle() {
|
||||||
|
return 'height:' + this.navBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor
|
||||||
|
},
|
||||||
|
bookingBtnStyle() {
|
||||||
|
return 'background:' + this.brandConfig.primaryColor + ';color:' + this.brandConfig.secondaryColor
|
||||||
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async loadCourseDetail(id) {
|
async loadCourseDetail(id) {
|
||||||
|
|||||||
@@ -1,32 +1,41 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="home-page">
|
<view class="home-page">
|
||||||
<!-- 状态栏 + 胶囊按钮占位 -->
|
<!-- 状态栏 + 胶囊按钮占位 -->
|
||||||
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
|
<view class="header-wrap" :style="headerStyle">
|
||||||
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
|
<view class="nav-bar" :style="navStyle">
|
||||||
<text class="nav-title">✯ 今日训练</text>
|
<text class="nav-title">✯ 今日训练</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<scroll-view class="content" scroll-y :style="{ height: 'calc(100vh - ' + totalHeaderHeight + 'px)' }">
|
<scroll-view class="content" scroll-y :style="{ height: 'calc(100vh - ' + totalHeaderHeight + 'px)' }">
|
||||||
<view class="content-inner">
|
<view class="content-inner">
|
||||||
|
<!-- 品牌展示 -->
|
||||||
|
<view class="brand-section">
|
||||||
|
<image class="brand-logo" :src="brandConfig.logoUrl || '/static/logo.png'" mode="aspectFit" />
|
||||||
|
<view class="brand-text">
|
||||||
|
<text class="brand-name">{{ brandConfig.brandName }}</text>
|
||||||
|
<text class="brand-slogan">{{ brandConfig.slogan }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 个人信息卡片(深色)+ 轮播图 -->
|
<!-- 个人信息卡片(深色)+ 轮播图 -->
|
||||||
<view class="greeting-card">
|
<view class="greeting-card" :style="greetingCardStyle">
|
||||||
<view class="greeting-header" @click="goToProfile">
|
<view class="greeting-header" @click="goToProfile">
|
||||||
<view class="member-info">
|
<view class="member-info">
|
||||||
<text class="member-name">{{ memberInfo.nickname ? '你好,' + memberInfo.nickname : '欢迎您!点击前往更新您的信息。' }}</text>
|
<text class="member-name">{{ memberInfo.nickname ? '你好,' + memberInfo.nickname : '欢迎您!点击前往更新您的信息。' }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="checkin-badge" v-if="memberInfo.checkedInToday">
|
<view class="checkin-badge" v-if="memberInfo.checkedInToday" :style="checkinBadgeStyle">
|
||||||
<text class="checkin-icon">✓</text>
|
<text class="checkin-icon">✓</text>
|
||||||
<text>今日已签到</text>
|
<text>今日已签到</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="stats">
|
<view class="stats">
|
||||||
<view class="stat-item">
|
<view class="stat-item">
|
||||||
<text class="stat-number">{{ memberInfo.totalSignDays }}</text>
|
<text class="stat-number" :style="{ color: brandConfig.primaryColor }">{{ memberInfo.totalSignDays }}</text>
|
||||||
<text class="stat-label">累计签到(天)</text>
|
<text class="stat-label">累计签到(天)</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="stat-item">
|
<view class="stat-item">
|
||||||
<text class="stat-number">{{ memberInfo.pendingBookings }}</text>
|
<text class="stat-number" :style="{ color: brandConfig.primaryColor }">{{ memberInfo.pendingBookings }}</text>
|
||||||
<text class="stat-label">待上团课</text>
|
<text class="stat-label">待上团课</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -46,7 +55,7 @@
|
|||||||
<view class="recommend-section">
|
<view class="recommend-section">
|
||||||
<view class="section-header">
|
<view class="section-header">
|
||||||
<text class="section-title">✯ 今日推荐</text>
|
<text class="section-title">✯ 今日推荐</text>
|
||||||
<text class="section-more" @click="goToSearch">查看全部 ›</text>
|
<text class="section-more" :style="{ color: brandConfig.primaryColor }" @click="goToSearch">查看全部 ›</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="recommend-list">
|
<view class="recommend-list">
|
||||||
<view v-for="(course, idx) in recommendCourses" :key="idx" class="recommend-card"
|
<view v-for="(course, idx) in recommendCourses" :key="idx" class="recommend-card"
|
||||||
@@ -56,7 +65,7 @@
|
|||||||
<text class="card-name">{{ course.courseName }}</text>
|
<text class="card-name">{{ course.courseName }}</text>
|
||||||
<text class="card-reason">{{ course.recommendReason }}</text>
|
<text class="card-reason">{{ course.recommendReason }}</text>
|
||||||
<view class="card-bottom">
|
<view class="card-bottom">
|
||||||
<text class="card-price">{{ course.priceLabel }}</text>
|
<text class="card-price" :style="{ color: brandConfig.primaryColor }">{{ course.priceLabel }}</text>
|
||||||
<text class="card-count">{{ course.bookedCount }}人已预约</text>
|
<text class="card-count">{{ course.bookedCount }}人已预约</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -75,6 +84,7 @@
|
|||||||
const memberApi = require('../../api/member')
|
const memberApi = require('../../api/member')
|
||||||
const courseApi = require('../../api/course')
|
const courseApi = require('../../api/course')
|
||||||
const bannerApi = require('../../api/banner')
|
const bannerApi = require('../../api/banner')
|
||||||
|
const brandStore = require('../../store/brand')
|
||||||
const { resolveCoverUrl } = require('../../utils/request')
|
const { resolveCoverUrl } = require('../../utils/request')
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -91,7 +101,8 @@
|
|||||||
pendingBookings: 0
|
pendingBookings: 0
|
||||||
},
|
},
|
||||||
banners: [],
|
banners: [],
|
||||||
recommendCourses: []
|
recommendCourses: [],
|
||||||
|
brandConfig: brandStore.config
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLoad() {
|
onLoad() {
|
||||||
@@ -107,6 +118,29 @@
|
|||||||
this.statusBarHeight = statusBarHeight
|
this.statusBarHeight = statusBarHeight
|
||||||
this.navBarHeight = navBarHeight
|
this.navBarHeight = navBarHeight
|
||||||
this.totalHeaderHeight = statusBarHeight + navBarHeight
|
this.totalHeaderHeight = statusBarHeight + navBarHeight
|
||||||
|
uni.$on('brand:updated', (config) => { this.brandConfig = config })
|
||||||
|
},
|
||||||
|
onUnload() {
|
||||||
|
uni.$off('brand:updated')
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
headerStyle() {
|
||||||
|
return 'padding-top:' + this.statusBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor
|
||||||
|
},
|
||||||
|
navStyle() {
|
||||||
|
return 'height:' + this.navBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor
|
||||||
|
},
|
||||||
|
greetingCardStyle() {
|
||||||
|
return {
|
||||||
|
backgroundColor: this.brandConfig.secondaryColor,
|
||||||
|
backgroundImage: this.brandConfig.backgroundImageUrl ? 'url(' + this.brandConfig.backgroundImageUrl + ')' : 'none',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
checkinBadgeStyle() {
|
||||||
|
return { backgroundColor: 'rgba(' + this.brandConfig.primaryColorRgb + ',0.2)', color: this.brandConfig.primaryColor }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onShow() {
|
onShow() {
|
||||||
if (!store.isLoggedIn) {
|
if (!store.isLoggedIn) {
|
||||||
@@ -114,9 +148,11 @@
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.loadData()
|
this.loadData()
|
||||||
|
brandStore.fetchConfig()
|
||||||
},
|
},
|
||||||
onPullDownRefresh() {
|
onPullDownRefresh() {
|
||||||
this.loadData().finally(() => { uni.stopPullDownRefresh() })
|
Promise.all([this.loadData(), brandStore.fetchConfig()])
|
||||||
|
.finally(() => { uni.stopPullDownRefresh() })
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
goToCourseDetail(id) { uni.navigateTo({ url: '/pages/course-detail/course-detail?id=' + id }) },
|
goToCourseDetail(id) { uni.navigateTo({ url: '/pages/course-detail/course-detail?id=' + id }) },
|
||||||
@@ -193,6 +229,13 @@
|
|||||||
.nav-title { font-size: 18px; font-weight: 700; color: #FFFFFF; }
|
.nav-title { font-size: 18px; font-weight: 700; color: #FFFFFF; }
|
||||||
.content-inner { padding: 16px; }
|
.content-inner { padding: 16px; }
|
||||||
|
|
||||||
|
/* 品牌展示 */
|
||||||
|
.brand-section { display: flex; align-items: center; background: #FFFFFF; border-radius: 16px; padding: 16px 18px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }
|
||||||
|
.brand-logo { width: 48px; height: 48px; border-radius: 10px; margin-right: 14px; flex-shrink: 0; }
|
||||||
|
.brand-text { display: flex; flex-direction: column; }
|
||||||
|
.brand-name { font-size: 18px; font-weight: 700; color: #1E1E1E; }
|
||||||
|
.brand-slogan { font-size: 12px; color: #9E9E9E; margin-top: 4px; }
|
||||||
|
|
||||||
/* 个人信息卡片 */
|
/* 个人信息卡片 */
|
||||||
.greeting-card { background: #1A1A1A; border-radius: 20px; padding: 18px 20px 0; color: #FFFFFF; margin-bottom: 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.06); overflow: hidden; }
|
.greeting-card { background: #1A1A1A; border-radius: 20px; padding: 18px 20px 0; color: #FFFFFF; margin-bottom: 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.06); overflow: hidden; }
|
||||||
.greeting-header { display: flex; justify-content: space-between; align-items: flex-start; }
|
.greeting-header { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||||
|
|||||||
@@ -1,30 +1,32 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="login-page" :style="{ paddingTop: statusBarHeight + 'px' }">
|
<view class="login-page" :style="{ paddingTop: statusBarHeight + 'px', backgroundColor: brandConfig.secondaryColor }">
|
||||||
<view class="brand-area">
|
<view class="brand-area">
|
||||||
<view class="logo-icon">
|
<view class="logo-wrap" :style="{ backgroundColor: brandConfig.logoUrl ? 'transparent' : 'rgba(' + brandConfig.primaryColorRgb + ',0.15)' }">
|
||||||
<text class="logo-symbol">✦</text>
|
<image v-if="brandConfig.logoUrl" class="logo-img" :src="brandConfig.logoUrl" mode="aspectFit" />
|
||||||
|
<text v-else class="logo-symbol" :style="{ color: brandConfig.primaryColor }">✦</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="app-name">Novalon 健身房</text>
|
<text class="app-name">{{ brandConfig.brandName }}</text>
|
||||||
<text class="app-slogan">专业预约 · 科学训练</text>
|
<text class="app-slogan" v-if="brandConfig.slogan">{{ brandConfig.slogan }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="login-btn-area">
|
<view class="login-btn-area">
|
||||||
<button class="wechat-login-btn" @click="handleLogin">
|
<button class="wechat-login-btn" @click="handleLogin"
|
||||||
<text class="wechat-symbol">微</text>
|
:style="{ background: brandConfig.primaryColor }">
|
||||||
<text class="btn-text">微信一键登录</text>
|
<text class="wechat-symbol" :style="{ color: brandConfig.secondaryColor }">微</text>
|
||||||
|
<text class="btn-text" :style="{ color: brandConfig.secondaryColor }">微信一键登录</text>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<view class="agreement-row">
|
<view class="agreement-row">
|
||||||
<view class="checkbox-wrapper" @click="toggleAgree">
|
<view class="checkbox-wrapper" @click="toggleAgree">
|
||||||
<view class="checkbox" :class="{ checked: agreed }">
|
<view class="checkbox" :class="{ checked: agreed }" :style="agreed ? agreedStyle : ''">
|
||||||
<text v-if="agreed" class="check-mark">✓</text>
|
<text v-if="agreed" class="check-mark" :style="{ color: brandConfig.secondaryColor }">✓</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<text class="agreement-text">
|
<text class="agreement-text">
|
||||||
已阅读并同意
|
已阅读并同意
|
||||||
<text class="link">《用户协议》</text>
|
<text class="link" :style="{ color: brandConfig.primaryColor }">《用户协议》</text>
|
||||||
和
|
和
|
||||||
<text class="link">《隐私政策》</text>
|
<text class="link" :style="{ color: brandConfig.primaryColor }">《隐私政策》</text>
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -50,13 +52,15 @@
|
|||||||
<script>
|
<script>
|
||||||
const authApi = require('../../api/auth')
|
const authApi = require('../../api/auth')
|
||||||
const store = require('../../store/index')
|
const store = require('../../store/index')
|
||||||
|
const brandStore = require('../../store/brand')
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
statusBarHeight: 0,
|
statusBarHeight: 0,
|
||||||
agreed: false,
|
agreed: false,
|
||||||
loading: false
|
loading: false,
|
||||||
|
brandConfig: brandStore.config
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLoad() {
|
onLoad() {
|
||||||
@@ -66,6 +70,15 @@
|
|||||||
if (store.isLoggedIn) {
|
if (store.isLoggedIn) {
|
||||||
uni.switchTab({ url: '/pages/index/index' })
|
uni.switchTab({ url: '/pages/index/index' })
|
||||||
}
|
}
|
||||||
|
uni.$on('brand:updated', (config) => { this.brandConfig = config })
|
||||||
|
},
|
||||||
|
onUnload() {
|
||||||
|
uni.$off('brand:updated')
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
agreedStyle() {
|
||||||
|
return 'background:' + this.brandConfig.primaryColor + ';border-color:' + this.brandConfig.secondaryColor
|
||||||
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
toggleAgree() { this.agreed = !this.agreed },
|
toggleAgree() { this.agreed = !this.agreed },
|
||||||
@@ -119,32 +132,33 @@
|
|||||||
.login-page { min-height: 100vh; background: #F5F7FA; display: flex; flex-direction: column; align-items: center; padding: 0 32px; overflow-x: hidden; }
|
.login-page { min-height: 100vh; background: #F5F7FA; display: flex; flex-direction: column; align-items: center; padding: 0 32px; overflow-x: hidden; }
|
||||||
|
|
||||||
.brand-area { display: flex; flex-direction: column; align-items: center; margin-top: 60px; margin-bottom: 80px; }
|
.brand-area { display: flex; flex-direction: column; align-items: center; margin-top: 60px; margin-bottom: 80px; }
|
||||||
.logo-icon { width: 80px; height: 80px; background: rgba(0,230,118,0.15); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-bottom: 20px; }
|
.logo-wrap { width: 120px; height: 120px; background: rgba(0,230,118,0.15); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-bottom: 24px; overflow: hidden; }
|
||||||
.logo-symbol { font-size: 36px; color: #00C853; }
|
.logo-img { width: 100%; height: 100%; }
|
||||||
.app-name { font-size: 28px; font-weight: 700; color: #1E1E1E; letter-spacing: 1px; }
|
.logo-symbol { font-size: 52px; color: #00C853; }
|
||||||
.app-slogan { font-size: 14px; color: #7A7E84; margin-top: 8px; letter-spacing: 2px; }
|
.app-name { font-size: 28px; font-weight: 700; color: #FFFFFF; letter-spacing: 1px; }
|
||||||
|
.app-slogan { font-size: 14px; color: rgba(255,255,255,0.6); margin-top: 8px; letter-spacing: 2px; }
|
||||||
|
|
||||||
.login-btn-area { width: 100%; display: flex; flex-direction: column; align-items: center; }
|
.login-btn-area { width: 100%; display: flex; flex-direction: column; align-items: center; }
|
||||||
.wechat-login-btn { width: 100%; height: 52px; background: #00E676; border-radius: 40px; display: flex; align-items: center; justify-content: center; border: none; padding: 0; box-shadow: 0 4px 16px rgba(0,230,118,0.35); }
|
.wechat-login-btn { width: 100%; height: 52px; background: #00E676; border-radius: 40px; display: flex; align-items: center; justify-content: center; border: none; padding: 0; box-shadow: 0 4px 16px rgba(0,230,118,0.35); }
|
||||||
.wechat-login-btn::after { border: none; }
|
.wechat-login-btn::after { border: none; }
|
||||||
.wechat-symbol { font-size: 18px; font-weight: 700; color: #1A1A1A; margin-right: 8px; }
|
.wechat-symbol { font-size: 18px; font-weight: 700; color: #1A1A1A; margin-right: 8px; }
|
||||||
.btn-text { font-size: 17px; font-weight: 700; color: #1A1A1A; }
|
.btn-text { font-size: 17px; font-weight: 700; color: #1A1A1A; }
|
||||||
|
|
||||||
.agreement-row { display: flex; align-items: center; margin-top: 16px; }
|
.agreement-row { display: flex; align-items: center; margin-top: 16px; }
|
||||||
.checkbox-wrapper { margin-right: 6px; }
|
.checkbox-wrapper { margin-right: 6px; }
|
||||||
.checkbox { width: 18px; height: 18px; border-radius: 50%; border: 2px solid #BDBDBD; display: flex; align-items: center; justify-content: center; }
|
.checkbox { width: 18px; height: 18px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.3); display: flex; align-items: center; justify-content: center; }
|
||||||
.checkbox.checked { background: #00E676; border-color: #00E676; }
|
.checkbox.checked { background: #00E676; border-color: #00E676; }
|
||||||
.check-mark { font-size: 12px; color: #1A1A1A; font-weight: 700; }
|
.check-mark { font-size: 12px; color: #1A1A1A; font-weight: 700; }
|
||||||
.agreement-text { font-size: 12px; color: #7A7E84; }
|
.agreement-text { font-size: 12px; color: rgba(255,255,255,0.5); }
|
||||||
.link { color: #00C853; }
|
.link { color: #00E676; }
|
||||||
|
|
||||||
.other-login { margin-top: 60px; width: 100%; display: flex; flex-direction: column; align-items: center; }
|
.other-login { margin-top: 60px; width: 100%; display: flex; flex-direction: column; align-items: center; }
|
||||||
.divider-text { font-size: 13px; color: #BDBDBD; margin-bottom: 20px; }
|
.divider-text { font-size: 13px; color: rgba(255,255,255,0.3); margin-bottom: 20px; }
|
||||||
.other-icons { display: flex; }
|
.other-icons { display: flex; }
|
||||||
.other-item { display: flex; flex-direction: column; align-items: center; }
|
.other-item { display: flex; flex-direction: column; align-items: center; }
|
||||||
.other-icon-circle { width: 48px; height: 48px; background: #FFFFFF; border-radius: 50%; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 12px rgba(0,0,0,0.06); }
|
.other-icon-circle { width: 48px; height: 48px; background: rgba(255,255,255,0.1); border-radius: 50%; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 12px rgba(0,0,0,0.2); }
|
||||||
.other-symbol { font-size: 22px; color: #7A7E84; }
|
.other-symbol { font-size: 22px; color: rgba(255,255,255,0.6); }
|
||||||
.other-label { font-size: 12px; color: #7A7E84; }
|
.other-label { font-size: 12px; color: rgba(255,255,255,0.4); }
|
||||||
|
|
||||||
.footer-tip { position: absolute; bottom: 60px; font-size: 12px; color: #BDBDBD; }
|
.footer-tip { position: absolute; bottom: 60px; font-size: 12px; color: rgba(255,255,255,0.25); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="my-courses-page">
|
<view class="my-courses-page">
|
||||||
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
|
<view class="header-wrap" :style="headerStyle">
|
||||||
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
|
<view class="nav-bar" :style="navStyle">
|
||||||
<text class="nav-title">⌕ 我的课程</text>
|
<text class="nav-title">⌕ 我的课程</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
<view v-if="!loading && bookings.length === 0" class="empty-state">
|
<view v-if="!loading && bookings.length === 0" class="empty-state">
|
||||||
<text class="empty-symbol">⊙</text>
|
<text class="empty-symbol">⊙</text>
|
||||||
<text class="empty-text">暂无预约课程</text>
|
<text class="empty-text">暂无预约课程</text>
|
||||||
<text class="empty-sub" @click="goToSearch">前往预约团课 ›</text>
|
<text class="empty-sub" :style="{ color: brandConfig.primaryColor }" @click="goToSearch">前往预约团课 ›</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="bottom-safe"></view>
|
<view class="bottom-safe"></view>
|
||||||
@@ -65,6 +65,7 @@
|
|||||||
<script>
|
<script>
|
||||||
const bookingApi = require('../../api/booking')
|
const bookingApi = require('../../api/booking')
|
||||||
const store = require('../../store/index')
|
const store = require('../../store/index')
|
||||||
|
const brandStore = require('../../store/brand')
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
@@ -75,7 +76,8 @@
|
|||||||
loading: true,
|
loading: true,
|
||||||
cancelling: null,
|
cancelling: null,
|
||||||
signing: null,
|
signing: null,
|
||||||
bookings: []
|
bookings: [],
|
||||||
|
brandConfig: brandStore.config
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLoad() {
|
onLoad() {
|
||||||
@@ -91,13 +93,26 @@
|
|||||||
this.statusBarHeight = statusBarHeight
|
this.statusBarHeight = statusBarHeight
|
||||||
this.navBarHeight = navBarHeight
|
this.navBarHeight = navBarHeight
|
||||||
this.totalHeaderHeight = statusBarHeight + navBarHeight
|
this.totalHeaderHeight = statusBarHeight + navBarHeight
|
||||||
|
uni.$on('brand:updated', (config) => { this.brandConfig = config })
|
||||||
|
},
|
||||||
|
onUnload() {
|
||||||
|
uni.$off('brand:updated')
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
headerStyle() {
|
||||||
|
return 'padding-top:' + this.statusBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor
|
||||||
|
},
|
||||||
|
navStyle() {
|
||||||
|
return 'height:' + this.navBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onShow() {
|
onShow() {
|
||||||
if (!store.isLoggedIn) return
|
if (!store.isLoggedIn) return
|
||||||
this.loadBookings()
|
this.loadBookings()
|
||||||
},
|
},
|
||||||
onPullDownRefresh() {
|
onPullDownRefresh() {
|
||||||
this.loadBookings().finally(() => { uni.stopPullDownRefresh() })
|
Promise.all([this.loadBookings(), brandStore.fetchConfig()])
|
||||||
|
.finally(() => { uni.stopPullDownRefresh() })
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
goToSearch() { uni.switchTab({ url: '/pages/search/search' }) },
|
goToSearch() { uni.switchTab({ url: '/pages/search/search' }) },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="profile-page">
|
<view class="profile-page">
|
||||||
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
|
<view class="header-wrap" :style="headerStyle">
|
||||||
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
|
<view class="nav-bar" :style="navStyle">
|
||||||
<text class="nav-title">● 个人中心</text>
|
<text class="nav-title">● 个人中心</text>
|
||||||
<text class="settings-symbol" @click="goToSettings">⚙</text>
|
<text class="settings-symbol" @click="goToSettings">⚙</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<image class="big-avatar-img" :src="avatarSrc" mode="aspectFill" @error="onAvatarError" />
|
<image class="big-avatar-img" :src="avatarSrc" mode="aspectFill" @error="onAvatarError" />
|
||||||
<view class="user-info">
|
<view class="user-info">
|
||||||
<text class="user-name">{{ userInfo.nickname }}</text>
|
<text class="user-name">{{ userInfo.nickname }}</text>
|
||||||
<view class="user-tag">
|
<view class="user-tag" :style="{ color: brandConfig.primaryColor }">
|
||||||
<text>会员号:{{ userInfo.memberNo }}</text>
|
<text>会员号:{{ userInfo.memberNo }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -40,14 +40,14 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="stats-card">
|
<view class="stats-card" :style="{ backgroundColor: brandConfig.secondaryColor }">
|
||||||
<view class="stat-item">
|
<view class="stat-item">
|
||||||
<text class="stat-number">{{ userInfo.totalSignInDays }}</text>
|
<text class="stat-number" :style="{ color: brandConfig.primaryColor }">{{ userInfo.totalSignInDays }}</text>
|
||||||
<text class="stat-label">累计签到(天)</text>
|
<text class="stat-label">累计签到(天)</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="stat-divider"></view>
|
<view class="stat-divider"></view>
|
||||||
<view class="stat-item">
|
<view class="stat-item">
|
||||||
<text class="stat-number">{{ userInfo.monthSignInCount }}</text>
|
<text class="stat-number" :style="{ color: brandConfig.primaryColor }">{{ userInfo.monthSignInCount }}</text>
|
||||||
<text class="stat-label">本月签到</text>
|
<text class="stat-label">本月签到</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -55,10 +55,10 @@
|
|||||||
<view class="sign-section">
|
<view class="sign-section">
|
||||||
<view class="sign-header">
|
<view class="sign-header">
|
||||||
<view class="section-title-row">
|
<view class="section-title-row">
|
||||||
<text class="section-symbol">☰</text>
|
<text class="section-symbol" :style="{ color: brandConfig.primaryColor }">☰</text>
|
||||||
<text class="section-title">最近签到</text>
|
<text class="section-title">最近签到</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="section-more" @click="viewAllSignIn">查看全部</text>
|
<text class="section-more" @click="viewAllSignIn" :style="{ color: brandConfig.primaryColor }">查看全部</text>
|
||||||
</view>
|
</view>
|
||||||
<view v-for="(record, idx) in signInRecords" :key="idx" class="sign-item">
|
<view v-for="(record, idx) in signInRecords" :key="idx" class="sign-item">
|
||||||
<view class="sign-left">
|
<view class="sign-left">
|
||||||
@@ -79,10 +79,10 @@
|
|||||||
<view class="sign-section">
|
<view class="sign-section">
|
||||||
<view class="sign-header">
|
<view class="sign-header">
|
||||||
<view class="section-title-row">
|
<view class="section-title-row">
|
||||||
<text class="section-symbol">✓</text>
|
<text class="section-symbol" :style="{ color: brandConfig.primaryColor }">✓</text>
|
||||||
<text class="section-title">团课签到记录</text>
|
<text class="section-title">团课签到记录</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="section-more">最近{{ courseCheckInRecords.length }}条</text>
|
<text class="section-more" :style="{ color: brandConfig.primaryColor }">最近{{ courseCheckInRecords.length }}条</text>
|
||||||
</view>
|
</view>
|
||||||
<view v-for="(record, idx) in courseCheckInRecords" :key="idx" class="sign-item">
|
<view v-for="(record, idx) in courseCheckInRecords" :key="idx" class="sign-item">
|
||||||
<view class="sign-left">
|
<view class="sign-left">
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
<view class="sign-right">
|
<view class="sign-right">
|
||||||
<text class="sign-type">{{ record.courseName }}</text>
|
<text class="sign-type">{{ record.courseName }}</text>
|
||||||
<text class="sign-source">{{ record.location }}</text>
|
<text class="sign-source">{{ record.location }}</text>
|
||||||
<text class="sign-status success">已签到</text>
|
<text class="sign-status success" :style="signStatusSuccessStyle">已签到</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="courseCheckInRecords.length === 0" class="empty-hint">
|
<view v-if="courseCheckInRecords.length === 0" class="empty-hint">
|
||||||
@@ -131,7 +131,8 @@
|
|||||||
</scroll-view>
|
</scroll-view>
|
||||||
<view class="edit-modal-footer">
|
<view class="edit-modal-footer">
|
||||||
<button class="edit-btn cancel" @click="closeEdit">取消</button>
|
<button class="edit-btn cancel" @click="closeEdit">取消</button>
|
||||||
<button class="edit-btn confirm" :disabled="saving" @click="saveProfile">
|
<button class="edit-btn confirm" :disabled="saving" @click="saveProfile"
|
||||||
|
:style="confirmBtnStyle">
|
||||||
{{ saving ? '保存中...' : '保存' }}
|
{{ saving ? '保存中...' : '保存' }}
|
||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
@@ -144,6 +145,7 @@
|
|||||||
const store = require('../../store/index')
|
const store = require('../../store/index')
|
||||||
const memberApi = require('../../api/member')
|
const memberApi = require('../../api/member')
|
||||||
const bookingApi = require('../../api/booking')
|
const bookingApi = require('../../api/booking')
|
||||||
|
const brandStore = require('../../store/brand')
|
||||||
const defaultAvatar = require('../../common/img/20200414210134_qbeyi.jpg')
|
const defaultAvatar = require('../../common/img/20200414210134_qbeyi.jpg')
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -169,7 +171,8 @@
|
|||||||
{ label: '女', value: 'FEMALE' }
|
{ label: '女', value: 'FEMALE' }
|
||||||
],
|
],
|
||||||
today: '',
|
today: '',
|
||||||
showDefaultAvatar: false
|
showDefaultAvatar: false,
|
||||||
|
brandConfig: brandStore.config
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLoad() {
|
onLoad() {
|
||||||
@@ -188,13 +191,53 @@
|
|||||||
// 设置今天日期,限制生日选择不晚于今天
|
// 设置今天日期,限制生日选择不晚于今天
|
||||||
const d = new Date()
|
const d = new Date()
|
||||||
this.today = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
|
this.today = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
|
||||||
|
uni.$on('brand:updated', (config) => { this.brandConfig = config })
|
||||||
|
},
|
||||||
|
onUnload() {
|
||||||
|
uni.$off('brand:updated')
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
headerStyle() {
|
||||||
|
return 'padding-top:' + this.statusBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor
|
||||||
|
},
|
||||||
|
navStyle() {
|
||||||
|
return 'height:' + this.navBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor
|
||||||
|
},
|
||||||
|
confirmBtnStyle() {
|
||||||
|
return 'background:' + this.brandConfig.primaryColor + ';color:' + this.brandConfig.secondaryColor
|
||||||
|
},
|
||||||
|
avatarSrc() {
|
||||||
|
if (this.showDefaultAvatar) return defaultAvatar
|
||||||
|
return this.userInfo.avatar || defaultAvatar
|
||||||
|
},
|
||||||
|
genderLabel() {
|
||||||
|
const g = this.userInfo.gender
|
||||||
|
if (g === 1 || g === 'MALE' || g === '男') return '男'
|
||||||
|
if (g === 2 || g === 'FEMALE' || g === '女') return '女'
|
||||||
|
return '未设置'
|
||||||
|
},
|
||||||
|
genderIndex() {
|
||||||
|
const genderMap = { 0: 'UNKNOWN', 1: 'MALE', 2: 'FEMALE' }
|
||||||
|
const genderStr = typeof this.editForm.gender === 'string'
|
||||||
|
? this.editForm.gender
|
||||||
|
: genderMap[this.editForm.gender] || 'UNKNOWN'
|
||||||
|
return Math.max(0, this.genderOptions.findIndex(o => o.value === genderStr))
|
||||||
|
},
|
||||||
|
signStatusSuccessStyle() {
|
||||||
|
return {
|
||||||
|
background: 'rgba(' + this.brandConfig.primaryColorRgb + ',0.15)',
|
||||||
|
color: this.brandConfig.primaryColor
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onShow() {
|
onShow() {
|
||||||
if (!store.isLoggedIn) return
|
if (!store.isLoggedIn) return
|
||||||
this.loadData()
|
this.loadData()
|
||||||
|
brandStore.fetchConfig()
|
||||||
},
|
},
|
||||||
onPullDownRefresh() {
|
onPullDownRefresh() {
|
||||||
this.loadData().finally(() => { uni.stopPullDownRefresh() })
|
Promise.all([this.loadData(), brandStore.fetchConfig()])
|
||||||
|
.finally(() => { uni.stopPullDownRefresh() })
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async loadData() {
|
async loadData() {
|
||||||
@@ -331,25 +374,6 @@
|
|||||||
onAvatarError() { this.showDefaultAvatar = true },
|
onAvatarError() { this.showDefaultAvatar = true },
|
||||||
goToSettings() { uni.showToast({ title: '设置页面', icon: 'none' }) },
|
goToSettings() { uni.showToast({ title: '设置页面', icon: 'none' }) },
|
||||||
viewAllSignIn() { uni.showToast({ title: '全部签到记录', icon: 'none' }) }
|
viewAllSignIn() { uni.showToast({ title: '全部签到记录', icon: 'none' }) }
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
avatarSrc() {
|
|
||||||
if (this.showDefaultAvatar) return defaultAvatar
|
|
||||||
return this.userInfo.avatar || defaultAvatar
|
|
||||||
},
|
|
||||||
genderLabel() {
|
|
||||||
const g = this.userInfo.gender
|
|
||||||
if (g === 1 || g === 'MALE' || g === '男') return '男'
|
|
||||||
if (g === 2 || g === 'FEMALE' || g === '女') return '女'
|
|
||||||
return '未设置'
|
|
||||||
},
|
|
||||||
genderIndex() {
|
|
||||||
const genderMap = { 0: 'UNKNOWN', 1: 'MALE', 2: 'FEMALE' }
|
|
||||||
const genderStr = typeof this.editForm.gender === 'string'
|
|
||||||
? this.editForm.gender
|
|
||||||
: genderMap[this.editForm.gender] || 'UNKNOWN'
|
|
||||||
return Math.max(0, this.genderOptions.findIndex(o => o.value === genderStr))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="search-page">
|
<view class="search-page">
|
||||||
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
|
<view class="header-wrap" :style="headerStyle">
|
||||||
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
|
<view class="nav-bar" :style="navStyle">
|
||||||
<text class="nav-title">⌕ 团课搜索</text>
|
<text class="nav-title">⌕ 团课搜索</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="search-area">
|
<view class="search-area" :style="{ backgroundColor: brandConfig.secondaryColor }">
|
||||||
<view class="search-box">
|
<view class="search-box">
|
||||||
<text class="search-symbol">⌕</text>
|
<text class="search-symbol">⌕</text>
|
||||||
<input class="search-input" v-model="searchKeyword" placeholder="搜索课程名称、教练、场地..."
|
<input class="search-input" v-model="searchKeyword" placeholder="搜索课程名称、教练、场地..."
|
||||||
@@ -31,7 +31,8 @@
|
|||||||
|
|
||||||
<view class="period-row">
|
<view class="period-row">
|
||||||
<view v-for="(period, idx) in periods" :key="idx" class="period-item"
|
<view v-for="(period, idx) in periods" :key="idx" class="period-item"
|
||||||
:class="{ active: currentPeriod === period.value }" @click="selectPeriod(period.value)">
|
:class="{ active: currentPeriod === period.value }" @click="selectPeriod(period.value)"
|
||||||
|
:style="currentPeriod === period.value ? activeBrandStyle : ''">
|
||||||
<text>{{ period.label }}</text>
|
<text>{{ period.label }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -40,18 +41,20 @@
|
|||||||
|
|
||||||
<view class="type-section">
|
<view class="type-section">
|
||||||
<view class="type-all-row">
|
<view class="type-all-row">
|
||||||
<view class="filter-tab" :class="{ active: currentFilter === 'all' }" @click="switchFilter('all')">全部</view>
|
<view class="filter-tab" :class="{ active: currentFilter === 'all' }" @click="switchFilter('all')"
|
||||||
|
:style="currentFilter === 'all' ? activeBrandStyle : ''">全部</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="type-grid" :class="{ expanded: typesExpanded }">
|
<view class="type-grid" :class="{ expanded: typesExpanded }">
|
||||||
<view v-for="(row, ri) in typeRows" :key="ri" class="type-row" :class="{ 'row-visible': isRowVisible(ri) }">
|
<view v-for="(row, ri) in typeRows" :key="ri" class="type-row" :class="{ 'row-visible': isRowVisible(ri) }">
|
||||||
<view v-for="(tab, ti) in row" :key="ti" class="filter-tab"
|
<view v-for="(tab, ti) in row" :key="ti" class="filter-tab"
|
||||||
:class="{ active: currentFilter === tab.value }" @click="selectType(tab)">
|
:class="{ active: currentFilter === tab.value }" @click="selectType(tab)"
|
||||||
|
:style="currentFilter === tab.value ? activeBrandStyle : ''">
|
||||||
{{ tab.label }}
|
{{ tab.label }}
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="typeRows.length > 1" class="type-expand-row">
|
<view v-if="typeRows.length > 1" class="type-expand-row">
|
||||||
<text class="type-expand-btn" @click="typesExpanded = !typesExpanded">
|
<text class="type-expand-btn" @click="typesExpanded = !typesExpanded" :style="{ color: brandConfig.primaryColor }">
|
||||||
{{ typesExpanded ? '收起 ▲' : '展开更多 ▾' }}
|
{{ typesExpanded ? '收起 ▲' : '展开更多 ▾' }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -61,7 +64,8 @@
|
|||||||
<text class="result-count">共 {{ filteredCourses.length }} 个课程</text>
|
<text class="result-count">共 {{ filteredCourses.length }} 个课程</text>
|
||||||
<view class="sort-options">
|
<view class="sort-options">
|
||||||
<text v-for="(sort, idx) in sortOptions" :key="idx" class="sort-item"
|
<text v-for="(sort, idx) in sortOptions" :key="idx" class="sort-item"
|
||||||
:class="{ active: currentSort === sort.value }" @click="switchSort(sort.value)">
|
:class="{ active: currentSort === sort.value }" @click="switchSort(sort.value)"
|
||||||
|
:style="currentSort === sort.value ? 'color:' + brandConfig.primaryColor : ''">
|
||||||
{{ sort.label }}
|
{{ sort.label }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -88,20 +92,20 @@
|
|||||||
<view class="course-body">
|
<view class="course-body">
|
||||||
<view class="course-top">
|
<view class="course-top">
|
||||||
<text class="course-name">{{ course.courseName }}</text>
|
<text class="course-name">{{ course.courseName }}</text>
|
||||||
<text class="course-price" :class="{ free: course.storedValueAmount === 0 }">
|
<text class="course-price" :class="{ free: course.storedValueAmount === 0 }" :style="{ color: brandConfig.primaryColor }">
|
||||||
{{ course.storedValueAmount > 0 ? '¥' + course.storedValueAmount + '/次' : '免费' }}
|
{{ course.storedValueAmount > 0 ? '¥' + course.storedValueAmount + '/次' : '免费' }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="course-labels" v-if="course.labels && course.labels.length">
|
<view class="course-labels" v-if="course.labels && course.labels.length">
|
||||||
<text v-for="(label, li) in course.labels" :key="li" class="course-label"
|
<text v-for="(label, li) in course.labels" :key="li" class="course-label"
|
||||||
:style="{ background: label.color + '1a', color: label.color }">{{ label.labelName }}</text>
|
:style="label._style">{{ label.labelName }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="course-meta">
|
<view class="course-meta">
|
||||||
<text class="meta-icon">🕓</text>
|
<text class="meta-icon">🕓</text>
|
||||||
<text class="course-date">{{ course.shortDate }}</text>
|
<text class="course-date">{{ course.shortDate }}</text>
|
||||||
<text class="course-time">{{ course.shortTime }} - {{ course.endShortTime }}</text>
|
<text class="course-time">{{ course.shortTime }} - {{ course.endShortTime }}</text>
|
||||||
<text class="course-duration" v-if="course.duration">{{ course.duration }}</text>
|
<text class="course-duration" v-if="course.duration" :style="{ color: brandConfig.primaryColor }">{{ course.duration }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="course-meta">
|
<view class="course-meta">
|
||||||
<text class="meta-icon">📍</text>
|
<text class="meta-icon">📍</text>
|
||||||
@@ -110,7 +114,8 @@
|
|||||||
<view class="course-bottom">
|
<view class="course-bottom">
|
||||||
<text class="course-capacity">已预约 {{ course.currentMembers }}/{{ course.maxMembers }}人</text>
|
<text class="course-capacity">已预约 {{ course.currentMembers }}/{{ course.maxMembers }}人</text>
|
||||||
<button class="btn-book" :class="{ disabled: !course.canBook }" :disabled="!course.canBook"
|
<button class="btn-book" :class="{ disabled: !course.canBook }" :disabled="!course.canBook"
|
||||||
@click.stop="bookCourse(course)">
|
@click.stop="bookCourse(course)"
|
||||||
|
:style="course.canBook ? activeBrandStyle : ''">
|
||||||
{{ course.canBook ? '立即预约' : course.statusLabel }}
|
{{ course.canBook ? '立即预约' : course.statusLabel }}
|
||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
@@ -133,6 +138,7 @@
|
|||||||
const courseApi = require('../../api/course')
|
const courseApi = require('../../api/course')
|
||||||
const bookingApi = require('../../api/booking')
|
const bookingApi = require('../../api/booking')
|
||||||
const store = require('../../store/index')
|
const store = require('../../store/index')
|
||||||
|
const brandStore = require('../../store/brand')
|
||||||
const { resolveCoverUrl } = require('../../utils/request')
|
const { resolveCoverUrl } = require('../../utils/request')
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -161,7 +167,8 @@
|
|||||||
{ label: '热度', value: 'popular' }
|
{ label: '热度', value: 'popular' }
|
||||||
],
|
],
|
||||||
courses: [],
|
courses: [],
|
||||||
activeBookedCourseIds: new Set() // 用户当前已预约(status=0)的课程ID集合
|
activeBookedCourseIds: new Set(), // 用户当前已预约(status=0)的课程ID集合
|
||||||
|
brandConfig: brandStore.config
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLoad() {
|
onLoad() {
|
||||||
@@ -179,15 +186,28 @@
|
|||||||
this.totalHeaderHeight = statusBarHeight + navBarHeight
|
this.totalHeaderHeight = statusBarHeight + navBarHeight
|
||||||
this.loadCourses()
|
this.loadCourses()
|
||||||
this.loadTypes()
|
this.loadTypes()
|
||||||
|
uni.$on('brand:updated', (config) => { this.brandConfig = config })
|
||||||
|
},
|
||||||
|
onUnload() {
|
||||||
|
uni.$off('brand:updated')
|
||||||
},
|
},
|
||||||
onShow() {
|
onShow() {
|
||||||
this.loadActiveBookings()
|
this.loadActiveBookings()
|
||||||
},
|
},
|
||||||
onPullDownRefresh() {
|
onPullDownRefresh() {
|
||||||
Promise.all([this.loadCourses(), this.loadTypes(), this.loadActiveBookings()])
|
Promise.all([this.loadCourses(), this.loadTypes(), this.loadActiveBookings(), brandStore.fetchConfig()])
|
||||||
.finally(() => { uni.stopPullDownRefresh() })
|
.finally(() => { uni.stopPullDownRefresh() })
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
headerStyle() {
|
||||||
|
return 'padding-top:' + this.statusBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor
|
||||||
|
},
|
||||||
|
navStyle() {
|
||||||
|
return 'height:' + this.navBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor
|
||||||
|
},
|
||||||
|
activeBrandStyle() {
|
||||||
|
return 'background:' + this.brandConfig.primaryColor + ';color:' + this.brandConfig.secondaryColor
|
||||||
|
},
|
||||||
// 将类型列表按每行 COLS_PER_ROW 个拆分为二维数组
|
// 将类型列表按每行 COLS_PER_ROW 个拆分为二维数组
|
||||||
typeRows() {
|
typeRows() {
|
||||||
const tabs = this.courseTypes.map(t => ({
|
const tabs = this.courseTypes.map(t => ({
|
||||||
@@ -266,7 +286,7 @@
|
|||||||
status: statusStr,
|
status: statusStr,
|
||||||
statusLabel: statusLabel,
|
statusLabel: statusLabel,
|
||||||
storedValueAmount: c.storedValueAmount != null ? c.storedValueAmount : 0,
|
storedValueAmount: c.storedValueAmount != null ? c.storedValueAmount : 0,
|
||||||
labels: this.getTypeLabels(c.courseType),
|
labels: this.getTypeLabels(c.courseType).map(l => ({ ...l, _style: { background: l.color + '1a', color: l.color } })),
|
||||||
coverImage: resolveCoverUrl(c.coverImage),
|
coverImage: resolveCoverUrl(c.coverImage),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -305,6 +325,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
labelStyle(label) {
|
||||||
|
return { background: label.color + '1a', color: label.color }
|
||||||
|
},
|
||||||
// 从已加载的课程类型中匹配标签
|
// 从已加载的课程类型中匹配标签
|
||||||
getTypeLabels(courseType) {
|
getTypeLabels(courseType) {
|
||||||
const type = this.getTypeInfo(courseType)
|
const type = this.getTypeInfo(courseType)
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
const brandApi = require('../api/brand')
|
||||||
|
|
||||||
|
const BRAND_CACHE_KEY = 'brand_config_cache'
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG = {
|
||||||
|
brandName: 'Novalon健身房',
|
||||||
|
slogan: '专业健身,从这里开始',
|
||||||
|
primaryColor: '#00E676',
|
||||||
|
primaryColorRgb: '0,230,118',
|
||||||
|
secondaryColor: '#1A1A1A',
|
||||||
|
secondaryColorRgb: '26,26,26',
|
||||||
|
logoUrl: '',
|
||||||
|
backgroundImageUrl: '',
|
||||||
|
updatedAt: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 品牌方案 Store
|
||||||
|
*
|
||||||
|
* 响应式策略:fetchConfig 更新后通过 uni.$emit('brand:updated', config) 广播,
|
||||||
|
* 页面通过 uni.$on 监听并在 data 中维护副本,确保 UI 自动刷新。
|
||||||
|
*/
|
||||||
|
const brandStore = {
|
||||||
|
_config: null,
|
||||||
|
_loaded: false,
|
||||||
|
|
||||||
|
/** 获取当前品牌配置(优先内存,其次缓存,最后默认值) */
|
||||||
|
get config() {
|
||||||
|
if (this._config) return this._config
|
||||||
|
try {
|
||||||
|
const cached = uni.getStorageSync(BRAND_CACHE_KEY)
|
||||||
|
if (cached && cached.primaryColor) {
|
||||||
|
this._config = cached
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
return { ...DEFAULT_CONFIG }
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 是否已从后端加载完成 */
|
||||||
|
get isLoaded() {
|
||||||
|
return this._loaded
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从后端拉取品牌配置
|
||||||
|
* 策略:对比 updatedAt,有变化时更新缓存并广播事件通知全部页面
|
||||||
|
* @returns {Promise<boolean>} 是否有变化
|
||||||
|
*/
|
||||||
|
async fetchConfig() {
|
||||||
|
try {
|
||||||
|
const res = await brandApi.getBrandConfig()
|
||||||
|
if (res) {
|
||||||
|
const newConfig = {
|
||||||
|
brandName: res.brandName || DEFAULT_CONFIG.brandName,
|
||||||
|
slogan: res.slogan || DEFAULT_CONFIG.slogan,
|
||||||
|
primaryColor: res.primaryColor || DEFAULT_CONFIG.primaryColor,
|
||||||
|
primaryColorRgb: res.primaryColorRgb || DEFAULT_CONFIG.primaryColorRgb,
|
||||||
|
secondaryColor: res.secondaryColor || DEFAULT_CONFIG.secondaryColor,
|
||||||
|
secondaryColorRgb: res.secondaryColorRgb || DEFAULT_CONFIG.secondaryColorRgb,
|
||||||
|
logoUrl: res.logoUrl || '',
|
||||||
|
backgroundImageUrl: res.backgroundImageUrl || '',
|
||||||
|
updatedAt: res.updatedAt || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._config || this._config.updatedAt !== newConfig.updatedAt) {
|
||||||
|
this._config = newConfig
|
||||||
|
try {
|
||||||
|
uni.setStorageSync(BRAND_CACHE_KEY, newConfig)
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
this._loaded = true
|
||||||
|
// 广播事件通知所有页面刷新
|
||||||
|
uni.$emit('brand:updated', newConfig)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[BrandStore] 获取品牌配置失败:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
this._loaded = true
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 应用 TabBar 品牌色 */
|
||||||
|
applyTabBar() {
|
||||||
|
try {
|
||||||
|
const cfg = this.config
|
||||||
|
uni.setTabBarStyle({
|
||||||
|
color: '#7A7E84',
|
||||||
|
selectedColor: cfg.primaryColor,
|
||||||
|
backgroundColor: cfg.secondaryColor,
|
||||||
|
borderStyle: 'black'
|
||||||
|
})
|
||||||
|
} catch (e) { /* 非 TabBar 页面忽略 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = brandStore
|
||||||
@@ -118,22 +118,22 @@ WHERE NOT EXISTS (SELECT 1 FROM course_type_label WHERE type_id = 103 AND label_
|
|||||||
|
|
||||||
-- 晨间瑜伽 | A教室 | 明天 08:00-09:00 | 教练100
|
-- 晨间瑜伽 | A教室 | 明天 08:00-09:00 | 教练100
|
||||||
INSERT INTO group_course (id, course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by, update_by, created_at, updated_at)
|
INSERT INTO group_course (id, course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by, update_by, created_at, updated_at)
|
||||||
SELECT 100, '晨间瑜伽', 100, 100, '2026-07-23 08:00:00'::TIMESTAMP, '2026-07-23 09:00:00'::TIMESTAMP, 20, 5, '0', 'A教室', '/static/course/e2e_yoga.jpg', 'E2E测试-晨间瑜伽课程,温和唤醒身体', 88.00, 'system', 'system', NOW(), NOW()
|
SELECT 100, '晨间瑜伽', 100, 100, '2026-07-27 08:00:00'::TIMESTAMP, '2026-07-27 09:00:00'::TIMESTAMP, 20, 5, '0', 'A教室', '/static/course/e2e_yoga.jpg', 'E2E测试-晨间瑜伽课程,温和唤醒身体', 88.00, 'system', 'system', NOW(), NOW()
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM group_course WHERE id = 100);
|
WHERE NOT EXISTS (SELECT 1 FROM group_course WHERE id = 100);
|
||||||
|
|
||||||
-- 动感燃脂 | 单车房 | 后天 19:00-20:00 | 教练101
|
-- 动感燃脂 | 单车房 | 后天 19:00-20:00 | 教练101
|
||||||
INSERT INTO group_course (id, course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by, update_by, created_at, updated_at)
|
INSERT INTO group_course (id, course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by, update_by, created_at, updated_at)
|
||||||
SELECT 101, '动感燃脂', 101, 101, '2026-07-24 19:00:00'::TIMESTAMP, '2026-07-24 20:00:00'::TIMESTAMP, 25, 10, '0', '单车房', '/static/course/e2e_spin.jpg', 'E2E测试-动感单车燃脂课程', 68.00, 'system', 'system', NOW(), NOW()
|
SELECT 101, '动感燃脂', 101, 101, '2026-07-28 19:00:00'::TIMESTAMP, '2026-07-28 20:00:00'::TIMESTAMP, 25, 10, '0', '单车房', '/static/course/e2e_spin.jpg', 'E2E测试-动感单车燃脂课程', 68.00, 'system', 'system', NOW(), NOW()
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM group_course WHERE id = 101);
|
WHERE NOT EXISTS (SELECT 1 FROM group_course WHERE id = 101);
|
||||||
|
|
||||||
-- 有氧搏击 | B教室 | 大后天 10:00-11:00 | 教练100
|
-- 有氧搏击 | B教室 | 大后天 10:00-11:00 | 教练100
|
||||||
INSERT INTO group_course (id, course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by, update_by, created_at, updated_at)
|
INSERT INTO group_course (id, course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by, update_by, created_at, updated_at)
|
||||||
SELECT 102, '有氧搏击', 100, 102, '2026-07-25 10:00:00'::TIMESTAMP, '2026-07-25 11:00:00'::TIMESTAMP, 20, 8, '0', 'B教室', '/static/course/e2e_boxing.jpg', 'E2E测试-有氧搏击操课程', 68.00, 'system', 'system', NOW(), NOW()
|
SELECT 102, '有氧搏击', 100, 102, '2026-07-29 10:00:00'::TIMESTAMP, '2026-07-29 11:00:00'::TIMESTAMP, 20, 8, '0', 'B教室', '/static/course/e2e_boxing.jpg', 'E2E测试-有氧搏击操课程', 68.00, 'system', 'system', NOW(), NOW()
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM group_course WHERE id = 102);
|
WHERE NOT EXISTS (SELECT 1 FROM group_course WHERE id = 102);
|
||||||
|
|
||||||
-- 普拉提入门 | 瑜伽房 | 4天后 14:00-15:00 | 教练101
|
-- 普拉提入门 | 瑜伽房 | 4天后 14:00-15:00 | 教练101
|
||||||
INSERT INTO group_course (id, course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by, update_by, created_at, updated_at)
|
INSERT INTO group_course (id, course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by, update_by, created_at, updated_at)
|
||||||
SELECT 103, '普拉提入门', 101, 103, '2026-07-26 14:00:00'::TIMESTAMP, '2026-07-26 15:00:00'::TIMESTAMP, 15, 3, '0', '瑜伽房', '/static/course/e2e_pilates.jpg', 'E2E测试-普拉提入门课程,适合初学者', 108.00, 'system', 'system', NOW(), NOW()
|
SELECT 103, '普拉提入门', 101, 103, '2026-07-30 14:00:00'::TIMESTAMP, '2026-07-30 15:00:00'::TIMESTAMP, 15, 3, '0', '瑜伽房', '/static/course/e2e_pilates.jpg', 'E2E测试-普拉提入门课程,适合初学者', 108.00, 'system', 'system', NOW(), NOW()
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM group_course WHERE id = 103);
|
WHERE NOT EXISTS (SELECT 1 FROM group_course WHERE id = 103);
|
||||||
|
|
||||||
-- ============================================
|
-- ============================================
|
||||||
@@ -158,9 +158,9 @@ ON CONFLICT (member_no) DO UPDATE SET
|
|||||||
|
|
||||||
INSERT INTO sign_in_record (id, member_id, sign_in_time, sign_in_type, sign_in_status, source, created_at, updated_at)
|
INSERT INTO sign_in_record (id, member_id, sign_in_time, sign_in_type, sign_in_status, source, created_at, updated_at)
|
||||||
VALUES
|
VALUES
|
||||||
(100, 100, '2026-07-22 08:30:00'::TIMESTAMP, 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', NOW(), NOW()),
|
(100, 100, '2026-07-26 08:30:00'::TIMESTAMP, 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', NOW(), NOW()),
|
||||||
(101, 101, '2026-07-22 09:00:00'::TIMESTAMP, 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', NOW(), NOW()),
|
(101, 101, '2026-07-26 09:00:00'::TIMESTAMP, 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', NOW(), NOW()),
|
||||||
(102, 100, '2026-07-22 18:00:00'::TIMESTAMP, 'MANUAL', 'SUCCESS', 'PC_BACKEND', NOW(), NOW())
|
(102, 100, '2026-07-26 18:00:00'::TIMESTAMP, 'MANUAL', 'SUCCESS', 'PC_BACKEND', NOW(), NOW())
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
-- ============================================
|
-- ============================================
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('品牌定制管理完整工作流', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
const testBrandName = `测试品牌_${Date.now()}`;
|
||||||
|
const testSlogan = `测试口号_${Date.now()}`;
|
||||||
|
const testPrimaryColor = '#FF5722';
|
||||||
|
const testSecondaryColor = '#37474F';
|
||||||
|
|
||||||
|
test('品牌定制页面可正常加载', async ({ page }) => {
|
||||||
|
await test.step('导航到品牌定制页面', async () => {
|
||||||
|
await page.goto('/brand');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('验证页面标题存在', async () => {
|
||||||
|
await expect(page.locator('h2')).toContainText('品牌定制', { timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('验证四个配置卡片渲染', async () => {
|
||||||
|
// Logo 设置卡片
|
||||||
|
await expect(page.locator('.brand-card').filter({ hasText: 'Logo 设置' })).toBeVisible({ timeout: 5000 });
|
||||||
|
// 背景图设置卡片
|
||||||
|
await expect(page.locator('.brand-card').filter({ hasText: '背景图设置' })).toBeVisible({ timeout: 5000 });
|
||||||
|
// 品牌配色卡片
|
||||||
|
await expect(page.locator('.brand-card').filter({ hasText: '品牌配色' })).toBeVisible({ timeout: 5000 });
|
||||||
|
// 品牌信息卡片
|
||||||
|
await expect(page.locator('.brand-card').filter({ hasText: '品牌信息' })).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('验证实时预览区域存在', async () => {
|
||||||
|
await expect(page.locator('.brand-card').filter({ hasText: '实时预览' })).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('验证 WebSocket 连接标签存在', async () => {
|
||||||
|
const wsTag = page.locator('.brand-page-header .el-tag');
|
||||||
|
await expect(wsTag).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('更新品牌信息', async ({ page }) => {
|
||||||
|
await test.step('导航到品牌定制', async () => {
|
||||||
|
await page.goto('/brand');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('填写品牌名称', async () => {
|
||||||
|
const brandCard = page.locator('.brand-card').filter({ hasText: '品牌信息' });
|
||||||
|
const nameInput = brandCard.locator('input').first();
|
||||||
|
await nameInput.clear();
|
||||||
|
await nameInput.fill(testBrandName);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('填写品牌口号', async () => {
|
||||||
|
const brandCard = page.locator('.brand-card').filter({ hasText: '品牌信息' });
|
||||||
|
const sloganInput = brandCard.locator('input').nth(1);
|
||||||
|
await sloganInput.clear();
|
||||||
|
await sloganInput.fill(testSlogan);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('点击保存信息', async () => {
|
||||||
|
const brandCard = page.locator('.brand-card').filter({ hasText: '品牌信息' });
|
||||||
|
await brandCard.locator('button:has-text("保存信息")').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('验证保存成功提示', async () => {
|
||||||
|
await expect(page.locator('.el-message--success').first()).toBeVisible({ timeout: 8000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('验证预览区域品牌名称更新', async () => {
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const mockBrandName = page.locator('.mock-brand-name');
|
||||||
|
await expect(mockBrandName).toContainText(testBrandName, { timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('更新品牌配色', async ({ page }) => {
|
||||||
|
await test.step('导航到品牌定制', async () => {
|
||||||
|
await page.goto('/brand');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('修改主色调', async () => {
|
||||||
|
const colorCard = page.locator('.brand-card').filter({ hasText: '品牌配色' });
|
||||||
|
// 主色调 HEX 输入框(.color-input 是 el-input 容器,需定位内部 <input>)
|
||||||
|
const primaryHexInput = colorCard.locator('.color-row').first().locator('input').first();
|
||||||
|
await primaryHexInput.clear();
|
||||||
|
await primaryHexInput.fill(testPrimaryColor);
|
||||||
|
// 触发 blur 验证
|
||||||
|
await primaryHexInput.blur();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('修改辅助色', async () => {
|
||||||
|
const colorCard = page.locator('.brand-card').filter({ hasText: '品牌配色' });
|
||||||
|
const secondaryHexInput = colorCard.locator('.color-row').nth(1).locator('input').first();
|
||||||
|
await secondaryHexInput.clear();
|
||||||
|
await secondaryHexInput.fill(testSecondaryColor);
|
||||||
|
await secondaryHexInput.blur();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('保存配色', async () => {
|
||||||
|
const colorCard = page.locator('.brand-card').filter({ hasText: '品牌配色' });
|
||||||
|
await colorCard.locator('button:has-text("保存配色")').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('验证保存成功', async () => {
|
||||||
|
await expect(page.locator('.el-message--success').first()).toBeVisible({ timeout: 8000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('品牌配色 - 输入非法HEX值应校验', async ({ page }) => {
|
||||||
|
await test.step('导航到品牌定制', async () => {
|
||||||
|
await page.goto('/brand');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('输入无效HEX值并尝试保存', async () => {
|
||||||
|
const colorCard = page.locator('.brand-card').filter({ hasText: '品牌配色' });
|
||||||
|
const primaryHexInput = colorCard.locator('.color-row').first().locator('input').first();
|
||||||
|
await primaryHexInput.clear();
|
||||||
|
await primaryHexInput.fill('not-a-color');
|
||||||
|
await primaryHexInput.blur();
|
||||||
|
|
||||||
|
await colorCard.locator('button:has-text("保存配色")').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('验证校验警告提示', async () => {
|
||||||
|
await expect(page.locator('.el-message--warning').first()).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('品牌信息 - 空名称校验', async ({ page }) => {
|
||||||
|
await test.step('导航到品牌定制', async () => {
|
||||||
|
await page.goto('/brand');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('清空品牌名称并保存', async () => {
|
||||||
|
const brandCard = page.locator('.brand-card').filter({ hasText: '品牌信息' });
|
||||||
|
const nameInput = brandCard.locator('input').first();
|
||||||
|
await nameInput.clear();
|
||||||
|
await brandCard.locator('button:has-text("保存信息")').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('验证警告提示', async () => {
|
||||||
|
await expect(page.locator('.el-message--warning').first()).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Logo 区域 - 上传组件正常渲染', async ({ page }) => {
|
||||||
|
await test.step('导航到品牌定制', async () => {
|
||||||
|
await page.goto('/brand');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('验证 Logo 上传区域存在', async () => {
|
||||||
|
const logoCard = page.locator('.brand-card').filter({ hasText: 'Logo 设置' });
|
||||||
|
await expect(logoCard.locator('.el-upload')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('背景图区域 - 上传组件正常渲染', async ({ page }) => {
|
||||||
|
await test.step('导航到品牌定制', async () => {
|
||||||
|
await page.goto('/brand');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('验证背景图上传区域存在', async () => {
|
||||||
|
const bgCard = page.locator('.brand-card').filter({ hasText: '背景图设置' });
|
||||||
|
await expect(bgCard.locator('.el-upload')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('实时预览 - 底部导航切换', async ({ page }) => {
|
||||||
|
await test.step('导航到品牌定制', async () => {
|
||||||
|
await page.goto('/brand');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('验证默认首页可见', async () => {
|
||||||
|
await expect(page.locator('.mock-brand-section')).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.locator('.mock-greeting-card')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('切换到课程tab', async () => {
|
||||||
|
await page.locator('.mock-tab-item').nth(1).click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await expect(page.locator('.mock-search-area')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('切换到我的课程tab', async () => {
|
||||||
|
await page.locator('.mock-tab-item').nth(2).click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await expect(page.locator('.mock-booking-card').first()).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('切换到我的tab', async () => {
|
||||||
|
await page.locator('.mock-tab-item').nth(3).click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await expect(page.locator('.mock-profile-card')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('切换回首页', async () => {
|
||||||
|
await page.locator('.mock-tab-item').first().click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await expect(page.locator('.mock-greeting-card')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('实时预览 - 课程详情钻取与返回', async ({ page }) => {
|
||||||
|
await test.step('导航到品牌定制', async () => {
|
||||||
|
await page.goto('/brand');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('点击今日推荐课程', async () => {
|
||||||
|
await page.locator('.mock-course-card').first().click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('验证课程详情页渲染', async () => {
|
||||||
|
await expect(page.locator('.mock-detail-header')).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.locator('.mock-detail-title')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('验证详情信息卡片', async () => {
|
||||||
|
await expect(page.locator('.mock-detail-info')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('点击返回按钮', async () => {
|
||||||
|
await page.locator('.mock-back-btn').click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('验证返回首页', async () => {
|
||||||
|
await expect(page.locator('.mock-greeting-card')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('实时预览 - 今日推荐查看更多进入搜索', async ({ page }) => {
|
||||||
|
await test.step('导航到品牌定制', async () => {
|
||||||
|
await page.goto('/brand');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('点击查看全部', async () => {
|
||||||
|
await page.locator('.mock-section-more').click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('验证进入课程搜索页', async () => {
|
||||||
|
await expect(page.locator('.mock-search-area')).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.locator('.mock-search-list')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Generated
+23
-56
@@ -145,8 +145,7 @@
|
|||||||
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
|
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "(Apache-2.0 AND BSD-3-Clause)",
|
"license": "(Apache-2.0 AND BSD-3-Clause)",
|
||||||
"optional": true,
|
"optional": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@csstools/color-helpers": {
|
"node_modules/@csstools/color-helpers": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
@@ -236,6 +235,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
},
|
},
|
||||||
@@ -276,6 +276,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
}
|
}
|
||||||
@@ -1116,7 +1117,6 @@
|
|||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"detect-libc": "^2.0.3",
|
"detect-libc": "^2.0.3",
|
||||||
"is-glob": "^4.0.3",
|
"is-glob": "^4.0.3",
|
||||||
@@ -1159,7 +1159,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -1181,7 +1180,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -1203,7 +1201,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -1225,7 +1222,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -1247,7 +1243,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -1269,7 +1264,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -1291,7 +1285,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -1313,7 +1306,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -1335,7 +1327,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -1357,7 +1348,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -1379,7 +1369,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -1401,7 +1390,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -1423,7 +1411,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -1439,7 +1426,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -1906,6 +1892,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/lodash": "*"
|
"@types/lodash": "*"
|
||||||
}
|
}
|
||||||
@@ -1916,6 +1903,7 @@
|
|||||||
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
|
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -1975,6 +1963,7 @@
|
|||||||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "6.21.0",
|
"@typescript-eslint/scope-manager": "6.21.0",
|
||||||
"@typescript-eslint/types": "6.21.0",
|
"@typescript-eslint/types": "6.21.0",
|
||||||
@@ -2606,6 +2595,7 @@
|
|||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2865,7 +2855,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"readdirp": "^4.0.1"
|
"readdirp": "^4.0.1"
|
||||||
},
|
},
|
||||||
@@ -2902,8 +2891,7 @@
|
|||||||
"integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==",
|
"integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/combined-stream": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
@@ -3125,7 +3113,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -3364,6 +3351,7 @@
|
|||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -4055,8 +4043,7 @@
|
|||||||
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
|
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
@@ -4421,13 +4408,15 @@
|
|||||||
"version": "4.17.23",
|
"version": "4.17.23",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/lodash-es": {
|
"node_modules/lodash-es": {
|
||||||
"version": "4.17.23",
|
"version": "4.17.23",
|
||||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
|
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
|
||||||
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
|
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/lodash-unified": {
|
"node_modules/lodash-unified": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
@@ -4648,8 +4637,7 @@
|
|||||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/nopt": {
|
"node_modules/nopt": {
|
||||||
"version": "7.2.1",
|
"version": "7.2.1",
|
||||||
@@ -5065,7 +5053,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14.18.0"
|
"node": ">= 14.18.0"
|
||||||
},
|
},
|
||||||
@@ -5250,7 +5237,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
@@ -5262,7 +5248,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^4.0.0",
|
"chokidar": "^4.0.0",
|
||||||
"immutable": "^5.1.5",
|
"immutable": "^5.1.5",
|
||||||
@@ -5285,7 +5270,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "^2.5.0",
|
"@bufbuild/protobuf": "^2.5.0",
|
||||||
"colorjs.io": "^0.5.0",
|
"colorjs.io": "^0.5.0",
|
||||||
@@ -5335,7 +5319,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"sass": "1.98.0"
|
"sass": "1.98.0"
|
||||||
}
|
}
|
||||||
@@ -5353,7 +5336,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
@@ -5371,7 +5353,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
@@ -5389,7 +5370,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
@@ -5407,7 +5387,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
@@ -5425,7 +5404,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
@@ -5443,7 +5421,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
@@ -5461,7 +5438,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
@@ -5479,7 +5455,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
@@ -5497,7 +5472,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
@@ -5515,7 +5489,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
@@ -5533,7 +5506,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
@@ -5551,7 +5523,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
@@ -5569,7 +5540,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
@@ -5587,7 +5557,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
@@ -5605,7 +5574,6 @@
|
|||||||
"!linux",
|
"!linux",
|
||||||
"!win32"
|
"!win32"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"sass": "1.98.0"
|
"sass": "1.98.0"
|
||||||
}
|
}
|
||||||
@@ -5623,7 +5591,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
@@ -5641,7 +5608,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
@@ -5653,7 +5619,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"has-flag": "^4.0.0"
|
"has-flag": "^4.0.0"
|
||||||
},
|
},
|
||||||
@@ -5960,7 +5925,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"sync-message-port": "^1.0.0"
|
"sync-message-port": "^1.0.0"
|
||||||
},
|
},
|
||||||
@@ -5975,7 +5939,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
}
|
}
|
||||||
@@ -6071,6 +6034,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -6176,8 +6140,7 @@
|
|||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "0BSD",
|
"license": "0BSD",
|
||||||
"optional": true,
|
"optional": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
@@ -6211,6 +6174,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -6249,8 +6213,7 @@
|
|||||||
"integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==",
|
"integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.1",
|
||||||
@@ -6258,6 +6221,7 @@
|
|||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -6366,6 +6330,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -6379,6 +6344,7 @@
|
|||||||
"integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==",
|
"integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "4.1.1",
|
"@vitest/expect": "4.1.1",
|
||||||
"@vitest/mocker": "4.1.1",
|
"@vitest/mocker": "4.1.1",
|
||||||
@@ -6480,6 +6446,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
|
||||||
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
|
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.30",
|
"@vue/compiler-dom": "3.5.30",
|
||||||
"@vue/compiler-sfc": "3.5.30",
|
"@vue/compiler-sfc": "3.5.30",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user