feat: 租户套餐枚举优化与学校端课程查询实现

- 新增 TenantPackageStatus 枚举类,消除 status 字段魔法值
- 修改 TenantPackage 实体 status 字段类型为枚举
- 更新 CoursePackageService、TenantServiceImpl、CourseLessonService 使用枚举
- 实现学校端课程查询接口 /api/v1/school/courses
- 新增 CourseService.getTenantPackageCourses() 方法查询租户套餐下的课程
- 前端新增 Course 类型定义

共修改 26 个文件,新增 609 行,删除 83 行
This commit is contained in:
En 2026-03-17 13:42:01 +08:00
parent dfbf89e8fe
commit bb7fb86c3b
26 changed files with 609 additions and 83 deletions

View File

@ -71,6 +71,7 @@ export interface CreateTenantDto {
contactPerson?: string;
contactPhone?: string;
packageType?: string;
packageId?: number;
teacherQuota?: number;
studentQuota?: number;
startDate?: string;
@ -129,6 +130,21 @@ export interface PopularCourse {
teacherCount: number;
}
export interface CoursePackage {
id: number;
name: string;
description: string;
price: number;
discountPrice?: number;
discountType?: string;
gradeLevels?: string[];
courseCount: number;
status: string;
publishedAt?: string;
createdAt?: string;
updatedAt?: string;
}
export interface AdminSettings {
// Basic settings
systemName?: string;
@ -207,6 +223,11 @@ export const getActiveTenants = (limit?: number) =>
export const getPopularCourses = (limit?: number) =>
http.get<PopularCourse[]>('/v1/admin/stats/courses/popular', { params: { limit } });
// ==================== 课程套餐 ====================
export const getPublishedPackages = () =>
http.get<CoursePackage[]>('/v1/admin/packages/all');
// ==================== 系统设置 ====================
export const getAdminSettings = () =>

View File

@ -334,11 +334,34 @@ export const updateSettings = (data: UpdateSettingsDto) =>
// ==================== 课程管理 ====================
export interface Course {
id: number;
tenantId?: number;
name: string;
code?: string;
description?: string;
coverUrl?: string;
coverImagePath?: string;
category?: string;
ageRange?: string;
difficultyLevel?: string;
durationMinutes?: number;
objectives?: string;
status: string;
isSystem: number;
version?: string;
usageCount?: number;
teacherCount?: number;
createdAt?: string;
updatedAt?: string;
publishedAt?: string;
}
export const getSchoolCourses = () =>
http.get<any[]>('/v1/school/courses');
http.get<Course[]>('/v1/school/courses');
export const getSchoolCourse = (id: number) =>
http.get<any>(`/v1/school/courses/${id}`);
http.get<Course>(`/v1/school/courses/${id}`);
// ==================== 班级教师管理 ====================

View File

@ -34,6 +34,9 @@ declare module 'vue' {
ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent']
ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader']
ALayoutSider: typeof import('ant-design-vue/es')['LayoutSider']
AList: typeof import('ant-design-vue/es')['List']
AListItem: typeof import('ant-design-vue/es')['ListItem']
AListItemMeta: typeof import('ant-design-vue/es')['ListItemMeta']
AMenu: typeof import('ant-design-vue/es')['Menu']
AMenuDivider: typeof import('ant-design-vue/es')['MenuDivider']
AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
@ -48,6 +51,7 @@ declare module 'vue' {
ARate: typeof import('ant-design-vue/es')['Rate']
ARow: typeof import('ant-design-vue/es')['Row']
ASelect: typeof import('ant-design-vue/es')['Select']
ASelectOptGroup: typeof import('ant-design-vue/es')['SelectOptGroup']
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
ASkeleton: typeof import('ant-design-vue/es')['Skeleton']
ASpace: typeof import('ant-design-vue/es')['Space']
@ -55,6 +59,8 @@ declare module 'vue' {
AStatistic: typeof import('ant-design-vue/es')['Statistic']
AStep: typeof import('ant-design-vue/es')['Step']
ASteps: typeof import('ant-design-vue/es')['Steps']
ASubMenu: typeof import('ant-design-vue/es')['SubMenu']
ASwitch: typeof import('ant-design-vue/es')['Switch']
ATable: typeof import('ant-design-vue/es')['Table']
ATabPane: typeof import('ant-design-vue/es')['TabPane']
ATabs: typeof import('ant-design-vue/es')['Tabs']
@ -62,6 +68,8 @@ declare module 'vue' {
ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATimeRangePicker: typeof import('ant-design-vue/es')['TimeRangePicker']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
ATypographyText: typeof import('ant-design-vue/es')['TypographyText']
AUpload: typeof import('ant-design-vue/es')['Upload']
FilePreviewModal: typeof import('./components/FilePreviewModal.vue')['default']
FileUploader: typeof import('./components/course/FileUploader.vue')['default']
LessonConfigPanel: typeof import('./components/course/LessonConfigPanel.vue')['default']

View File

@ -170,11 +170,15 @@
<a-input v-model:value="formData.address" placeholder="请输入学校地址" />
</a-form-item>
<a-form-item label="套餐类型" name="packageType">
<a-select v-model:value="formData.packageType">
<a-select-option value="BASIC">基础版</a-select-option>
<a-select-option value="STANDARD">标准版</a-select-option>
<a-select-option value="ADVANCED">高级版</a-select-option>
<a-select-option value="CUSTOM">定制版</a-select-option>
<a-select v-model:value="formData.packageType" @change="handlePackageTypeChange">
<a-select-option value="">请选择套餐</a-select-option>
<a-select-option
v-for="pkg in packageList"
:key="pkg.id"
:value="pkg.name"
>
{{ pkg.name }} ({{ formatPackagePrice(pkg.discountPrice || pkg.price) }})
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="教师配额" name="teacherQuota">
@ -348,10 +352,12 @@ import {
updateTenantStatus,
resetTenantPassword,
deleteTenant,
getPublishedPackages,
type Tenant,
type TenantDetail,
type CreateTenantDto,
type UpdateTenantDto,
type CoursePackage,
} from '@/api/admin';
//
@ -414,7 +420,8 @@ const formData = reactive<CreateTenantDto & { dateRange?: string[] }>({
contactPerson: '',
contactPhone: '',
address: '',
packageType: 'STANDARD',
packageType: '',
packageId: undefined,
teacherQuota: 20,
studentQuota: 200,
startDate: '',
@ -444,6 +451,35 @@ const quotaForm = reactive({
const drawerVisible = ref(false);
const detailData = ref<TenantDetail | null>(null);
//
const packageList = ref<CoursePackage[]>([]);
//
const formatPackagePrice = (priceInCents: number) => {
return (priceInCents / 100).toFixed(2);
};
//
const handlePackageTypeChange = (value: string) => {
const selectedPackage = packageList.value.find(pkg => pkg.name === value);
if (selectedPackage) {
// ID
formData.packageId = selectedPackage.id;
//
if (selectedPackage.name.includes('基础')) {
formData.teacherQuota = 10;
formData.studentQuota = 100;
} else if (selectedPackage.name.includes('标准')) {
formData.teacherQuota = 20;
formData.studentQuota = 200;
} else if (selectedPackage.name.includes('高级')) {
formData.teacherQuota = 50;
formData.studentQuota = 500;
}
}
};
//
const loadData = async () => {
loading.value = true;
@ -464,6 +500,16 @@ const loadData = async () => {
}
};
//
const loadPackageList = async () => {
try {
const res = await getPublishedPackages();
packageList.value = res;
} catch (error) {
console.error('加载套餐列表失败', error);
}
};
//
const handleSearch = () => {
pagination.current = 1;
@ -497,7 +543,8 @@ const showAddModal = () => {
contactPerson: '',
contactPhone: '',
address: '',
packageType: 'STANDARD',
packageType: '',
packageId: undefined,
teacherQuota: 20,
studentQuota: 200,
});
@ -695,6 +742,7 @@ const getStatusText = (status: string) => {
//
onMounted(() => {
loadData();
loadPackageList();
});
</script>

View File

@ -34,6 +34,9 @@ public enum ErrorCode {
TENANT_EXPIRED(3002, "Tenant has expired"),
TENANT_DISABLED(3003, "Tenant is disabled"),
// Package Errors (3100+)
PACKAGE_NOT_FOUND(3101, "Package not found"),
// User Errors (4000+)
USER_NOT_FOUND(4001, "User not found"),
USER_ALREADY_EXISTS(4002, "User already exists"),

View File

@ -0,0 +1,34 @@
package com.reading.platform.common.enums;
import lombok.Getter;
/**
* 租户套餐状态枚举
*/
@Getter
public enum TenantPackageStatus {
ACTIVE("ACTIVE", "激活"),
EXPIRED("EXPIRED", "已过期");
private final String code;
private final String description;
TenantPackageStatus(String code, String description) {
this.code = code;
this.description = description;
}
/**
* 根据 code 获取枚举
*/
public static TenantPackageStatus fromCode(String code) {
for (TenantPackageStatus status : values()) {
if (status.getCode().equals(code)) {
return status;
}
}
return ACTIVE;
}
}

View File

@ -6,6 +6,7 @@ import com.reading.platform.common.enums.UserRole;
import com.reading.platform.common.response.PageResult;
import com.reading.platform.common.response.Result;
import com.reading.platform.dto.request.CourseCreateRequest;
import com.reading.platform.dto.request.CoursePageQueryRequest;
import com.reading.platform.dto.request.CourseUpdateRequest;
import com.reading.platform.dto.response.CourseResponse;
import com.reading.platform.entity.Course;
@ -17,18 +18,21 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
@Tag(name = "Admin - Course", description = "System Course Management APIs for Admin")
/**
* 课程管理控制器超管端
*/
@RestController
@RequestMapping("/api/v1/admin/courses")
@RequiredArgsConstructor
@Slf4j
@RequireRole(UserRole.ADMIN)
@Tag(name = "超管端 - 课程管理")
public class AdminCourseController {
private final CourseService courseService;
@Operation(summary = "Create system course")
@PostMapping
@Operation(summary = "创建课程")
public Result<Course> createCourse(@Valid @RequestBody CourseCreateRequest request) {
log.info("收到课程创建请求name={}, themeId={}, gradeTags={}", request.getName(), request.getThemeId(), request.getGradeTags());
try {
@ -41,51 +45,57 @@ public class AdminCourseController {
}
}
@Operation(summary = "Update course")
@PutMapping("/{id}")
@Operation(summary = "更新课程")
public Result<Course> updateCourse(@PathVariable Long id, @RequestBody CourseUpdateRequest request) {
return Result.success(courseService.updateCourse(id, request));
}
@Operation(summary = "Get course by ID")
@GetMapping("/{id}")
@Operation(summary = "查询课程详情")
public Result<CourseResponse> getCourse(@PathVariable Long id) {
return Result.success(courseService.getCourseByIdWithLessons(id));
}
@Operation(summary = "Get system course page")
@GetMapping
public Result<PageResult<Course>> getCoursePage(
@RequestParam(required = false, defaultValue = "1") Integer pageNum,
@RequestParam(required = false, defaultValue = "10") Integer pageSize,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String category,
@RequestParam(required = false) String status,
@RequestParam(required = false, defaultValue = "false") Boolean reviewOnly) {
@Operation(summary = "分页查询课程")
public Result<PageResult<Course>> getCoursePage(CoursePageQueryRequest request) {
log.info("查询课程列表pageNum={}, pageSize={}, keyword={}, category={}, status={}, reviewOnly={}",
pageNum, pageSize, keyword, category, status, reviewOnly);
Page<Course> page = courseService.getSystemCoursePage(pageNum, pageSize, keyword, category, status, reviewOnly);
request.getPageNum(), request.getPageSize(), request.getKeyword(), request.getCategory(), request.getStatus(), request.getReviewOnly());
// 页码
// 每页数量
// 关键词
// 分类
// 状态
// 是否仅查询待审核
Page<Course> page = courseService.getSystemCoursePage(
request.getPageNum(),
request.getPageSize(),
request.getKeyword(),
request.getCategory(),
request.getStatus(),
request.getReviewOnly());
PageResult<Course> result = PageResult.of(page);
log.info("课程列表查询结果total={}, list={}", result.getTotal(), result.getList().size());
return Result.success(result);
}
@Operation(summary = "Delete course")
@DeleteMapping("/{id}")
@Operation(summary = "删除课程")
public Result<Void> deleteCourse(@PathVariable Long id) {
courseService.deleteCourse(id);
return Result.success();
}
@Operation(summary = "Publish course")
@PostMapping("/{id}/publish")
@Operation(summary = "发布课程")
public Result<Void> publishCourse(@PathVariable Long id) {
courseService.publishCourse(id);
return Result.success();
}
@Operation(summary = "Archive course")
@PostMapping("/{id}/archive")
@Operation(summary = "归档课程")
public Result<Void> archiveCourse(@PathVariable Long id) {
courseService.archiveCourse(id);
return Result.success();

View File

@ -129,6 +129,12 @@ public class AdminPackageController {
return Result.success();
}
@GetMapping("/all")
@Operation(summary = "查询所有已发布的套餐列表")
public Result<List<CoursePackage>> getPublishedPackages() {
return Result.success(packageService.findPublishedPackages());
}
@PostMapping("/{id}/grant")
@Operation(summary = "授权套餐给租户")
@RequireRole(UserRole.ADMIN)

View File

@ -3,74 +3,84 @@ package com.reading.platform.controller.admin;
import com.reading.platform.common.annotation.RequireRole;
import com.reading.platform.common.enums.UserRole;
import com.reading.platform.common.response.Result;
import com.reading.platform.dto.request.ActiveTenantsQueryRequest;
import com.reading.platform.dto.request.PopularCoursesQueryRequest;
import com.reading.platform.dto.request.RecentActivitiesQueryRequest;
import com.reading.platform.dto.response.ActiveTenantItemResponse;
import com.reading.platform.dto.response.PopularCourseItemResponse;
import com.reading.platform.dto.response.RecentActivityItemResponse;
import com.reading.platform.dto.response.StatsResponse;
import com.reading.platform.dto.response.StatsTrendResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 超管端 - 统计管理
* 统计管理控制器超管端
*/
@Tag(name = "超管 - 统计管理", description = "Admin Stats APIs")
@RestController
@RequestMapping("/api/v1/admin/stats")
@RequiredArgsConstructor
@RequireRole(UserRole.ADMIN)
@Tag(name = "超管端 - 统计管理")
public class AdminStatsController {
@Operation(summary = "获取统计数据")
@GetMapping
public Result<Map<String, Object>> getStats() {
@Operation(summary = "获取统计数据")
public Result<StatsResponse> getStats() {
// TODO: 实现统计数据查询
Map<String, Object> stats = new HashMap<>();
stats.put("totalTenants", 0);
stats.put("activeTenants", 0);
stats.put("totalTeachers", 0);
stats.put("totalStudents", 0);
stats.put("totalCourses", 0);
stats.put("totalLessons", 0);
return Result.success(stats);
return Result.success(StatsResponse.builder()
.totalTenants(0L)
.activeTenants(0L)
.totalTeachers(0L)
.totalStudents(0L)
.totalCourses(0L)
.totalLessons(0L)
.build());
}
@Operation(summary = "获取趋势数据")
@GetMapping("/trend")
public Result<Map<String, Object>> getTrendData() {
@Operation(summary = "获取趋势数据")
public Result<StatsTrendResponse> getTrendData() {
// TODO: 实现趋势数据查询
Map<String, Object> trend = new HashMap<>();
trend.put("dates", new String[]{});
trend.put("newStudents", new Integer[]{});
trend.put("newTeachers", new Integer[]{});
trend.put("newCourses", new Integer[]{});
return Result.success(trend);
return Result.success(StatsTrendResponse.builder()
.dates(new ArrayList<>())
.newStudents(new ArrayList<>())
.newTeachers(new ArrayList<>())
.newCourses(new ArrayList<>())
.build());
}
@Operation(summary = "获取活跃租户")
@GetMapping("/tenants/active")
public Result<List<Map<String, Object>>> getActiveTenants(@RequestParam(required = false, defaultValue = "5") Integer limit) {
@Operation(summary = "获取活跃租户")
public Result<List<ActiveTenantItemResponse>> getActiveTenants(@ModelAttribute ActiveTenantsQueryRequest request) {
// 返回数量限制
// TODO: 实现活跃租户查询
return Result.success(new ArrayList<>());
}
@Operation(summary = "获取热门课程")
@GetMapping("/courses/popular")
public Result<List<Map<String, Object>>> getPopularCourses(@RequestParam(required = false, defaultValue = "5") Integer limit) {
@Operation(summary = "获取热门课程")
public Result<List<PopularCourseItemResponse>> getPopularCourses(@ModelAttribute PopularCoursesQueryRequest request) {
// 返回数量限制
// TODO: 实现热门课程查询
return Result.success(new ArrayList<>());
}
@Operation(summary = "获取最近活动")
@GetMapping("/activities")
public Result<List<Map<String, Object>>> getRecentActivities(@RequestParam(required = false, defaultValue = "10") Integer limit) {
@Operation(summary = "获取最近活动")
public Result<List<RecentActivityItemResponse>> getRecentActivities(@ModelAttribute RecentActivitiesQueryRequest request) {
// 返回数量限制
// TODO: 实现最近活动查询
return Result.success(new ArrayList<>());
}
}

View File

@ -1,39 +1,45 @@
package com.reading.platform.controller.school;
import com.reading.platform.common.response.Result;
import com.reading.platform.entity.Course;
import com.reading.platform.service.CourseService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 课程管理控制器学校端
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/school/courses")
@RequiredArgsConstructor
@Tag(name = "学校端 - 课程管理")
public class SchoolCourseController {
private final CourseService courseService;
@GetMapping
@Operation(summary = "获取学校课程列表")
public Result<List<Map<String, Object>>> getSchoolCourses() {
List<Map<String, Object>> courses = new ArrayList<>();
// For now, return empty list
// TODO: Implement tenant course query
public Result<List<Course>> getSchoolCourses() {
log.info("获取学校课程列表");
// TODO: SecurityContext 获取当前登录用户所属租户 ID
// 临时使用 tenantId = 1 作为测试
Long tenantId = 1L;
List<Course> courses = courseService.getTenantPackageCourses(tenantId);
return Result.success(courses);
}
@GetMapping("/{id}")
@Operation(summary = "获取课程详情")
public Result<Map<String, Object>> getSchoolCourse(@PathVariable Long id) {
Map<String, Object> course = new HashMap<>();
// TODO: Implement course detail query
public Result<Course> getSchoolCourse(@PathVariable Long id) {
log.info("获取课程详情id={}", id);
Course course = courseService.getCourseById(id);
return Result.success(course);
}
}

View File

@ -0,0 +1,15 @@
package com.reading.platform.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 活跃租户查询请求
*/
@Data
@Schema(description = "活跃租户查询请求")
public class ActiveTenantsQueryRequest {
@Schema(description = "返回数量限制", example = "5")
private Integer limit = 5;
}

View File

@ -0,0 +1,30 @@
package com.reading.platform.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 课程分页查询请求
*/
@Data
@Schema(description = "课程分页查询请求")
public class CoursePageQueryRequest {
@Schema(description = "页码", example = "1")
private Integer pageNum = 1;
@Schema(description = "每页数量", example = "10")
private Integer pageSize = 10;
@Schema(description = "关键词")
private String keyword;
@Schema(description = "分类")
private String category;
@Schema(description = "状态")
private String status;
@Schema(description = "是否仅查询待审核", example = "false")
private Boolean reviewOnly = false;
}

View File

@ -0,0 +1,15 @@
package com.reading.platform.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 热门课程查询请求
*/
@Data
@Schema(description = "热门课程查询请求")
public class PopularCoursesQueryRequest {
@Schema(description = "返回数量限制", example = "5")
private Integer limit = 5;
}

View File

@ -0,0 +1,15 @@
package com.reading.platform.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 最近活动查询请求
*/
@Data
@Schema(description = "最近活动查询请求")
public class RecentActivitiesQueryRequest {
@Schema(description = "返回数量限制", example = "10")
private Integer limit = 10;
}

View File

@ -49,6 +49,9 @@ public class TenantCreateRequest {
@Schema(description = "结束日期")
private LocalDate expireDate;
@Schema(description = "课程套餐 ID可选")
private Long packageId;
@Schema(description = "过期时间(兼容旧字段)")
@Deprecated
private LocalDateTime expireAt;

View File

@ -0,0 +1,30 @@
package com.reading.platform.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
/**
* 活跃租户项响应
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "活跃租户项响应")
public class ActiveTenantItemResponse {
@Schema(description = "租户 ID")
private Long tenantId;
@Schema(description = "租户名称")
private String tenantName;
@Schema(description = "活跃用户数")
private Integer activeUsers;
@Schema(description = "课程使用数")
private Integer courseCount;
}

View File

@ -0,0 +1,30 @@
package com.reading.platform.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
/**
* 热门课程项响应
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "热门课程项响应")
public class PopularCourseItemResponse {
@Schema(description = "课程 ID")
private Long courseId;
@Schema(description = "课程名称")
private String courseName;
@Schema(description = "使用次数")
private Integer usageCount;
@Schema(description = "教师数量")
private Integer teacherCount;
}

View File

@ -0,0 +1,38 @@
package com.reading.platform.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import java.time.LocalDateTime;
/**
* 最近活动项响应
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "最近活动项响应")
public class RecentActivityItemResponse {
@Schema(description = "活动 ID")
private Long activityId;
@Schema(description = "活动类型")
private String activityType;
@Schema(description = "活动描述")
private String description;
@Schema(description = "操作人 ID")
private Long operatorId;
@Schema(description = "操作人名称")
private String operatorName;
@Schema(description = "操作时间")
private LocalDateTime operationTime;
}

View File

@ -0,0 +1,36 @@
package com.reading.platform.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
/**
* 统计数据响应
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "统计数据响应")
public class StatsResponse {
@Schema(description = "租户总数")
private Long totalTenants;
@Schema(description = "活跃租户数")
private Long activeTenants;
@Schema(description = "教师总数")
private Long totalTeachers;
@Schema(description = "学生总数")
private Long totalStudents;
@Schema(description = "课程总数")
private Long totalCourses;
@Schema(description = "课时总数")
private Long totalLessons;
}

View File

@ -0,0 +1,32 @@
package com.reading.platform.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import java.util.List;
/**
* 趋势数据响应
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "趋势数据响应")
public class StatsTrendResponse {
@Schema(description = "日期列表")
private List<String> dates;
@Schema(description = "新增学生数列表")
private List<Integer> newStudents;
@Schema(description = "新增教师数列表")
private List<Integer> newTeachers;
@Schema(description = "新增课程数列表")
private List<Integer> newCourses;
}

View File

@ -1,6 +1,7 @@
package com.reading.platform.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.reading.platform.common.enums.TenantPackageStatus;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@ -32,6 +33,6 @@ public class TenantPackage extends BaseEntity {
private Long pricePaid;
@Schema(description = "状态ACTIVE、EXPIRED")
private String status;
private TenantPackageStatus status;
}

View File

@ -3,6 +3,7 @@ package com.reading.platform.service;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.reading.platform.common.enums.TenantPackageStatus;
import com.reading.platform.common.exception.BusinessException;
import com.reading.platform.entity.*;
import com.reading.platform.mapper.*;
@ -399,7 +400,7 @@ public class CourseLessonService extends ServiceImpl<CourseLessonMapper, CourseL
Long count = tenantPackageMapper.selectCount(
new LambdaQueryWrapper<TenantPackage>()
.eq(TenantPackage::getTenantId, tenantId)
.eq(TenantPackage::getStatus, "ACTIVE")
.eq(TenantPackage::getStatus, TenantPackageStatus.ACTIVE)
);
if (count == 0) {

View File

@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.reading.platform.common.enums.CourseStatus;
import com.reading.platform.common.enums.TenantPackageStatus;
import com.reading.platform.common.exception.BusinessException;
import com.reading.platform.common.response.PageResult;
import com.reading.platform.dto.response.CoursePackageResponse;
@ -111,7 +112,7 @@ public class CoursePackageService extends ServiceImpl<CoursePackageMapper, Cours
Long tenantCount = tenantPackageMapper.selectCount(
new LambdaQueryWrapper<TenantPackage>()
.eq(TenantPackage::getPackageId, pkg.getId())
.eq(TenantPackage::getStatus, "ACTIVE")
.eq(TenantPackage::getStatus, TenantPackageStatus.ACTIVE)
);
response.setTenantCount(tenantCount.intValue());
@ -229,7 +230,7 @@ public class CoursePackageService extends ServiceImpl<CoursePackageMapper, Cours
Long tenantCount = tenantPackageMapper.selectCount(
new LambdaQueryWrapper<TenantPackage>()
.eq(TenantPackage::getPackageId, id)
.eq(TenantPackage::getStatus, "ACTIVE")
.eq(TenantPackage::getStatus, TenantPackageStatus.ACTIVE)
);
if (tenantCount > 0) {
@ -371,7 +372,7 @@ public class CoursePackageService extends ServiceImpl<CoursePackageMapper, Cours
List<TenantPackage> tenantPackages = tenantPackageMapper.selectList(
new LambdaQueryWrapper<TenantPackage>()
.eq(TenantPackage::getTenantId, tenantId)
.eq(TenantPackage::getStatus, "ACTIVE")
.eq(TenantPackage::getStatus, TenantPackageStatus.ACTIVE)
.orderByDesc(TenantPackage::getCreatedAt)
);
@ -409,7 +410,7 @@ public class CoursePackageService extends ServiceImpl<CoursePackageMapper, Cours
if (existing != null) {
existing.setEndDate(endDate);
existing.setStatus("ACTIVE");
existing.setStatus(TenantPackageStatus.ACTIVE);
if (pricePaid != null) {
existing.setPricePaid(pricePaid);
}
@ -422,11 +423,25 @@ public class CoursePackageService extends ServiceImpl<CoursePackageMapper, Cours
tp.setPackageId(packageId);
tp.setStartDate(LocalDate.now());
tp.setEndDate(endDate);
tp.setStatus("ACTIVE");
tp.setStatus(TenantPackageStatus.ACTIVE);
tp.setPricePaid(pricePaid != null ? pricePaid : 0L);
tp.setCreatedAt(LocalDateTime.now());
tenantPackageMapper.insert(tp);
log.info("租户套餐新办成功tenantId={}, packageId={}", tenantId, packageId);
}
}
/**
* 查询所有已发布的套餐列表
*/
public List<CoursePackage> findPublishedPackages() {
log.info("查询所有已发布的套餐列表");
LambdaQueryWrapper<CoursePackage> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(CoursePackage::getStatus, CourseStatus.PUBLISHED.getCode())
.orderByDesc(CoursePackage::getCreatedAt);
List<CoursePackage> packages = packageMapper.selectList(wrapper);
log.info("查询所有已发布的套餐列表成功count={}", packages.size());
return packages;
}
}

View File

@ -43,4 +43,9 @@ public interface CourseService extends com.baomidou.mybatisplus.extension.servic
List<Course> getCoursesByTenantId(Long tenantId);
/**
* 查询租户套餐下的课程
*/
List<Course> getTenantPackageCourses(Long tenantId);
}

View File

@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.reading.platform.common.enums.CourseStatus;
import com.reading.platform.common.enums.ErrorCode;
import com.reading.platform.common.enums.TenantPackageStatus;
import com.reading.platform.common.exception.BusinessException;
import com.reading.platform.dto.request.CourseCreateRequest;
import com.reading.platform.dto.request.CourseUpdateRequest;
@ -13,8 +14,12 @@ import com.reading.platform.dto.response.CourseResponse;
import com.reading.platform.dto.response.LessonStepResponse;
import com.reading.platform.entity.Course;
import com.reading.platform.entity.CourseLesson;
import com.reading.platform.entity.CoursePackageCourse;
import com.reading.platform.entity.LessonStep;
import com.reading.platform.entity.TenantPackage;
import com.reading.platform.mapper.CourseMapper;
import com.reading.platform.mapper.CoursePackageCourseMapper;
import com.reading.platform.mapper.TenantPackageMapper;
import com.reading.platform.service.CourseLessonService;
import com.reading.platform.service.CourseService;
import lombok.RequiredArgsConstructor;
@ -36,6 +41,8 @@ public class CourseServiceImpl extends ServiceImpl<CourseMapper, Course>
private final CourseMapper courseMapper;
private final CourseLessonService courseLessonService;
private final TenantPackageMapper tenantPackageMapper;
private final CoursePackageCourseMapper packageCourseMapper;
@Override
@Transactional
@ -453,6 +460,51 @@ public class CourseServiceImpl extends ServiceImpl<CourseMapper, Course>
);
}
@Override
public List<Course> getTenantPackageCourses(Long tenantId) {
log.info("查询租户套餐下的课程tenantId={}", tenantId);
// 1. 查询租户的套餐关联
List<TenantPackage> tenantPackages = tenantPackageMapper.selectList(
new LambdaQueryWrapper<TenantPackage>()
.eq(TenantPackage::getTenantId, tenantId)
.eq(TenantPackage::getStatus, TenantPackageStatus.ACTIVE)
);
if (tenantPackages.isEmpty()) {
log.info("租户套餐下的课程查询结果为空tenantId={}", tenantId);
return List.of();
}
// 2. 获取套餐 ID 列表
List<Long> packageIds = tenantPackages.stream()
.map(TenantPackage::getPackageId)
.collect(Collectors.toList());
// 3. 查询套餐包含的课程 ID
List<CoursePackageCourse> packageCourses = packageCourseMapper.selectList(
new LambdaQueryWrapper<CoursePackageCourse>()
.in(CoursePackageCourse::getPackageId, packageIds)
);
if (packageCourses.isEmpty()) {
log.info("租户套餐下没有关联的课程tenantId={}", tenantId);
return List.of();
}
// 4. 获取课程 ID 列表
List<Long> courseIds = packageCourses.stream()
.map(CoursePackageCourse::getCourseId)
.distinct()
.collect(Collectors.toList());
// 5. 查询课程详情
List<Course> courses = courseMapper.selectBatchIds(courseIds);
log.info("查询租户套餐下的课程成功tenantId={}, count={}", tenantId, courses.size());
return courses;
}
/**
* 将空字符串转为 null避免 MySQL JSON 列报错空串不是有效 JSON
*/

View File

@ -3,11 +3,16 @@ package com.reading.platform.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.reading.platform.common.enums.ErrorCode;
import com.reading.platform.common.enums.TenantPackageStatus;
import com.reading.platform.common.exception.BusinessException;
import com.reading.platform.dto.request.TenantCreateRequest;
import com.reading.platform.dto.request.TenantUpdateRequest;
import com.reading.platform.entity.CoursePackage;
import com.reading.platform.entity.Tenant;
import com.reading.platform.entity.TenantPackage;
import com.reading.platform.mapper.TenantMapper;
import com.reading.platform.mapper.TenantPackageMapper;
import com.reading.platform.service.CoursePackageService;
import com.reading.platform.service.TenantService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -15,6 +20,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.util.List;
/**
@ -27,6 +33,8 @@ public class TenantServiceImpl extends com.baomidou.mybatisplus.extension.servic
implements TenantService {
private final TenantMapper tenantMapper;
private final TenantPackageMapper tenantPackageMapper;
private final CoursePackageService coursePackageService;
@Override
@Transactional
@ -52,14 +60,7 @@ public class TenantServiceImpl extends com.baomidou.mybatisplus.extension.servic
tenant.setAddress(request.getAddress());
tenant.setLogoUrl(request.getLogoUrl());
// 使用新字段
tenant.setPackageType(request.getPackageType() != null ? request.getPackageType() : "STANDARD");
tenant.setTeacherQuota(request.getTeacherQuota() != null ? request.getTeacherQuota() : 20);
tenant.setStudentQuota(request.getStudentQuota() != null ? request.getStudentQuota() : 200);
tenant.setStartDate(request.getStartDate());
tenant.setExpireDate(request.getExpireDate());
// 兼容旧字段
// 设置有效期相关字段兼容旧字段
if (request.getExpireAt() != null) {
tenant.setExpireAt(request.getExpireAt());
}
@ -72,10 +73,48 @@ public class TenantServiceImpl extends com.baomidou.mybatisplus.extension.servic
tenant.setStatus("ACTIVE");
// 使用 MP insert 方法
tenantMapper.insert(tenant);
// 如果传入了 packageId查询套餐信息并填充相关字段
if (request.getPackageId() != null) {
CoursePackage coursePackage = coursePackageService.getById(request.getPackageId());
if (coursePackage == null) {
log.warn("课程套餐不存在packageId: {}", request.getPackageId());
throw new BusinessException(ErrorCode.PACKAGE_NOT_FOUND, "课程套餐不存在");
}
// 根据套餐信息填充租户字段
tenant.setPackageType(coursePackage.getName());
tenant.setTeacherQuota(request.getTeacherQuota() != null ? request.getTeacherQuota() : 20);
tenant.setStudentQuota(request.getStudentQuota() != null ? request.getStudentQuota() : 200);
tenant.setStartDate(request.getStartDate());
tenant.setExpireDate(request.getExpireDate());
// 使用 MP insert 方法
tenantMapper.insert(tenant);
// 创建租户套餐关联记录
TenantPackage tenantPackage = new TenantPackage();
tenantPackage.setTenantId(tenant.getId());
tenantPackage.setPackageId(request.getPackageId());
tenantPackage.setStartDate(request.getStartDate() != null ? request.getStartDate() : LocalDate.now());
tenantPackage.setEndDate(request.getExpireDate());
tenantPackage.setPricePaid(coursePackage.getDiscountPrice() != null ? coursePackage.getDiscountPrice() : coursePackage.getPrice());
tenantPackage.setStatus(TenantPackageStatus.ACTIVE);
tenantPackageMapper.insert(tenantPackage);
log.info("租户创建成功并关联套餐ID: {}, packageId: {}", tenant.getId(), request.getPackageId());
} else {
// 没有传入 packageId使用原有逻辑
tenant.setPackageType(request.getPackageType() != null ? request.getPackageType() : "STANDARD");
tenant.setTeacherQuota(request.getTeacherQuota() != null ? request.getTeacherQuota() : 20);
tenant.setStudentQuota(request.getStudentQuota() != null ? request.getStudentQuota() : 200);
tenant.setStartDate(request.getStartDate());
tenant.setExpireDate(request.getExpireDate());
// 使用 MP insert 方法
tenantMapper.insert(tenant);
log.info("租户创建成功ID: {}", tenant.getId());
}
log.info("租户创建成功ID: {}", tenant.getId());
return tenant;
}