“mitao”
2026-04-14 1f173d482408cbeedb84d261898ebd2efb816547
feat:生产环境配置
8个文件已添加
20个文件已修改
6370 ■■■■■ 已修改文件
doc/nginx-tcm-training.conf 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/nginx.conf 126 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/sql/tcm_training_admin.sql 5559 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/controller/AppHealthCourseController.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/service/AppHealthCourseService.java 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppHealthCourseCategoryOptionVO.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppHealthCourseListItemVO.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/bootstrap/HaizhentongDataSourceStartupValidator.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/dto/HealthCourseSaveRequest.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/excel/HealthCourseExportRow.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/HealthCourseAppService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/LiveCourseCategoryAppService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/upload/ObsUploadService.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/upload/UploadProgressService.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/resources/application-prod.yml 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/resources/application.yml 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/live/service/AppLiveCourseServiceTest.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/bootstrap/HaizhentongDataSourceStartupValidatorTest.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/admin.zip 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/config/env.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/Login/index.tsx 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/category/index.tsx 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/health/index.tsx 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/health/manage.tsx 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/health/service.ts 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/typings.d.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/healthCourses/healthCourses.vue 281 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/healthCourses/service.js 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/nginx-tcm-training.conf
New file
@@ -0,0 +1,49 @@
# tcm-training 项目线上 Nginx 配置片段
# 使用方式:
# 1. 保持现有 doc/nginx.conf 不变。
# 2. 将下面的 location 配置按需合并到线上 443 的 server 块中。
# 3. 管理后台构建产物部署到 /usr/local/project/tcm-training/admin/。
# 4. 后端 Spring Boot 服务启动在 127.0.0.1:48080。
# 5. 如需统一项目部署目录,推荐使用 /usr/local/project/tcm-training/ 作为项目根目录。
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name haizhentong.com;
    # 管理后台访问 /admin 时补全尾部斜杠,确保静态资源路径与 publicPath 对齐。
    location = /admin {
        return 301 /admin/;
    }
    # 如需兼容旧入口,可将 /manage 平滑跳转到新的 /admin/。
    location = /manage {
        return 301 /admin/;
    }
    # 管理后台为 Umi 构建产物,直接由 Nginx 托管静态文件。
    location /admin/ {
        alias /usr/local/project/tcm-training/admin/;
        index index.html;
        try_files $uri $uri/ /admin/index.html;
    }
    # 后端接口统一走 /api/ 前缀。
    location /api/ {
        proxy_pass http://127.0.0.1:48080;
        proxy_read_timeout 60s;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
# 管理后台部署补充说明:
# 1. 生产构建命令:cd tcm-training-admin && npm run build:prod
# 2. 构建产物目录:tcm-training-admin/admin
# 3. 线上部署目录建议:/usr/local/project/tcm-training/admin/
# 4. 线上访问地址:https://haizhentong.com/admin/
# 5. 后端接口地址:https://haizhentong.com/api/
# 6. 管理后台生产环境接口基地址需要配置为 /api,避免页面继续请求本地开发地址。
doc/nginx.conf
New file
@@ -0,0 +1,126 @@
# For more information on configuration, see:
#   * Official English Documentation: http://nginx.org/en/docs/
#   * Official Russian Documentation: http://nginx.org/ru/docs/
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log;
pid /run/nginx.pid;
# Load dynamic modules. See /usr/share/doc/nginx/README.dynamic.
include /usr/share/nginx/modules/*.conf;
events {
    worker_connections 1024;
}
http {
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';
    access_log  /var/log/nginx/access.log  main;
    sendfile            on;
    tcp_nopush          on;
    tcp_nodelay         on;
    keepalive_timeout   65;
    types_hash_max_size 4096;
    include             /etc/nginx/mime.types;
    default_type        application/octet-stream;
    # Load modular configuration files from the /etc/nginx/conf.d directory.
    # See http://nginx.org/en/docs/ngx_core_module.html#include
    # for more information.
    include /etc/nginx/conf.d/*.conf;
    server {
        listen       80;
        listen       [::]:80;
        server_name  _;
        root         /usr/share/nginx/html;
        # Load configuration files for the default server block.
        include /etc/nginx/default.d/*.conf;
        error_page 404 /404.html;
        location = /404.html {
        }
        error_page 500 502 503 504 /50x.html;
        location = /50x.html {
        }
    }
# Settings for a TLS enabled server.
#
    server {
        listen       443 ssl http2;
        listen       [::]:443 ssl http2;
        server_name  haizhentong.com;
        root         /usr/share/nginx/html;
        ssl_certificate "/etc/pki/nginx/scs1756353593438_haizhentong.com_server.crt";
        ssl_certificate_key "/etc/pki/nginx/scs1756353593438_haizhentong.com_server.key";
        ssl_session_cache shared:SSL:1m;
        ssl_session_timeout  10m;
        ssl_ciphers HIGH:!aNULL:!MD5;
        ssl_prefer_server_ciphers on;
        client_max_body_size 100M;
        # Load configuration files for the default server block.
        include /etc/nginx/default.d/*.conf;
        location /manage {
             proxy_pass http://127.0.0.1:8089; # 代理到上面的地址去
             proxy_read_timeout 60s;
             proxy_set_header Host $host;
             proxy_set_header X-Real_IP $remote_addr;
             proxy_set_header X-Forwarded-for $remote_addr;
             proxy_http_version 1.1;
             proxy_set_header Upgrade $http_upgrade;
          }
        location /applet {
             proxy_pass http://127.0.0.1:8088; # 代理到上面的地址去
             proxy_read_timeout 60s;
             proxy_set_header Host $host;
             proxy_set_header X-Real_IP $remote_addr;
             proxy_set_header X-Forwarded-for $remote_addr;
             proxy_http_version 1.1;
             proxy_set_header Upgrade $http_upgrade;
             proxy_set_header Connection 'Upgrade';
          }
        location / {
             root  /usr/local/project/web/;
            index  index.html index.htm;
          }
        location /MP_verify_7FfidZ6E3EWJhVDd.txt {
               alias /usr/local/project/MP_verify_7FfidZ6E3EWJhVDd.txt;
          }
#          location /hweb {
#             alias  /usr/local/project/hweb/;
#            index  index.html index.htm;
#          }
          location /hweb {
               alias /usr/local/project/hweb;
               index index.php shareIndex.html shareIndex.htm yszc.html yszc.htm default.php default.htm default.html index.html index.htm;
               try_files $uri $uri/ /hweb/index.html;
          }
        error_page 404 /404.html;
            location = /40x.html {
        }
        error_page 500 502 503 504 /50x.html;
            location = /50x.html {
        }
    }
}
tcm-training-admin-backend/sql/tcm_training_admin.sql
New file
Diff too large
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/controller/AppHealthCourseController.java
@@ -1,13 +1,18 @@
package com.tcm.training.admin.app.course.controller;
import com.tcm.training.admin.app.course.service.AppHealthCourseService;
import com.tcm.training.admin.app.course.vo.AppHealthCourseCategoryOptionVO;
import com.tcm.training.admin.app.course.vo.AppHealthCourseDetailVO;
import com.tcm.training.admin.app.course.vo.AppHealthCourseListItemVO;
import com.tcm.training.admin.common.ApiResponse;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
 * APP 养生课程详情接口。
@@ -27,10 +32,35 @@
    }
    /**
     * 获取 APP 养生课程列表。
     */
    @GetMapping("/health-courses")
    public ApiResponse<List<AppHealthCourseListItemVO>> list(@RequestParam(value = "levelCategoryId", required = false) Long levelCategoryId,
                                                             @RequestParam(value = "difficultyCategoryId", required = false) Long difficultyCategoryId) {
        return ApiResponse.ok(appHealthCourseService.list(levelCategoryId, difficultyCategoryId));
    }
    /**
     * 获取 APP 养生课程详情。
     */
    @GetMapping("/health-courses/{courseId}")
    public ApiResponse<AppHealthCourseDetailVO> detail(@PathVariable("courseId") Long courseId) {
        return ApiResponse.ok(appHealthCourseService.detail(courseId));
    }
    /**
     * 获取 APP 养生课程主题筛选项。
     */
    @GetMapping("/health-course-themes")
    public ApiResponse<List<AppHealthCourseCategoryOptionVO>> themes() {
        return ApiResponse.ok(appHealthCourseService.listThemes());
    }
    /**
     * 获取 APP 养生课程难度筛选项。
     */
    @GetMapping("/health-course-difficulties")
    public ApiResponse<List<AppHealthCourseCategoryOptionVO>> difficulties() {
        return ApiResponse.ok(appHealthCourseService.listDifficulties());
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/service/AppHealthCourseService.java
@@ -1,15 +1,22 @@
package com.tcm.training.admin.app.course.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.tcm.training.admin.app.course.vo.AppHealthCourseCategoryOptionVO;
import com.tcm.training.admin.app.course.vo.AppHealthCourseDetailVO;
import com.tcm.training.admin.app.course.vo.AppHealthCourseExpertVO;
import com.tcm.training.admin.app.course.vo.AppHealthCourseListItemVO;
import com.tcm.training.admin.app.course.vo.AppHealthCourseVideoVO;
import com.tcm.training.admin.live.entity.Expert;
import com.tcm.training.admin.live.entity.HealthCourseVideo;
import com.tcm.training.admin.live.entity.LiveCourseCategory;
import com.tcm.training.admin.live.mapper.ExpertMapper;
import com.tcm.training.admin.live.mapper.HealthCourseMapper;
import com.tcm.training.admin.live.mapper.HealthCourseVideoMapper;
import com.tcm.training.admin.live.mapper.LiveCourseCategoryMapper;
import com.tcm.training.admin.live.service.HealthCourseAppService;
import com.tcm.training.admin.live.service.LiveCourseCategoryAppService;
import com.tcm.training.admin.live.vo.HealthCourseDetailVO;
import com.tcm.training.admin.live.vo.HealthCourseListRow;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@@ -27,18 +34,52 @@
    private static final int VIDEO_STATUS_ENABLED = 1;
    private final HealthCourseAppService healthCourseAppService;
    private final HealthCourseMapper healthCourseMapper;
    private final HealthCourseVideoMapper healthCourseVideoMapper;
    private final LiveCourseCategoryMapper liveCourseCategoryMapper;
    private final ExpertMapper expertMapper;
    /**
     * 注入 APP 养生课程详情所需依赖。
     */
    public AppHealthCourseService(HealthCourseAppService healthCourseAppService,
                                  HealthCourseMapper healthCourseMapper,
                                  HealthCourseVideoMapper healthCourseVideoMapper,
                                  LiveCourseCategoryMapper liveCourseCategoryMapper,
                                  ExpertMapper expertMapper) {
        this.healthCourseAppService = healthCourseAppService;
        this.healthCourseMapper = healthCourseMapper;
        this.healthCourseVideoMapper = healthCourseVideoMapper;
        this.liveCourseCategoryMapper = liveCourseCategoryMapper;
        this.expertMapper = expertMapper;
    }
    /**
     * 获取 APP 养生课程列表页数据。
     */
    public List<AppHealthCourseListItemVO> list(Long levelCategoryId, Long difficultyCategoryId) {
        List<HealthCourseListRow> rows = healthCourseMapper.selectCourseList(
                null,
                levelCategoryId,
                difficultyCategoryId,
                COURSE_STATUS_ENABLED
        );
        if (rows == null || rows.isEmpty()) {
            return Collections.emptyList();
        }
        List<AppHealthCourseListItemVO> result = new ArrayList<AppHealthCourseListItemVO>(rows.size());
        for (HealthCourseListRow row : rows) {
            AppHealthCourseListItemVO itemVO = new AppHealthCourseListItemVO();
            itemVO.setId(row.getId());
            itemVO.setTitle(trimToEmpty(row.getTitle()));
            itemVO.setCoverImage(trimToEmpty(row.getCoverImg()));
            itemVO.setLevelName(trimToEmpty(row.getLevelName()));
            itemVO.setDifficultyName(trimToEmpty(row.getDifficultyName()));
            itemVO.setSalePrice(defaultPrice(row.getSalePrice()));
            itemVO.setLearnUserCount(defaultNumber(row.getLearnUserCount()));
            result.add(itemVO);
        }
        return result;
    }
    /**
@@ -75,6 +116,43 @@
    }
    /**
     * 获取 APP 养生课程主题筛选项。
     */
    public List<AppHealthCourseCategoryOptionVO> listThemes() {
        return listCategoriesByType(LiveCourseCategoryAppService.TYPE_LEVEL);
    }
    /**
     * 获取 APP 养生课程难度筛选项。
     */
    public List<AppHealthCourseCategoryOptionVO> listDifficulties() {
        return listCategoriesByType(LiveCourseCategoryAppService.TYPE_DIFFICULTY);
    }
    /**
     * 按分类类型读取 APP 养生课程筛选项。
     */
    private List<AppHealthCourseCategoryOptionVO> listCategoriesByType(String categoryType) {
        List<LiveCourseCategory> categories = liveCourseCategoryMapper.selectList(new LambdaQueryWrapper<LiveCourseCategory>()
                .eq(LiveCourseCategory::getType, categoryType)
                .eq(LiveCourseCategory::getDeleted, 0)
                .orderByAsc(LiveCourseCategory::getSort)
                .orderByAsc(LiveCourseCategory::getId));
        if (categories == null || categories.isEmpty()) {
            return Collections.emptyList();
        }
        List<AppHealthCourseCategoryOptionVO> result =
                new ArrayList<AppHealthCourseCategoryOptionVO>(categories.size());
        for (LiveCourseCategory category : categories) {
            AppHealthCourseCategoryOptionVO optionVO = new AppHealthCourseCategoryOptionVO();
            optionVO.setId(category.getId());
            optionVO.setName(trimToEmpty(category.getName()));
            result.add(optionVO);
        }
        return result;
    }
    /**
     * 查询课程下启用的视频章节。
     */
    private List<HealthCourseVideo> listEnabledVideos(Long courseId) {
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppHealthCourseCategoryOptionVO.java
New file
@@ -0,0 +1,16 @@
package com.tcm.training.admin.app.course.vo;
import lombok.Getter;
import lombok.Setter;
/**
 * APP 养生课程分类筛选项响应。
 */
@Getter
@Setter
public class AppHealthCourseCategoryOptionVO {
    private Long id;
    private String name;
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppHealthCourseListItemVO.java
New file
@@ -0,0 +1,28 @@
package com.tcm.training.admin.app.course.vo;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
/**
 * APP 养生课程列表卡片响应。
 */
@Getter
@Setter
public class AppHealthCourseListItemVO {
    private Long id;
    private String title;
    private String coverImage;
    private String levelName;
    private String difficultyName;
    private BigDecimal salePrice;
    private Integer learnUserCount;
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/bootstrap/HaizhentongDataSourceStartupValidator.java
@@ -22,6 +22,8 @@
    private static final Logger log = LoggerFactory.getLogger(HaizhentongDataSourceStartupValidator.class);
    private static final String HAIZHENTONG_DATASOURCE_NAME = "haizhentong";
    private static final String MISSING_DATASOURCE_MESSAGE =
            "海诊通数据源 haizhentong 未配置,请检查当前激活环境的配置文件";
    private final DynamicDataSourceProperties dynamicDataSourceProperties;
    private final ObjectProvider<DynamicRoutingDataSource> dynamicRoutingDataSourceProvider;
@@ -77,7 +79,7 @@
        if (configuredDataSourceNames.contains(HAIZHENTONG_DATASOURCE_NAME)) {
            return;
        }
        throw new IllegalStateException("海诊通数据源 haizhentong 未配置,请检查 application-local.yml");
        throw new IllegalStateException(MISSING_DATASOURCE_MESSAGE);
    }
    /**
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/dto/HealthCourseSaveRequest.java
@@ -21,7 +21,7 @@
    @Size(max = 128, message = "课程名称长度不能超过128个字符")
    private String title;
    @NotNull(message = "请选择养生等级")
    @NotNull(message = "请选择养生主题")
    private Long levelCategoryId;
    @NotNull(message = "请选择难度")
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/excel/HealthCourseExportRow.java
@@ -15,7 +15,7 @@
    @ExcelProperty("课程名称")
    private String title;
    @ExcelProperty("养生等级")
    @ExcelProperty("养生主题")
    private String levelName;
    @ExcelProperty("难度")
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/HealthCourseAppService.java
@@ -417,7 +417,7 @@
    }
    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("分类不存在");
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/LiveCourseCategoryAppService.java
@@ -133,7 +133,7 @@
    }
    private String resolveLabel(String type) {
        return TYPE_LEVEL.equals(type) ? "养生等级" : "难度";
        return TYPE_LEVEL.equals(type) ? "养生主题" : "难度";
    }
    private LiveCourseCategoryDetailVO toDetailVO(LiveCourseCategory entity) {
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/upload/ObsUploadService.java
@@ -38,7 +38,8 @@
        String originalName = file.getOriginalFilename();
        String objectKey = buildObjectKey(bizPath, originalName);
        File tempFile = createTempFile(file, originalName);
        uploadProgressService.init(taskId);
        long totalBytes = file.getSize();
        uploadProgressService.init(taskId, totalBytes);
        try (ObsClient obsClient = new ObsClient(
                properties.getAccessKeyId(),
                properties.getSecretAccessKey(),
@@ -52,7 +53,7 @@
            request.setProgressInterval(1024 * 100L);
            obsClient.putObject(request);
            UploadFileResult result = new UploadFileResult(buildFileUrl(objectKey), originalName, objectKey);
            uploadProgressService.markSuccess(taskId, result);
            uploadProgressService.markSuccess(taskId, result, totalBytes);
            return result;
        } catch (ObsException e) {
            log.error("OBS文件上传失败: bucket={}, objectKey={}", properties.getBucketName(), objectKey, e);
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/upload/UploadProgressService.java
@@ -12,7 +12,7 @@
    private final ConcurrentMap<String, UploadProgressInfo> progressMap = new ConcurrentHashMap<>();
    public void init(String taskId) {
    public void init(String taskId, long totalBytes) {
        if (!StringUtils.hasText(taskId)) {
            return;
        }
@@ -21,7 +21,7 @@
        info.setStatus(UploadProgressInfo.STATUS_UPLOADING);
        info.setPercent(0);
        info.setTransferredBytes(0L);
        info.setTotalBytes(0L);
        info.setTotalBytes(Math.max(totalBytes, 0L));
        progressMap.put(taskId, info);
    }
@@ -33,16 +33,20 @@
        info.setStatus(UploadProgressInfo.STATUS_UPLOADING);
        info.setPercent(status.getTransferPercentage());
        info.setTransferredBytes(status.getTransferredBytes());
        info.setTotalBytes(status.getTotalBytes());
        if (status.getTotalBytes() > 0) {
            info.setTotalBytes(status.getTotalBytes());
        }
    }
    public void markSuccess(String taskId, UploadFileResult result) {
    public void markSuccess(String taskId, UploadFileResult result, long totalBytes) {
        if (!StringUtils.hasText(taskId)) {
            return;
        }
        UploadProgressInfo info = progressMap.computeIfAbsent(taskId, this::createUploadingInfo);
        info.setStatus(UploadProgressInfo.STATUS_SUCCESS);
        info.setPercent(100);
        info.setTotalBytes(Math.max(totalBytes, 0L));
        info.setTransferredBytes(Math.max(totalBytes, 0L));
        info.setUrl(result.getUrl());
        info.setFileName(result.getFileName());
    }
tcm-training-admin-backend/src/main/resources/application-prod.yml
New file
@@ -0,0 +1,37 @@
# 生产环境配置
spring:
  datasource:
    dynamic:
      datasource:
        master:
          url: jdbc:mysql://localhost:10633/tcm_training_admin?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
          username: root
          password: Haizhentong@2025Mysql!
        haizhentong:
          type: com.alibaba.druid.pool.DruidDataSource
          driver-class-name: com.mysql.cj.jdbc.Driver
          url: jdbc:mysql://localhost:10633/haizhentong?autoReconnect=true&useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
          username: root
          password: Haizhentong@2025Mysql!
  redis:
    host: localhost
    port: 10644
    database: 0
    # 连接超时时间
    timeout: 5s
    password: Haizhentong@2025Redis!
    lettuce:
      pool:
        # 连接池中的最小空闲连接
        min-idle: 0
        # 连接池中的最大空闲连接
        max-idle: 8
        # 连接池的最大数据库连接数
        max-active: 8
        # #连接池最大阻塞等待时间(使用负值表示没有限制)
        max-wait: -1ms
app:
  auth:
    sms:
      debug-enabled: false
      debug-code: ""
tcm-training-admin-backend/src/main/resources/application.yml
@@ -4,8 +4,8 @@
spring:
  application:
    name: tcm-training-admin-backend
  config:
    import: optional:application-local.yml
  profiles:
    active: local
  servlet:
    multipart:
      max-file-size: 200MB
@@ -27,6 +27,16 @@
    database: 0
    timeout: 5s
    password: 123456
    lettuce:
      pool:
        # 连接池中的最小空闲连接
        min-idle: 0
        # 连接池中的最大空闲连接
        max-idle: 8
        # 连接池的最大数据库连接数
        max-active: 8
        # #连接池最大阻塞等待时间(使用负值表示没有限制)
        max-wait: -1ms
mybatis-plus:
  mapper-locations: classpath*:mapper/**/*.xml
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/live/service/AppLiveCourseServiceTest.java
@@ -14,6 +14,11 @@
import com.tcm.training.admin.live.mapper.LiveCourseMapper;
import com.tcm.training.admin.live.mapper.LiveReserveMapper;
import com.tcm.training.admin.live.mapper.LiveSessionMapper;
import com.tcm.training.admin.live.mapper.LiveSessionQuestionMapper;
import com.tcm.training.admin.live.mapper.LiveSessionQuizRecordMapper;
import com.tcm.training.admin.live.mapper.LiveWatchRecordMapper;
import com.tcm.training.admin.points.mapper.UserPointsAccountMapper;
import com.tcm.training.admin.points.service.PointsLedgerAppService;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
@@ -32,6 +37,7 @@
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -63,6 +69,21 @@
    private ExpertMapper expertMapper;
    @Mock
    private LiveSessionQuestionMapper liveSessionQuestionMapper;
    @Mock
    private LiveSessionQuizRecordMapper liveSessionQuizRecordMapper;
    @Mock
    private LiveWatchRecordMapper liveWatchRecordMapper;
    @Mock
    private UserPointsAccountMapper userPointsAccountMapper;
    @Mock
    private PointsLedgerAppService pointsLedgerAppService;
    @Mock
    private AppAuthSessionService appAuthSessionService;
    private AppLiveCourseService appLiveCourseService;
@@ -76,11 +97,20 @@
    @BeforeEach
    void setUp() {
        AppLiveExpertService appLiveExpertService = new AppLiveExpertService(expertMapper);
        // 详情与预约用例不关心答题和观看明细,统一返回空列表避免空指针干扰主断言。
        lenient().when(liveSessionQuestionMapper.selectList(any())).thenReturn(Collections.emptyList());
        lenient().when(liveSessionQuizRecordMapper.selectList(any())).thenReturn(Collections.emptyList());
        lenient().when(liveWatchRecordMapper.selectList(any())).thenReturn(Collections.emptyList());
        appLiveCourseService = new AppLiveCourseService(
                liveCourseMapper,
                liveSessionMapper,
                liveReserveMapper,
                expertMapper,
                liveSessionQuestionMapper,
                liveSessionQuizRecordMapper,
                liveWatchRecordMapper,
                userPointsAccountMapper,
                pointsLedgerAppService,
                appAuthSessionService,
                new AppLiveCourseDisplaySupport(),
                appLiveExpertService
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/bootstrap/HaizhentongDataSourceStartupValidatorTest.java
@@ -35,7 +35,7 @@
        IllegalStateException exception = assertThrows(IllegalStateException.class,
                () -> validator.run(new DefaultApplicationArguments(new String[0])));
        assertEquals("海诊通数据源 haizhentong 未配置,请检查 application-local.yml", exception.getMessage());
        assertEquals("海诊通数据源 haizhentong 未配置,请检查当前激活环境的配置文件", exception.getMessage());
    }
    /**
tcm-training-admin/admin.zip
Binary files differ
tcm-training-admin/config/env.ts
@@ -3,9 +3,9 @@
    SERVER_URL: 'http://192.168.110.188:48080/api',
  },
  test: {
    SERVER_URL: '',
    SERVER_URL: '/api',
  },
  prod: {
    SERVER_URL: '',
    SERVER_URL: '/api',
  },
};
tcm-training-admin/src/pages/Login/index.tsx
@@ -20,6 +20,15 @@
  captchaCode: string;
};
const loginInitialValues: Partial<LoginValues> = {
  ...(REACT_APP_ENV === 'prod'
    ? {}
    : {
        username: 'admin',
        password: 'Admin@123456',
      }),
};
const Login: React.FC = () => {
  const [form] = Form.useForm<LoginValues>();
  const [submitting, setSubmitting] = useState(false);
@@ -98,10 +107,7 @@
            layout="vertical"
            onFinish={handleSubmit}
            autoComplete="off"
            initialValues={{
              username: 'admin',
              password: 'Admin@123456',
            }}
            initialValues={loginInitialValues}
          >
            <Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
              <Input size="large" prefix={<UserOutlined />} placeholder="请输入用户名" />
tcm-training-admin/src/pages/live/category/index.tsx
@@ -28,7 +28,7 @@
};
const TAB_OPTIONS: Array<{ key: CategoryType; label: string }> = [
  { key: 'LEVEL', label: '养生等级' },
  { key: 'LEVEL', label: '养生主题' },
  { key: 'DIFFICULTY', label: '难度' },
];
tcm-training-admin/src/pages/live/health/index.tsx
@@ -162,7 +162,7 @@
      ),
    },
    {
      title: '养生等级',
      title: '养生主题',
      dataIndex: 'levelName',
      width: 110,
      render: (value) => value || '-',
@@ -254,7 +254,7 @@
            <Form.Item label="课程名称" name="keyword">
              <Input placeholder="请输入" allowClear />
            </Form.Item>
            <Form.Item label="养生等级" name="levelCategoryId">
            <Form.Item label="养生主题" name="levelCategoryId">
              <Select
                placeholder="全部"
                allowClear
tcm-training-admin/src/pages/live/health/manage.tsx
@@ -140,7 +140,7 @@
            <Form.Item label="课程名称" name="title" rules={[{ required: true, message: '请输入课程名称' }]}>
              <Input placeholder="请输入" maxLength={128} disabled={loading} />
            </Form.Item>
            <Form.Item label="养生等级" name="levelCategoryId" rules={[{ required: true, message: '请选择养生等级' }]}>
            <Form.Item label="养生主题" name="levelCategoryId" rules={[{ required: true, message: '请选择养生主题' }]}>
              <Select
                placeholder="请选择"
                options={levelOptions.map((item) => ({ label: item.name, value: item.id }))}
tcm-training-admin/src/pages/live/health/service.ts
@@ -1,6 +1,16 @@
import { decryptData } from '@/utils/utils';
import { request } from '@umijs/max';
/**
 * 去掉接口基础地址末尾的斜杠,避免手工拼接上传路径时出现双斜杠。
 */
const normalizeBaseUrl = (baseUrl?: string) => {
  if (!baseUrl) {
    return '';
  }
  return baseUrl.replace(/\/+$/, '');
};
export async function getHealthCoursePage(params?: Record<string, any>) {
  return request('/health-courses', {
    method: 'GET',
@@ -205,8 +215,10 @@
    const xhr = new XMLHttpRequest();
    const authorization = decryptData(localStorage.getItem('token')) || '';
    let pollingTimer: number | undefined;
    const uploadBaseUrl = normalizeBaseUrl(BASE_URL);
    let lastReportedPercent = 0;
    xhr.open('POST', `${BASE_URL}/upload/video`, true);
    xhr.open('POST', `${uploadBaseUrl}/upload/video`, true);
    if (authorization) {
      xhr.setRequestHeader('Authorization', authorization);
    }
@@ -225,13 +237,35 @@
          method: 'GET',
          params: { taskId: uploadTaskId },
        });
        const uploadStatus = response?.data?.status;
        const percent = response?.data?.percent;
        if (typeof percent === 'number') {
          onProgress?.(percent);
        if (uploadStatus === 'success') {
          lastReportedPercent = 100;
          onProgress?.(100);
          return;
        }
        if (typeof percent === 'number' && percent > 0) {
          // 浏览器上传进度用于展示客户端到服务端的传输,服务端轮询补足落盘/转存阶段的尾部进度。
          const mergedPercent = Math.min(99, Math.max(lastReportedPercent, 95 + Math.round(percent * 0.05)));
          lastReportedPercent = mergedPercent;
          onProgress?.(mergedPercent);
        }
      } catch (error) {
        // Ignore transient polling errors while upload request is still in-flight.
      }
    };
    xhr.upload.onprogress = (event) => {
      if (!event.lengthComputable || event.total <= 0) {
        return;
      }
      // 客户端上传阶段最多展示到 95%,剩余进度交给服务端完成转存后再收口到 100%。
      const nextPercent = Math.min(95, Math.round((event.loaded / event.total) * 95));
      if (nextPercent <= lastReportedPercent) {
        return;
      }
      lastReportedPercent = nextPercent;
      onProgress?.(nextPercent);
    };
    pollingTimer = window.setInterval(() => {
@@ -248,6 +282,7 @@
        try {
          const response = JSON.parse(xhr.responseText || '{}');
          if (response?.code === 0) {
            lastReportedPercent = 100;
            onProgress?.(100);
            resolve(response);
            return;
tcm-training-admin/src/typings.d.ts
@@ -27,4 +27,4 @@
declare module 'react-fittext';
declare module 'bizcharts-plugin-slider';
declare const REACT_APP_ENV: 'test' | 'dev' | 'pre' | false;
declare const REACT_APP_ENV: 'test' | 'dev' | 'pre' | 'prod' | false;
traditionalApp/pages/healthCourses/healthCourses.vue
@@ -29,18 +29,18 @@
                    <view 
                        :class="['dropdown-item', { 'is-selected': selectedTheme === item }]" 
                        v-for="(item, index) in themeOptions" 
                        :key="index"
                        :key="`theme-${index}`"
                        @tap="selectTheme(item)"
                    >
                        <image v-if="selectedTheme === item" class="check-icon" src="/static/image/healthCourses/dui.png" mode="aspectFit"></image>
                        <text :class="['item-text', { active: selectedTheme === item }]">{{ item }}</text>
                    </view>
                </template>
                <template v-if="activeFilter === 'difficulty'">
                <template v-else-if="activeFilter === 'difficulty'">
                    <view 
                        :class="['dropdown-item', { 'is-selected': selectedDifficulty === item }]" 
                        v-for="(item, index) in difficultyOptions" 
                        :key="index"
                        :key="`difficulty-${index}`"
                        @tap="selectDifficulty(item)"
                    >
                        <image v-if="selectedDifficulty === item" class="check-icon" src="/static/image/healthCourses/dui.png" mode="aspectFit"></image>
@@ -52,94 +52,265 @@
        <!-- 课程列表 -->
        <scroll-view scroll-y class="course-list-area">
            <view class="course-list">
                <view class="course-item" v-for="i in 4" :key="'course'+i" @tap="goToCourseDetail">
                    <image class="course-img" src="/static/image/home/class.png" mode="aspectFill"></image>
            <view v-if="courseList.length" class="course-list">
                <view class="course-item" v-for="item in courseList" :key="item.id" @tap="goToCourseDetail(item.id)">
                    <image class="course-img" :src="item.coverImage || defaultCourseCover" mode="aspectFill"></image>
                    <view class="course-info">
                        <view class="course-title-box">
                            <text class="tag">养生</text>
                            <text class="tag" v-if="i > 1">入门</text>
                            <text class="title">拍八虚新手入门版</text>
                            <text v-if="item.levelName" class="tag">{{ item.levelName }}</text>
                            <text v-else-if="item.difficultyName" class="tag">{{ item.difficultyName }}</text>
                            <text class="title">{{ item.title }}</text>
                        </view>
                        <view class="course-bottom">
                            <view class="price-box">
                                <text class="price-symbol">¥</text>
                                <text class="price-integer">8888</text>
                                <text class="price-decimal">.25</text>
                                <text class="price-integer">{{ resolvePriceParts(item.salePrice).integer }}</text>
                                <text class="price-decimal">.{{ resolvePriceParts(item.salePrice).decimal }}</text>
                            </view>
                            <view class="people-box">
                                <image class="person-icon" src="/static/image/home/person.png" mode="aspectFit"></image>
                                <text class="people-text">8888人正在学</text>
                                <text class="people-text">{{ formatLearningCount(item.learnUserCount) }}人正在学</text>
                            </view>
                        </view>
                    </view>
                </view>
            </view>
            <view class="no-more">没有更多了</view>
            <view v-else-if="!loading" class="empty-text">暂无课程内容</view>
            <view v-if="loading" class="loading-text">加载中...</view>
            <view v-else-if="courseList.length" class="no-more">没有更多了</view>
        </scroll-view>
    </view>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { computed, onMounted, ref } from 'vue'
import { getHealthCourseDifficultyList, getHealthCourseList, getHealthCourseThemeList } from './service'
const statusBarHeight = ref(0);
interface HealthCourseListItem {
    id: number | string
    title: string
    coverImage?: string
    levelName?: string
    difficultyName?: string
    salePrice?: number | string
    learnUserCount?: number
}
const activeFilter = ref('');
const selectedTheme = ref('全部');
const selectedDifficulty = ref('全部');
interface DifficultyOptionItem {
    id?: number | string | null
    name: string
}
const themeOptions = ['全部', '养生', '养心', '文本内容'];
const difficultyOptions = ['全部', '入门', '进阶', '高级'];
interface ThemeOptionItem {
    id?: number | string | null
    name: string
}
const statusBarHeight = ref(0)
const activeFilter = ref('')
const selectedTheme = ref('全部')
const selectedDifficulty = ref('全部')
const loading = ref(false)
const selectedThemeId = ref<number | string | null>(null)
const selectedDifficultyId = ref<number | string | null>(null)
const courseList = ref<HealthCourseListItem[]>([])
const themeList = ref<ThemeOptionItem[]>([])
const difficultyList = ref<DifficultyOptionItem[]>([])
const defaultCourseCover = '/static/image/home/class.png'
/**
 * 页面初始化时加载状态栏高度、课程列表和筛选项。
 */
onMounted(() => {
    uni.getSystemInfo({
        success: (res) => {
            statusBarHeight.value = res.statusBarHeight || 0;
            statusBarHeight.value = res.statusBarHeight || 0
        }
    });
});
    })
    loadThemeList()
    loadDifficultyList()
    loadCourseList()
})
const goSearch = () => {
const themeOptions = computed(() => {
    const options = ['全部']
    for (const item of themeList.value) {
        if (item.name) {
            options.push(item.name)
        }
    }
    return options
})
const difficultyOptions = computed(() => {
    const options = ['全部']
    for (const item of difficultyList.value) {
        if (item.name) {
            options.push(item.name)
        }
    }
    return options
})
/**
 * 跳转搜索页。
 */
function goSearch() {
    uni.navigateTo({
        url: '/pages/search/search'
    });
};
    })
}
const goToCourseDetail = () => {
/**
 * 跳转养生课程详情页。
 */
function goToCourseDetail(courseId?: number | string | null) {
    uni.navigateTo({
        url: '/pages/courseDetail/courseDetail'
    });
};
        url: courseId ? `/pages/courseDetail/courseDetail?id=${courseId}` : '/pages/courseDetail/courseDetail'
    })
}
const toggleFilter = (type: string) => {
/**
 * 切换筛选下拉面板。
 */
function toggleFilter(type: string) {
    if (activeFilter.value === type) {
        activeFilter.value = '';
        activeFilter.value = ''
    } else {
        activeFilter.value = type;
        activeFilter.value = type
    }
};
}
const selectTheme = (theme: string) => {
    selectedTheme.value = theme;
    activeFilter.value = '';
};
/**
 * 选择主题筛选项并刷新课程列表。
 */
function selectTheme(theme: string) {
    selectedTheme.value = theme
    if (theme === '全部') {
        selectedThemeId.value = null
    } else {
        const matchedItem = themeList.value.find((item) => item.name === theme)
        selectedThemeId.value = matchedItem?.id || null
    }
    activeFilter.value = ''
    loadCourseList()
}
const selectDifficulty = (diff: string) => {
    selectedDifficulty.value = diff;
    activeFilter.value = '';
};
/**
 * 选择难度筛选项并刷新课程列表。
 */
function selectDifficulty(diff: string) {
    selectedDifficulty.value = diff
    if (diff === '全部') {
        selectedDifficultyId.value = null
    } else {
        const matchedItem = difficultyList.value.find((item) => item.name === diff)
        selectedDifficultyId.value = matchedItem?.id || null
    }
    activeFilter.value = ''
    loadCourseList()
}
const closeFilter = () => {
    activeFilter.value = '';
};
/**
 * 关闭筛选下拉层。
 */
function closeFilter() {
    activeFilter.value = ''
}
/**
 * 拉取养生课程列表。
 */
async function loadCourseList() {
    loading.value = true
    try {
        const response = await getHealthCourseList({
            levelCategoryId: selectedThemeId.value || undefined,
            difficultyCategoryId: selectedDifficultyId.value || undefined,
        })
        if (response?.code !== 0 || !Array.isArray(response.data)) {
            courseList.value = []
            return
        }
        courseList.value = response.data.filter((item: HealthCourseListItem) => !!item?.id)
    } catch {
        courseList.value = []
    } finally {
        loading.value = false
    }
}
/**
 * 拉取主题筛选项。
 */
async function loadThemeList() {
    try {
        const response = await getHealthCourseThemeList()
        if (response?.code !== 0 || !Array.isArray(response.data)) {
            themeList.value = []
            return
        }
        themeList.value = response.data.filter((item: ThemeOptionItem) => !!item?.id)
    } catch {
        themeList.value = []
    }
}
/**
 * 拉取难度筛选项。
 */
async function loadDifficultyList() {
    try {
        const response = await getHealthCourseDifficultyList()
        if (response?.code !== 0 || !Array.isArray(response.data)) {
            difficultyList.value = []
            return
        }
        difficultyList.value = response.data.filter((item: DifficultyOptionItem) => !!item?.id)
    } catch {
        difficultyList.value = []
    }
}
/**
 * 将课程价格格式化为两位小数。
 */
function formatPrice(price?: number | string) {
    const numericPrice = Number(price || 0)
    if (!Number.isFinite(numericPrice)) {
        return '0.00'
    }
    return numericPrice.toFixed(2)
}
/**
 * 解析价格区展示所需的整数和小数部分。
 */
function resolvePriceParts(price?: number | string) {
    const [integer, decimal] = formatPrice(price).split('.')
    return {
        integer: integer || '0',
        decimal: decimal || '00',
    }
}
/**
 * 将学习人数格式化为页面展示文案。
 */
function formatLearningCount(count?: number) {
    const safeCount = typeof count === 'number' && count > 0 ? count : 0
    if (safeCount >= 10000) {
        return `${(Math.round(safeCount / 100) / 100).toFixed(2)}W`
    }
    return `${safeCount}`
}
/**
 * 计算筛选下拉面板的顶部偏移。
 */
const dropdownTop = computed(() => {
    // statusBarHeight + navBar(88rpx) + filterBar(88rpx)
    // uni-app parses rpx differently in styles vs script, but we can compute it using uni.upx2px
    // Alternatively, just use CSS calc in the style, and pass statusBarHeight as a CSS variable or inline style.
    return `calc(${statusBarHeight.value}px + 176rpx)`;
});
    return `calc(${statusBarHeight.value}px + 176rpx)`
})
</script>
<style>
@@ -385,4 +556,12 @@
    color: #C0C4CC;
    padding: 40rpx 0;
}
</style>
.loading-text,
.empty-text {
    text-align: center;
    font-size: 24rpx;
    color: #C0C4CC;
    padding: 40rpx 0;
}
</style>
traditionalApp/pages/healthCourses/service.js
New file
@@ -0,0 +1,22 @@
import request from '@/utils/request.js'
/**
 * 获取 APP 养生课程列表。
 */
export const getHealthCourseList = (params) => {
    return request.get('/api/app/health-courses', params, false, false)
}
/**
 * 获取 APP 养生课程主题筛选项。
 */
export const getHealthCourseThemeList = () => {
    return request.get('/api/app/health-course-themes', {}, false, false)
}
/**
 * 获取 APP 养生课程难度筛选项。
 */
export const getHealthCourseDifficultyList = () => {
    return request.get('/api/app/health-course-difficulties', {}, false, false)
}