backend/src/main/java/com/tcm/training/admin/live/service/HealthCourseAppService.java
@@ -14,6 +14,7 @@
import com.tcm.training.admin.live.entity.HealthCourseVideo;
import com.tcm.training.admin.live.entity.LiveCourseCategory;
import com.tcm.training.admin.live.excel.HealthCourseExportRow;
import com.tcm.training.admin.live.excel.HealthCourseVideoImportRow;
import com.tcm.training.admin.live.mapper.ExpertMapper;
import com.tcm.training.admin.live.mapper.HealthCourseMapper;
import com.tcm.training.admin.live.mapper.HealthCourseVideoMapper;
@@ -23,11 +24,13 @@
import com.tcm.training.admin.live.vo.HealthCourseDetailVO;
import com.tcm.training.admin.live.vo.HealthCourseListItemVO;
import com.tcm.training.admin.live.vo.HealthCourseListRow;
import com.tcm.training.admin.live.vo.HealthCourseOptionVO;
import com.tcm.training.admin.live.vo.HealthCourseStatusVO;
import com.tcm.training.admin.live.vo.HealthCourseVideoDetailVO;
import com.tcm.training.admin.live.vo.HealthCourseVideoItemVO;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@@ -35,7 +38,13 @@
import java.net.URLEncoder;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
public class HealthCourseAppService {
@@ -138,7 +147,7 @@
                       Integer status) throws IOException {
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        response.setCharacterEncoding("utf-8");
        String filename = URLEncoder.encode("养身课程", "UTF-8").replace("+", "%20");
        String filename = URLEncoder.encode("养生课程", "UTF-8").replace("+", "%20");
        response.setHeader("Content-Disposition", "attachment;filename*=utf-8''" + filename + ".xlsx");
        List<HealthCourseListRow> rows = healthCourseMapper.selectCourseList(
@@ -165,15 +174,28 @@
            exports.add(export);
        }
        FastExcel.write(response.getOutputStream(), HealthCourseExportRow.class)
                .sheet("养身课程")
                .sheet("养生课程")
                .doWrite(exports);
    }
    public List<HealthCourseOptionVO> options(String keyword) {
        List<HealthCourse> courses = healthCourseMapper.selectList(new LambdaQueryWrapper<HealthCourse>()
                .eq(HealthCourse::getDeleted, 0)
                .like(keyword != null && !keyword.trim().isEmpty(), HealthCourse::getTitle, keyword == null ? null : keyword.trim())
                .orderByAsc(HealthCourse::getSort)
                .orderByDesc(HealthCourse::getId));
        List<HealthCourseOptionVO> list = new ArrayList<HealthCourseOptionVO>();
        for (HealthCourse course : courses) {
            list.add(new HealthCourseOptionVO(course.getId(), course.getTitle()));
        }
        return list;
    }
    public PageResponse<HealthCourseVideoItemVO> pageVideos(Long courseId,
                                                            int page,
                                                            int pageSize,
                                                            String chapterName,
                                                            Integer saleType) {
                                                            Integer priceType) {
        requireCourse(courseId);
        LambdaQueryWrapper<HealthCourseVideo> qw = new LambdaQueryWrapper<HealthCourseVideo>()
                .eq(HealthCourseVideo::getCourseId, courseId)
@@ -183,13 +205,55 @@
        if (chapterName != null && !chapterName.trim().isEmpty()) {
            qw.like(HealthCourseVideo::getChapterName, chapterName.trim());
        }
        if (saleType != null) {
            qw.eq(HealthCourseVideo::getSaleType, saleType);
        if (priceType != null) {
            if (priceType == 0) {
                qw.eq(HealthCourseVideo::getSalePrice, BigDecimal.ZERO);
            } else if (priceType == 1) {
                qw.gt(HealthCourseVideo::getSalePrice, BigDecimal.ZERO);
            }
        }
        IPage<HealthCourseVideo> result = healthCourseVideoMapper.selectPage(new Page<HealthCourseVideo>(page, pageSize), qw);
        List<HealthCourseVideoItemVO> list = new ArrayList<HealthCourseVideoItemVO>();
        for (HealthCourseVideo item : result.getRecords()) {
            list.add(toVideoItem(item));
        }
        return PageResponse.of(list, result.getTotal(), result.getCurrent(), result.getSize());
    }
    public PageResponse<HealthCourseVideoItemVO> pageVideoResources(int page,
                                                                    int pageSize,
                                                                    String courseKeyword,
                                                                    Long courseId,
                                                                    Integer courseType,
                                                                    String chapterName,
                                                                    Integer priceType,
                                                                    Integer status) {
        List<Long> matchedCourseIds = resolveMatchedCourseIds(courseKeyword, courseId);
        if (matchedCourseIds.isEmpty()) {
            return PageResponse.of(Collections.<HealthCourseVideoItemVO>emptyList(), 0, page, pageSize);
        }
        LambdaQueryWrapper<HealthCourseVideo> qw = new LambdaQueryWrapper<HealthCourseVideo>()
                .in(HealthCourseVideo::getCourseId, matchedCourseIds)
                .eq(HealthCourseVideo::getDeleted, 0)
                .eq(courseType != null, HealthCourseVideo::getCourseType, courseType)
                .eq(status != null, HealthCourseVideo::getStatus, status)
                .orderByAsc(HealthCourseVideo::getSort)
                .orderByDesc(HealthCourseVideo::getId);
        if (chapterName != null && !chapterName.trim().isEmpty()) {
            qw.like(HealthCourseVideo::getChapterName, chapterName.trim());
        }
        if (priceType != null) {
            if (priceType == 0) {
                qw.eq(HealthCourseVideo::getSalePrice, BigDecimal.ZERO);
            } else if (priceType == 1) {
                qw.gt(HealthCourseVideo::getSalePrice, BigDecimal.ZERO);
            }
        }
        IPage<HealthCourseVideo> result = healthCourseVideoMapper.selectPage(new Page<HealthCourseVideo>(page, pageSize), qw);
        Map<Long, String> courseTitleMap = resolveCourseTitleMap(extractCourseIds(result.getRecords()));
        List<HealthCourseVideoItemVO> list = new ArrayList<HealthCourseVideoItemVO>();
        for (HealthCourseVideo item : result.getRecords()) {
            list.add(toVideoItem(item, courseTitleMap.get(item.getCourseId())));
        }
        return PageResponse.of(list, result.getTotal(), result.getCurrent(), result.getSize());
    }
@@ -200,26 +264,14 @@
    @Transactional
    public HealthCourseVideoDetailVO createVideo(Long courseId, HealthCourseVideoSaveRequest req) {
        requireCourse(courseId);
        validateVideoSale(req);
        HealthCourseVideo next = new HealthCourseVideo();
        next.setCourseId(courseId);
        fillVideo(next, req);
        next.setSort(resolveNextVideoSort(courseId));
        next.setDeleted(0);
        healthCourseVideoMapper.insert(next);
        refreshCourseStats(courseId);
        return detailVideo(courseId, next.getId());
        req.setCourseId(courseId);
        return createVideoResource(req);
    }
    @Transactional
    public HealthCourseVideoDetailVO updateVideo(Long courseId, Long videoId, HealthCourseVideoSaveRequest req) {
        validateVideoSale(req);
        HealthCourseVideo video = requireVideo(courseId, videoId);
        fillVideo(video, req);
        healthCourseVideoMapper.updateById(video);
        refreshCourseStats(courseId);
        return detailVideo(courseId, videoId);
        req.setCourseId(courseId);
        return updateVideoResource(videoId, req);
    }
    @Transactional
@@ -227,6 +279,114 @@
        requireVideo(courseId, videoId);
        healthCourseVideoMapper.deleteById(videoId);
        refreshCourseStats(courseId);
    }
    public HealthCourseVideoDetailVO detailVideoResource(Long id) {
        HealthCourseVideo video = requireVideoById(id);
        return toVideoDetail(video, requireCourse(video.getCourseId()).getTitle());
    }
    @Transactional
    public HealthCourseVideoDetailVO createVideoResource(HealthCourseVideoSaveRequest req) {
        Long courseId = requireVideoCourseId(req.getCourseId());
        validateVideo(req);
        HealthCourseVideo next = new HealthCourseVideo();
        next.setCourseId(courseId);
        fillVideo(next, req);
        next.setSort(resolveNextVideoSort(courseId));
        next.setDeleted(0);
        healthCourseVideoMapper.insert(next);
        refreshCourseStats(courseId);
        return detailVideoResource(next.getId());
    }
    @Transactional
    public HealthCourseVideoDetailVO updateVideoResource(Long id, HealthCourseVideoSaveRequest req) {
        HealthCourseVideo video = requireVideoById(id);
        Long targetCourseId = req.getCourseId() == null ? video.getCourseId() : requireVideoCourseId(req.getCourseId());
        req.setCourseId(targetCourseId);
        validateVideo(req);
        Long oldCourseId = video.getCourseId();
        video.setCourseId(targetCourseId);
        fillVideo(video, req);
        healthCourseVideoMapper.updateById(video);
        refreshCourseStats(targetCourseId);
        if (!oldCourseId.equals(targetCourseId)) {
            refreshCourseStats(oldCourseId);
        }
        return detailVideoResource(id);
    }
    @Transactional
    public void deleteVideoResource(Long id) {
        HealthCourseVideo video = requireVideoById(id);
        healthCourseVideoMapper.deleteById(id);
        refreshCourseStats(video.getCourseId());
    }
    public void downloadVideoResourceTemplate(HttpServletResponse response) throws IOException {
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        response.setCharacterEncoding("utf-8");
        String filename = URLEncoder.encode("视频与课程资源导入模板", "UTF-8").replace("+", "%20");
        response.setHeader("Content-Disposition", "attachment;filename*=utf-8''" + filename + ".xlsx");
        List<HealthCourseVideoImportRow> rows = new ArrayList<HealthCourseVideoImportRow>();
        HealthCourseVideoImportRow sample = new HealthCourseVideoImportRow();
        sample.setCourseId(1L);
        sample.setCourseTitle("中医针灸资格证");
        sample.setCourseTypeText(courseTypeText(HealthCourseVideo.COURSE_TYPE_HEALTH));
        sample.setChapterName("方剂学");
        sample.setSalePrice(BigDecimal.ZERO);
        sample.setDurationSeconds(238);
        sample.setVideoUrl("https://example.com/video.mp4");
        sample.setStatusText(statusText(1));
        rows.add(sample);
        FastExcel.write(response.getOutputStream(), HealthCourseVideoImportRow.class)
                .sheet("视频资源导入模板")
                .doWrite(rows);
    }
    @Transactional
    public void importVideoResources(MultipartFile file) throws IOException {
        if (file == null || file.isEmpty()) {
            throw new IllegalArgumentException("请选择导入文件");
        }
        List<HealthCourseVideoImportRow> rows = FastExcel.read(file.getInputStream())
                .head(HealthCourseVideoImportRow.class)
                .sheet()
                .doReadSync();
        if (rows == null || rows.isEmpty()) {
            throw new IllegalArgumentException("导入文件不能为空");
        }
        Set<Long> touchedCourseIds = new LinkedHashSet<Long>();
        int rowNum = 1;
        for (HealthCourseVideoImportRow row : rows) {
            rowNum++;
            if (row == null || isImportRowEmpty(row)) {
                continue;
            }
            validateImportRow(row, rowNum);
            HealthCourse course = requireCourse(row.getCourseId());
            HealthCourseVideo video = new HealthCourseVideo();
            video.setCourseId(course.getId());
            video.setCourseType(HealthCourseVideo.COURSE_TYPE_HEALTH);
            video.setChapterName(row.getChapterName().trim());
            video.setVideoSourceType(HealthCourseVideo.VIDEO_SOURCE_URL);
            video.setVideoUrl(row.getVideoUrl().trim());
            video.setVideoFileName(null);
            video.setDurationSeconds(row.getDurationSeconds());
            video.setSalePrice(row.getSalePrice() == null ? BigDecimal.ZERO : row.getSalePrice());
            video.setSort(resolveNextVideoSort(course.getId()));
            video.setStatus(parseStatusText(row.getStatusText()));
            video.setDeleted(0);
            healthCourseVideoMapper.insert(video);
            touchedCourseIds.add(course.getId());
        }
        if (touchedCourseIds.isEmpty()) {
            throw new IllegalArgumentException("导入文件没有可用数据");
        }
        for (Long courseId : touchedCourseIds) {
            refreshCourseStats(courseId);
        }
    }
    private void fillCourse(HealthCourse course, HealthCourseSaveRequest req) {
@@ -246,18 +406,18 @@
    }
    private void fillVideo(HealthCourseVideo video, HealthCourseVideoSaveRequest req) {
        video.setCourseType(resolveCourseType(req.getCourseType()));
        video.setChapterName(req.getChapterName().trim());
        video.setChapterRemark(trimToNull(req.getChapterRemark()));
        video.setVideoSourceType(req.getVideoSourceType());
        video.setVideoUrl(req.getVideoUrl().trim());
        video.setVideoFileName(trimToNull(req.getVideoFileName()));
        video.setDurationSeconds(req.getDurationSeconds());
        video.setSaleType(req.getSaleType());
        video.setSalePrice(req.getSaleType() != null && req.getSaleType() == 0 ? BigDecimal.ZERO : req.getSalePrice());
        video.setPlayCount(req.getPlayCount());
        video.setStatus(req.getStatus());
        video.setSalePrice(req.getSalePrice());
        video.setStatus(resolveVideoStatus(req.getStatus()));
    }
    private void validateCategories(Long levelCategoryId, Long difficultyCategoryId) {
        LiveCourseCategory level = requireCategory(levelCategoryId, LiveCourseCategoryAppService.TYPE_LEVEL, "养身等级");
        LiveCourseCategory level = requireCategory(levelCategoryId, LiveCourseCategoryAppService.TYPE_LEVEL, "养生等级");
        LiveCourseCategory difficulty = requireCategory(difficultyCategoryId, LiveCourseCategoryAppService.TYPE_DIFFICULTY, "难度");
        if (level == null || difficulty == null) {
            throw new IllegalArgumentException("分类不存在");
@@ -289,13 +449,29 @@
        }
    }
    private void validateVideoSale(HealthCourseVideoSaveRequest req) {
        if (req.getSaleType() == null || (req.getSaleType() != 0 && req.getSaleType() != 1)) {
            throw new IllegalArgumentException("售价类型不合法");
    private void validateVideo(HealthCourseVideoSaveRequest req) {
        Integer courseType = resolveCourseType(req.getCourseType());
        if (courseType != HealthCourseVideo.COURSE_TYPE_HEALTH) {
            throw new IllegalArgumentException("课程类型不合法");
        }
        if (req.getSaleType() == 0 && req.getSalePrice() != null && req.getSalePrice().compareTo(BigDecimal.ZERO) != 0) {
            throw new IllegalArgumentException("免费课程售价必须为0");
        if (req.getCourseId() == null) {
            throw new IllegalArgumentException("请选择关联课程");
        }
        if (req.getVideoSourceType() == null
                || (req.getVideoSourceType() != HealthCourseVideo.VIDEO_SOURCE_URL
                && req.getVideoSourceType() != HealthCourseVideo.VIDEO_SOURCE_UPLOAD)) {
            throw new IllegalArgumentException("视频来源不合法");
        }
        if (req.getVideoUrl() == null || req.getVideoUrl().trim().isEmpty()) {
            throw new IllegalArgumentException("请输入视频地址");
        }
        if (req.getSalePrice() != null && req.getSalePrice().compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("售价不能小于0");
        }
    }
    private Integer resolveVideoStatus(Integer status) {
        return status == null ? 1 : status;
    }
    private int resolveNextVideoSort(Long courseId) {
@@ -334,10 +510,7 @@
    private HealthCourseVideo requireVideo(Long courseId, Long videoId) {
        requireCourse(courseId);
        HealthCourseVideo video = healthCourseVideoMapper.selectById(videoId);
        if (video == null) {
            throw new IllegalArgumentException("视频课程不存在");
        }
        HealthCourseVideo video = requireVideoById(videoId);
        if (!courseId.equals(video.getCourseId())) {
            throw new IllegalArgumentException("视频课程不属于当前课程");
        }
@@ -394,33 +567,49 @@
    }
    private HealthCourseVideoItemVO toVideoItem(HealthCourseVideo item) {
        return toVideoItem(item, null);
    }
    private HealthCourseVideoItemVO toVideoItem(HealthCourseVideo item, String courseTitle) {
        HealthCourseVideoItemVO vo = new HealthCourseVideoItemVO();
        vo.setId(item.getId());
        vo.setCourseType(resolveCourseType(item.getCourseType()));
        vo.setCourseTypeText(courseTypeText(item.getCourseType()));
        vo.setCourseId(item.getCourseId());
        vo.setCourseTitle(defaultText(courseTitle));
        vo.setChapterName(item.getChapterName());
        vo.setChapterRemark(item.getChapterRemark());
        vo.setVideoSourceType(item.getVideoSourceType());
        vo.setVideoUrl(item.getVideoUrl());
        vo.setVideoFileName(item.getVideoFileName());
        vo.setDurationSeconds(item.getDurationSeconds());
        vo.setSaleType(item.getSaleType());
        vo.setSalePrice(item.getSalePrice());
        vo.setPlayCount(item.getPlayCount());
        vo.setDurationText(formatDuration(defaultNumber(item.getDurationSeconds())));
        vo.setSalePrice(item.getSalePrice() == null ? BigDecimal.ZERO : item.getSalePrice());
        vo.setWatchCount(0);
        vo.setSort(item.getSort());
        vo.setStatus(item.getStatus());
        vo.setStatusText(statusText(item.getStatus()));
        vo.setCreatedAt(item.getCreatedAt());
        return vo;
    }
    private HealthCourseVideoDetailVO toVideoDetail(HealthCourseVideo item) {
        return toVideoDetail(item, null);
    }
    private HealthCourseVideoDetailVO toVideoDetail(HealthCourseVideo item, String courseTitle) {
        HealthCourseVideoDetailVO vo = new HealthCourseVideoDetailVO();
        vo.setId(item.getId());
        vo.setCourseType(resolveCourseType(item.getCourseType()));
        vo.setCourseTypeText(courseTypeText(item.getCourseType()));
        vo.setCourseId(item.getCourseId());
        vo.setCourseTitle(defaultText(courseTitle));
        vo.setChapterName(item.getChapterName());
        vo.setChapterRemark(item.getChapterRemark());
        vo.setVideoSourceType(item.getVideoSourceType());
        vo.setVideoUrl(item.getVideoUrl());
        vo.setVideoFileName(item.getVideoFileName());
        vo.setDurationSeconds(item.getDurationSeconds());
        vo.setSaleType(item.getSaleType());
        vo.setSalePrice(item.getSalePrice());
        vo.setPlayCount(item.getPlayCount());
        vo.setSalePrice(item.getSalePrice() == null ? BigDecimal.ZERO : item.getSalePrice());
        vo.setWatchCount(0);
        vo.setSort(item.getSort());
        vo.setStatus(item.getStatus());
        vo.setCreatedAt(item.getCreatedAt());
@@ -447,6 +636,10 @@
        return status != null && status == 1 ? "启用" : "禁用";
    }
    private String courseTypeText(Integer courseType) {
        return resolveCourseType(courseType) == HealthCourseVideo.COURSE_TYPE_HEALTH ? "养生课程" : "-";
    }
    private String defaultText(String text) {
        return text == null || text.trim().isEmpty() ? "-" : text;
    }
@@ -458,4 +651,104 @@
        String trimmed = text.trim();
        return trimmed.isEmpty() ? null : trimmed;
    }
    private Integer resolveCourseType(Integer courseType) {
        return courseType == null ? HealthCourseVideo.COURSE_TYPE_HEALTH : courseType;
    }
    private Long requireVideoCourseId(Long courseId) {
        return requireCourse(courseId).getId();
    }
    private HealthCourseVideo requireVideoById(Long id) {
        HealthCourseVideo video = healthCourseVideoMapper.selectById(id);
        if (video == null) {
            throw new IllegalArgumentException("视频课程不存在");
        }
        return video;
    }
    private List<Long> resolveMatchedCourseIds(String courseKeyword, Long courseId) {
        if (courseId != null) {
            return Collections.singletonList(requireCourse(courseId).getId());
        }
        List<HealthCourse> courses = healthCourseMapper.selectList(new LambdaQueryWrapper<HealthCourse>()
                .eq(HealthCourse::getDeleted, 0)
                .like(courseKeyword != null && !courseKeyword.trim().isEmpty(), HealthCourse::getTitle, courseKeyword == null ? null : courseKeyword.trim()));
        if (courses.isEmpty()) {
            return Collections.emptyList();
        }
        List<Long> ids = new ArrayList<Long>();
        for (HealthCourse course : courses) {
            ids.add(course.getId());
        }
        return ids;
    }
    private Map<Long, String> resolveCourseTitleMap(Collection<Long> courseIds) {
        if (courseIds == null || courseIds.isEmpty()) {
            return Collections.emptyMap();
        }
        List<HealthCourse> courses = healthCourseMapper.selectList(new LambdaQueryWrapper<HealthCourse>()
                .in(HealthCourse::getId, courseIds)
                .eq(HealthCourse::getDeleted, 0));
        Map<Long, String> map = new LinkedHashMap<Long, String>();
        for (HealthCourse course : courses) {
            map.put(course.getId(), course.getTitle());
        }
        return map;
    }
    private List<Long> extractCourseIds(List<HealthCourseVideo> videos) {
        Set<Long> ids = new LinkedHashSet<Long>();
        for (HealthCourseVideo video : videos) {
            ids.add(video.getCourseId());
        }
        return new ArrayList<Long>(ids);
    }
    private boolean isImportRowEmpty(HealthCourseVideoImportRow row) {
        return row.getCourseId() == null
                && (row.getChapterName() == null || row.getChapterName().trim().isEmpty())
                && row.getSalePrice() == null
                && row.getDurationSeconds() == null
                && (row.getVideoUrl() == null || row.getVideoUrl().trim().isEmpty())
                && (row.getStatusText() == null || row.getStatusText().trim().isEmpty());
    }
    private void validateImportRow(HealthCourseVideoImportRow row, int rowNum) {
        if (row.getCourseId() == null) {
            throw new IllegalArgumentException("第" + rowNum + "行:关联课程ID不能为空");
        }
        if (row.getCourseTypeText() != null && !row.getCourseTypeText().trim().isEmpty() && !"养生课程".equals(row.getCourseTypeText().trim())) {
            throw new IllegalArgumentException("第" + rowNum + "行:课程类型仅支持养生课程");
        }
        if (row.getChapterName() == null || row.getChapterName().trim().isEmpty()) {
            throw new IllegalArgumentException("第" + rowNum + "行:小节名称不能为空");
        }
        if (row.getSalePrice() != null && row.getSalePrice().compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("第" + rowNum + "行:小节价格不能小于0");
        }
        if (row.getDurationSeconds() == null || row.getDurationSeconds() < 0) {
            throw new IllegalArgumentException("第" + rowNum + "行:时长必须大于等于0");
        }
        if (row.getVideoUrl() == null || row.getVideoUrl().trim().isEmpty()) {
            throw new IllegalArgumentException("第" + rowNum + "行:视频地址不能为空");
        }
        try {
            parseStatusText(row.getStatusText());
        } catch (IllegalArgumentException ex) {
            throw new IllegalArgumentException("第" + rowNum + "行:" + ex.getMessage());
        }
    }
    private Integer parseStatusText(String statusText) {
        if (statusText == null || statusText.trim().isEmpty() || "启用".equals(statusText.trim())) {
            return 1;
        }
        if ("禁用".equals(statusText.trim())) {
            return 0;
        }
        throw new IllegalArgumentException("状态仅支持启用或禁用");
    }
}