2026-03-17 12:13:21 +08:00
|
|
|
|
# 开发日志 - 2026-03-17
|
|
|
|
|
|
|
|
|
|
|
|
## 多地点登录支持实现
|
|
|
|
|
|
|
|
|
|
|
|
### 修改内容
|
|
|
|
|
|
|
|
|
|
|
|
#### 1. JwtTokenRedisService.java
|
|
|
|
|
|
|
|
|
|
|
|
**文件路径**: `reading-platform-java/src/main/java/com/reading/platform/common/security/JwtTokenRedisService.java`
|
|
|
|
|
|
|
|
|
|
|
|
**修改内容**:
|
|
|
|
|
|
- 修改 `validateToken()` 方法,移除了 token 一致性检查
|
|
|
|
|
|
- 现在只检查 token 是否在黑名单中
|
|
|
|
|
|
- 允许同一账号有多个有效的 token,支持多地点同时登录
|
|
|
|
|
|
|
|
|
|
|
|
**修改前逻辑**:
|
|
|
|
|
|
```java
|
|
|
|
|
|
// 检查是否与存储的 token 一致
|
|
|
|
|
|
String storedToken = getStoredToken(username);
|
|
|
|
|
|
if (storedToken == null) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
boolean isValid = token.equals(storedToken);
|
|
|
|
|
|
return isValid; // 如果不一致则返回 false,导致互踢下线
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**修改后逻辑**:
|
|
|
|
|
|
```java
|
|
|
|
|
|
// 仅检查是否在黑名单中
|
|
|
|
|
|
if (isBlacklisted(token)) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 不再检查是否与存储的 token 一致,允许同一账号有多个有效 token
|
|
|
|
|
|
return true;
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
#### 2. JwtAuthenticationFilter.java
|
|
|
|
|
|
|
|
|
|
|
|
**文件路径**: `reading-platform-java/src/main/java/com/reading/platform/common/security/JwtAuthenticationFilter.java`
|
|
|
|
|
|
|
|
|
|
|
|
**修改内容**:
|
|
|
|
|
|
- 新增依赖注入:`AdminUserMapper`, `TenantMapper`, `TeacherMapper`, `ParentMapper`
|
|
|
|
|
|
- 在 token 验证通过后,增加账户状态检查
|
|
|
|
|
|
- 新增 `isAccountActive()` 方法,根据用户角色查询对应表验证账户状态
|
|
|
|
|
|
|
|
|
|
|
|
**新增代码**:
|
|
|
|
|
|
```java
|
|
|
|
|
|
// 检查账户状态是否为 active
|
|
|
|
|
|
if (!isAccountActive(payload)) {
|
|
|
|
|
|
log.debug("Account is not active for user: {}", payload.getUsername());
|
|
|
|
|
|
sendError(response, HttpStatus.UNAUTHORIZED, "账户已被禁用,请联系管理员");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**新增方法**:
|
|
|
|
|
|
```java
|
|
|
|
|
|
private boolean isAccountActive(JwtPayload payload) {
|
|
|
|
|
|
String role = payload.getRole();
|
|
|
|
|
|
Long userId = payload.getUserId();
|
|
|
|
|
|
|
|
|
|
|
|
return switch (role) {
|
|
|
|
|
|
case "admin" -> {
|
|
|
|
|
|
AdminUser adminUser = adminUserMapper.selectById(userId);
|
|
|
|
|
|
yield adminUser != null && "active".equalsIgnoreCase(adminUser.getStatus());
|
|
|
|
|
|
}
|
|
|
|
|
|
case "school" -> {
|
|
|
|
|
|
Tenant tenant = tenantMapper.selectById(userId);
|
|
|
|
|
|
yield tenant != null && "active".equalsIgnoreCase(tenant.getStatus());
|
|
|
|
|
|
}
|
|
|
|
|
|
case "teacher" -> {
|
|
|
|
|
|
Teacher teacher = teacherMapper.selectById(userId);
|
|
|
|
|
|
yield teacher != null && "active".equalsIgnoreCase(teacher.getStatus());
|
|
|
|
|
|
}
|
|
|
|
|
|
case "parent" -> {
|
|
|
|
|
|
Parent parent = parentMapper.selectById(userId);
|
|
|
|
|
|
yield parent != null && "active".equalsIgnoreCase(parent.getStatus());
|
|
|
|
|
|
}
|
|
|
|
|
|
default -> false;
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
#### 3. AuthServiceImpl.java
|
|
|
|
|
|
|
|
|
|
|
|
**文件路径**: `reading-platform-java/src/main/java/com/reading/platform/service/impl/AuthServiceImpl.java`
|
|
|
|
|
|
|
|
|
|
|
|
**修改内容**:
|
|
|
|
|
|
- 更新 `logout()` 方法的注释,说明当前实现
|
|
|
|
|
|
|
|
|
|
|
|
#### 4. 状态判断忽略大小写修改
|
|
|
|
|
|
|
|
|
|
|
|
**修改内容**:
|
|
|
|
|
|
- 将所有 `"active".equals(status)` 修改为 `"active".equalsIgnoreCase(status)`
|
|
|
|
|
|
- 确保大小写不敏感的状态判断,如 "Active"、"ACTIVE"、"active" 都被识别为激活状态
|
|
|
|
|
|
|
|
|
|
|
|
**修改文件**:
|
|
|
|
|
|
- `JwtAuthenticationFilter.java` - `isAccountActive()` 方法中的 4 处判断
|
|
|
|
|
|
- `AuthServiceImpl.java` - `login()` 方法中的 8 处判断(admin、teacher、parent、tenant 各 2 处)
|
|
|
|
|
|
|
|
|
|
|
|
### 功能说明
|
|
|
|
|
|
|
|
|
|
|
|
#### 多地点登录
|
|
|
|
|
|
- 同一账号可以在多个设备/浏览器同时登录
|
|
|
|
|
|
- 各个登录状态的 token 都有效,不会互踢下线
|
|
|
|
|
|
- JWT token 本身的过期时间(默认 24 小时)保证安全性
|
|
|
|
|
|
|
|
|
|
|
|
#### 账户状态验证
|
|
|
|
|
|
- 每次请求都会验证账户状态是否为 "active"
|
|
|
|
|
|
- 如果管理员在后台禁用某个账号,该账号的所有已登录会话将立即失效
|
|
|
|
|
|
- 支持所有角色:admin、school、teacher、parent
|
|
|
|
|
|
|
|
|
|
|
|
#### 黑名单机制
|
|
|
|
|
|
- 黑名单机制仍然有效
|
|
|
|
|
|
- 可以用于主动使特定 token 失效(如踢人下线场景)
|
|
|
|
|
|
|
|
|
|
|
|
### 验证步骤
|
|
|
|
|
|
|
|
|
|
|
|
1. **多地点登录测试**:
|
|
|
|
|
|
- 使用同一账号在两个不同的浏览器登录
|
|
|
|
|
|
- 在两个浏览器都发起 API 请求
|
|
|
|
|
|
- 预期:两个登录都保持有效
|
|
|
|
|
|
|
|
|
|
|
|
2. **账户状态禁用测试**:
|
|
|
|
|
|
- 使用账号 A 在浏览器 1 登录
|
|
|
|
|
|
- 在超管后台将账号 A 的状态修改为"非激活"
|
|
|
|
|
|
- 在浏览器 1 再次发起请求,应返回"账户已被禁用"
|
|
|
|
|
|
|
|
|
|
|
|
### 安全性考虑
|
|
|
|
|
|
|
|
|
|
|
|
1. JWT token 有过期时间(默认 24 小时),过期后自动失效
|
|
|
|
|
|
2. 黑名单机制仍然有效,可以主动使特定 token 失效
|
|
|
|
|
|
3. 每次请求都会验证账户状态,确保禁用账号无法访问
|
|
|
|
|
|
|
|
|
|
|
|
### 影响范围
|
|
|
|
|
|
|
|
|
|
|
|
- 所有角色的登录认证流程
|
|
|
|
|
|
- Token 验证流程
|
|
|
|
|
|
- 账户状态检查
|
|
|
|
|
|
|
|
|
|
|
|
### 兼容性
|
|
|
|
|
|
|
|
|
|
|
|
- 此修改不影响现有功能
|
|
|
|
|
|
- 登出、黑名单等功能仍然正常工作
|
|
|
|
|
|
- 前端无需修改
|
2026-03-18 00:02:05 +08:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Admin 控制器三层架构规范化
|
|
|
|
|
|
|
|
|
|
|
|
### 修改内容
|
|
|
|
|
|
|
|
|
|
|
|
#### 1. AdminCourseCollectionController.java
|
|
|
|
|
|
|
|
|
|
|
|
**文件路径**: `reading-platform-java/src/main/java/com/reading/platform/controller/admin/AdminCourseCollectionController.java`
|
|
|
|
|
|
|
|
|
|
|
|
**问题**:
|
|
|
|
|
|
- `findAll` 返回 `Result<List<?>>` - 类型不明确
|
|
|
|
|
|
- `findOne` 返回 `Result<?>` - 类型不明确
|
|
|
|
|
|
- `create/update` 返回 `Result<CourseCollection>` - 直接返回 Entity
|
|
|
|
|
|
|
|
|
|
|
|
**修改后**:
|
|
|
|
|
|
- 重命名为 `page` 方法,返回 `Result<PageResult<CourseCollectionResponse>>`
|
|
|
|
|
|
- `findOne` 返回 `Result<CourseCollectionResponse>`
|
|
|
|
|
|
- `create` 返回 `Result<CourseCollectionResponse>`
|
|
|
|
|
|
- `update` 返回 `Result<CourseCollectionResponse>`
|
|
|
|
|
|
|
|
|
|
|
|
#### 2. CourseCollectionService.java
|
|
|
|
|
|
|
|
|
|
|
|
**文件路径**: `reading-platform-java/src/main/java/com/reading/platform/service/CourseCollectionService.java`
|
|
|
|
|
|
|
|
|
|
|
|
**新增方法**:
|
|
|
|
|
|
- `pageCollections(Integer pageNum, Integer pageSize, String status)` - 分页查询并转换为 Response
|
|
|
|
|
|
|
|
|
|
|
|
**修改方法**:
|
|
|
|
|
|
- `createCollection()` - 返回类型从 `CourseCollection` 改为 `CourseCollectionResponse`
|
|
|
|
|
|
- `updateCollection()` - 返回类型从 `CourseCollection` 改为 `CourseCollectionResponse`
|
|
|
|
|
|
|
|
|
|
|
|
#### 3. CourseCollectionPageQueryRequest.java(新建)
|
|
|
|
|
|
|
|
|
|
|
|
**文件路径**: `reading-platform-java/src/main/java/com/reading/platform/dto/request/CourseCollectionPageQueryRequest.java`
|
|
|
|
|
|
|
|
|
|
|
|
```java
|
|
|
|
|
|
@Data
|
|
|
|
|
|
@Schema(description = "课程套餐分页查询请求")
|
|
|
|
|
|
public class CourseCollectionPageQueryRequest {
|
|
|
|
|
|
@Schema(description = "页码", example = "1")
|
|
|
|
|
|
private Integer pageNum = 1;
|
|
|
|
|
|
|
|
|
|
|
|
@Schema(description = "每页数量", example = "10")
|
|
|
|
|
|
private Integer pageSize = 10;
|
|
|
|
|
|
|
|
|
|
|
|
@Schema(description = "状态")
|
|
|
|
|
|
private String status;
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 修改文件列表
|
|
|
|
|
|
|
|
|
|
|
|
| 文件 | 类型 | 说明 |
|
|
|
|
|
|
|------|------|------|
|
|
|
|
|
|
| `AdminCourseCollectionController.java` | 修改 | 规范化返回类型 |
|
|
|
|
|
|
| `CourseCollectionService.java` | 修改 | 新增分页方法,修改返回类型 |
|
|
|
|
|
|
| `CourseCollectionPageQueryRequest.java` | 新增 | 分页查询请求 DTO |
|
|
|
|
|
|
|
|
|
|
|
|
### 验证
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
export JAVA_HOME="/f/Java/jdk-17"
|
|
|
|
|
|
mvn clean compile -DskipTests
|
|
|
|
|
|
# BUILD SUCCESS
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 规范的控制器
|
|
|
|
|
|
|
|
|
|
|
|
| 控制器 | 状态 |
|
|
|
|
|
|
|--------|------|
|
|
|
|
|
|
| AdminCourseCollectionController | ✅ 已规范 |
|
|
|
|
|
|
| AdminStatsController | ✅ 已规范 |
|
|
|
|
|
|
| AdminTenantController | ✅ 已规范 |
|
|
|
|
|
|
| AdminResourceController | ✅ 已规范 |
|
|
|
|
|
|
| AdminCourseLessonController | ✅ 已规范 |
|
|
|
|
|
|
| AdminCourseController | ✅ 已规范 |
|
|
|
|
|
|
| AdminPackageController | ✅ 已规范 |
|
|
|
|
|
|
| AdminThemeController | ✅ 已规范 |
|
|
|
|
|
|
| AdminSettingsController | 🟡 可选(设置类接口允许使用 Map) |
|
2026-03-18 09:34:16 +08:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 排课计划参考功能实现
|
|
|
|
|
|
|
|
|
|
|
|
### 背景
|
|
|
|
|
|
|
|
|
|
|
|
学校端排课功能需要显示排课计划参考数据,帮助老师了解课程包下的课程安排建议。用户说明:"因为我们一个套餐是一个课程体系,在这个课程体系下,有多个课程包,每个课程包下,有导入课、集体课、五大领域课,我们提供的排课计划参考是为了给学校老师指引,告诉他们课程包下的这三类课如何上,才能发挥最大的价值。"
|
|
|
|
|
|
|
|
|
|
|
|
### 新建排课流程
|
|
|
|
|
|
|
|
|
|
|
|
1. 选择课程套餐 → 选择课程包 → 显示排课计划参考
|
|
|
|
|
|
2. 单选课程类型(导入课/集体课/五大领域课)
|
|
|
|
|
|
3. 选择班级 → 指定授课教师
|
|
|
|
|
|
4. 设置授课时间
|
|
|
|
|
|
|
|
|
|
|
|
### 后端修改
|
|
|
|
|
|
|
|
|
|
|
|
#### 1. CoursePackageResponse.java
|
|
|
|
|
|
|
|
|
|
|
|
**文件路径**: `reading-platform-java/src/main/java/com/reading/platform/dto/response/CoursePackageResponse.java`
|
|
|
|
|
|
|
|
|
|
|
|
**修改内容**: 在 `CoursePackageCourseItem` 内部类中添加 `scheduleRefData` 和 `lessonType` 字段
|
|
|
|
|
|
|
|
|
|
|
|
```java
|
|
|
|
|
|
@Schema(description = "排课计划参考数据(JSON)")
|
|
|
|
|
|
private String scheduleRefData;
|
|
|
|
|
|
|
|
|
|
|
|
@Schema(description = "课程类型")
|
|
|
|
|
|
private String lessonType;
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
#### 2. CoursePackageService.java
|
|
|
|
|
|
|
|
|
|
|
|
**文件路径**: `reading-platform-java/src/main/java/com/reading/platform/service/CoursePackageService.java`
|
|
|
|
|
|
|
|
|
|
|
|
**修改内容**: 修改 `toResponse()` 方法,填充 `scheduleRefData` 和 `lessonType`
|
|
|
|
|
|
|
|
|
|
|
|
```java
|
|
|
|
|
|
if (course != null) {
|
|
|
|
|
|
item.setId(course.getId());
|
|
|
|
|
|
item.setName(course.getName());
|
|
|
|
|
|
item.setScheduleRefData(course.getScheduleRefData());
|
|
|
|
|
|
item.setLessonType(course.getLessonType());
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
#### 3. CourseCollectionService.java
|
|
|
|
|
|
|
|
|
|
|
|
**文件路径**: `reading-platform-java/src/main/java/com/reading/platform/service/CourseCollectionService.java`
|
|
|
|
|
|
|
|
|
|
|
|
**修改内容**:
|
|
|
|
|
|
- 添加依赖注入:`CoursePackageCourseMapper`, `CourseMapper`
|
|
|
|
|
|
- 添加导入:`Course`, `CoursePackageCourse`
|
|
|
|
|
|
- 完全重写 `toPackageResponse()` 方法,包含课程列表查询逻辑
|
|
|
|
|
|
|
|
|
|
|
|
```java
|
|
|
|
|
|
private CoursePackageResponse toPackageResponse(CoursePackage pkg) {
|
|
|
|
|
|
// 查询课程包关联的课程
|
|
|
|
|
|
List<CoursePackageCourse> packageCourses = packageCourseMapper.selectList(
|
|
|
|
|
|
new LambdaQueryWrapper<CoursePackageCourse>()
|
|
|
|
|
|
.eq(CoursePackageCourse::getPackageId, pkg.getId())
|
|
|
|
|
|
.orderByAsc(CoursePackageCourse::getSortOrder)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
List<CoursePackageResponse.CoursePackageCourseItem> courseItems = ...;
|
|
|
|
|
|
// 构建 courseItems 列表,包含 scheduleRefData
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 前端修改
|
|
|
|
|
|
|
|
|
|
|
|
#### 1. school.ts
|
|
|
|
|
|
|
|
|
|
|
|
**文件路径**: `reading-platform-frontend/src/api/school.ts`
|
|
|
|
|
|
|
|
|
|
|
|
**修改内容**: 在 `CoursePackage` 接口的 `courses` 数组项中添加 `scheduleRefData` 字段
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
courses?: Array<{
|
|
|
|
|
|
id: number;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
gradeLevel: string;
|
|
|
|
|
|
sortOrder: number;
|
|
|
|
|
|
scheduleRefData?: string; // 新增字段
|
|
|
|
|
|
}>;
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
#### 2. CreateScheduleModal.vue
|
|
|
|
|
|
|
|
|
|
|
|
**文件路径**: `reading-platform-frontend/src/views/school/schedule/components/CreateScheduleModal.vue`
|
|
|
|
|
|
|
|
|
|
|
|
**修改内容**: 优化 `selectPackage()` 方法,直接从响应数据中提取排课计划参考
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
const selectPackage = async (packageId: number) => {
|
|
|
|
|
|
formData.packageId = packageId;
|
|
|
|
|
|
formData.courseId = undefined;
|
|
|
|
|
|
|
|
|
|
|
|
// 加载排课计划参考(从选中的课程包中获取)
|
|
|
|
|
|
if (selectedCollection.value?.packages) {
|
|
|
|
|
|
const selectedPkg = selectedCollection.value.packages.find((p: any) => p.id === packageId);
|
|
|
|
|
|
if (selectedPkg?.courses && selectedPkg.courses.length > 0) {
|
|
|
|
|
|
const firstCourse = selectedPkg.courses[0];
|
|
|
|
|
|
if (firstCourse.scheduleRefData) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const parsedData = JSON.parse(firstCourse.scheduleRefData);
|
|
|
|
|
|
scheduleRefData.value = Array.isArray(parsedData) ? parsedData : [];
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
scheduleRefData.value = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await loadLessonTypes(packageId);
|
|
|
|
|
|
};
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### API 验证
|
|
|
|
|
|
|
|
|
|
|
|
**请求**:
|
|
|
|
|
|
```
|
|
|
|
|
|
GET /api/v1/school/packages/5/packages
|
|
|
|
|
|
Authorization: Bearer {token}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**响应**:
|
|
|
|
|
|
```json
|
|
|
|
|
|
{
|
|
|
|
|
|
"code": 200,
|
|
|
|
|
|
"data": [{
|
|
|
|
|
|
"id": "5",
|
|
|
|
|
|
"name": "完整阅读能力培养套餐",
|
|
|
|
|
|
"courses": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": "6",
|
|
|
|
|
|
"name": "小猪佩奇绘本阅读",
|
|
|
|
|
|
"gradeLevel": "小班",
|
|
|
|
|
|
"sortOrder": 1,
|
|
|
|
|
|
"scheduleRefData": null,
|
|
|
|
|
|
"lessonType": null
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}]
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 下一步工作
|
|
|
|
|
|
|
|
|
|
|
|
1. 在数据库中添加示例排课计划参考数据(`course.schedule_ref_data` 字段)
|
|
|
|
|
|
2. 考虑添加 `collectionId` 存储(需要数据库迁移和 DTO 更新)
|
|
|
|
|
|
|
|
|
|
|
|
### Git 提交
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
commit 9f89ce7
|
|
|
|
|
|
feat: 添加课程包排课计划参考数据返回
|
|
|
|
|
|
```
|