feat(养生课程): 新增当前在学人数统计与展示功能
- 新增数据库字段studying_count并提供兼容的初始化SQL脚本
- 添加用户访问课程详情时自动递增在学人数的逻辑
- 同步更新所有后端实体、VO、DTO类以支持新字段
- 更新前后端所有页面的展示逻辑,将「X人正在学」替换为使用当前在学人数
- 新增管理后台课程编辑页的初始在学人数配置项与校验规则
| | |
| | | `base_sales_count` INT NOT NULL DEFAULT 0 COMMENT '基础销量', |
| | | `real_sales_count` INT NOT NULL DEFAULT 0 COMMENT '真实销量', |
| | | `learn_user_count` INT NOT NULL DEFAULT 0 COMMENT '累计学习人数', |
| | | `studying_count` INT NOT NULL DEFAULT 0 COMMENT '正在学习人数', |
| | | `favorite_count` INT NOT NULL DEFAULT 0 COMMENT '收藏量', |
| | | `total_duration_seconds` INT NOT NULL DEFAULT 0 COMMENT '课程总时长(秒)', |
| | | `video_count` INT NOT NULL DEFAULT 0 COMMENT '视频小节数', |
| New file |
| | |
| | | -- 在目标主库执行;仅新增养生课程「正在学习人数」字段,不修改已有业务数据,可重复执行。 |
| | | SET @ddl = IF( |
| | | (SELECT COUNT(*) FROM information_schema.COLUMNS |
| | | WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'tcm_health_course' |
| | | AND COLUMN_NAME = 'studying_count') = 0, |
| | | 'ALTER TABLE tcm_health_course ADD COLUMN studying_count INT NOT NULL DEFAULT 0 COMMENT ''正在学习人数'' AFTER learn_user_count', |
| | | 'SELECT 1'); |
| | | PREPARE stmt FROM @ddl; |
| | | EXECUTE stmt; |
| | | DEALLOCATE PREPARE stmt; |
| | |
| | | itemVO.setDifficultyName(trimToEmpty(row.getDifficultyName())); |
| | | itemVO.setSalePrice(defaultPrice(row.getSalePrice())); |
| | | itemVO.setLearnUserCount(defaultNumber(row.getLearnUserCount())); |
| | | itemVO.setStudyingCount(defaultNumber(row.getStudyingCount())); |
| | | result.add(itemVO); |
| | | } |
| | | return result; |
| | |
| | | if (courseDetail.getStatus() == null || courseDetail.getStatus().intValue() != COURSE_STATUS_ENABLED) { |
| | | throw new IllegalArgumentException("养生课程不存在"); |
| | | } |
| | | // 进入课程详情即视为一次学习行为,正在学习人数自增;返回值按自增后的口径展示 |
| | | incrementStudyingCount(courseId); |
| | | |
| | | List<HealthCourseVideo> enabledVideos = listEnabledVideos(courseId); |
| | | AppHealthCourseDetailVO detailVO = new AppHealthCourseDetailVO(); |
| | |
| | | detailVO.setDifficultyName(trimToEmpty(courseDetail.getDifficultyName())); |
| | | detailVO.setSalePrice(defaultPrice(courseDetail.getSalePrice())); |
| | | detailVO.setLearnUserCount(defaultNumber(courseDetail.getLearnUserCount())); |
| | | detailVO.setStudyingCount(Integer.valueOf(defaultNumber(courseDetail.getStudyingCount()) + 1)); |
| | | detailVO.setSummary(trimToEmpty(courseDetail.getSummary())); |
| | | detailVO.setTargetUsers(trimToEmpty(courseDetail.getTargetUsers())); |
| | | detailVO.setContent(trimToEmpty(courseDetail.getContent())); |
| | |
| | | itemVO.setTeacherName(trimToEmpty(row.getTeacherName())); |
| | | itemVO.setTeacherAvatar(trimToEmpty(row.getTeacherAvatar())); |
| | | itemVO.setLearnUserCount(defaultNumber(row.getLearnUserCount())); |
| | | itemVO.setStudyingCount(defaultNumber(row.getStudyingCount())); |
| | | itemVO.setFavoritedAt(row.getFavoritedAt()); |
| | | result.add(itemVO); |
| | | } |
| | |
| | | } |
| | | |
| | | /** |
| | | * 正在学习人数自增 1,进入课程详情时调用。 |
| | | */ |
| | | private void incrementStudyingCount(Long courseId) { |
| | | LambdaUpdateWrapper<HealthCourse> updateWrapper = new LambdaUpdateWrapper<HealthCourse>() |
| | | .eq(HealthCourse::getId, courseId) |
| | | .setSql("studying_count = COALESCE(studying_count, 0) + 1"); |
| | | healthCourseMapper.update(null, updateWrapper); |
| | | } |
| | | |
| | | /** |
| | | * 构造收藏操作返回结果,避免控制器层重复拼装。 |
| | | */ |
| | | private AppHealthCourseFavoriteResultVO buildFavoriteResult(Long courseId, boolean favorited, Integer favoriteCount) { |
| | |
| | | |
| | | private Integer learnUserCount; |
| | | |
| | | /** 正在学习人数:访问详情页时自增,随详情接口返回最新值 */ |
| | | private Integer studyingCount; |
| | | |
| | | private String summary; |
| | | |
| | | private String targetUsers; |
| | |
| | | private String teacherName; |
| | | private String teacherAvatar; |
| | | private Integer learnUserCount; |
| | | |
| | | /** 正在学习人数:与课程列表/详情口径一致,用于「X人正在学」展示 */ |
| | | private Integer studyingCount; |
| | | private LocalDateTime favoritedAt; |
| | | } |
| | |
| | | private BigDecimal salePrice; |
| | | |
| | | private Integer learnUserCount; |
| | | |
| | | /** 正在学习人数:每次进入课程详情自增,用于列表展示 */ |
| | | private Integer studyingCount; |
| | | } |
| | |
| | | vo.setDifficultyName(categoryNameMap.get(course.getDifficultyCategoryId())); |
| | | vo.setSalePrice(course.getSalePrice() == null ? BigDecimal.ZERO : course.getSalePrice()); |
| | | vo.setLearnUserCount(defaultNumber(course.getLearnUserCount())); |
| | | vo.setStudyingCount(defaultNumber(course.getStudyingCount())); |
| | | result.add(vo); |
| | | } |
| | | return result; |
| | |
| | | private BigDecimal salePrice; |
| | | |
| | | private Integer learnUserCount; |
| | | |
| | | /** 正在学习人数:与课程详情/列表口径一致,用于「X人正在学」展示 */ |
| | | private Integer studyingCount; |
| | | } |
| | |
| | | courseVO.setDifficultyName(trimToEmpty(row.getDifficultyName())); |
| | | courseVO.setSalePrice(defaultPrice(row.getSalePrice())); |
| | | courseVO.setLearnUserCount(defaultNumber(row.getLearnUserCount())); |
| | | courseVO.setStudyingCount(defaultNumber(row.getStudyingCount())); |
| | | result.add(courseVO); |
| | | } |
| | | return result; |
| | |
| | | private BigDecimal salePrice; |
| | | |
| | | private Integer learnUserCount; |
| | | |
| | | /** 正在学习人数:与课程列表/详情口径一致,用于「X人正在学」展示 */ |
| | | private Integer studyingCount; |
| | | } |
| | |
| | | import lombok.Setter; |
| | | |
| | | import javax.validation.constraints.DecimalMin; |
| | | import javax.validation.constraints.Min; |
| | | import javax.validation.constraints.NotBlank; |
| | | import javax.validation.constraints.NotNull; |
| | | import javax.validation.constraints.Size; |
| | |
| | | @NotNull(message = "请输入基础购买量") |
| | | private Integer baseSalesCount; |
| | | |
| | | @NotNull(message = "请输入学习人数") |
| | | @Min(value = 0, message = "学习人数不能小于0") |
| | | private Integer studyingCount; |
| | | |
| | | @NotNull(message = "请选择是否启用") |
| | | private Integer status; |
| | | |
| | |
| | | @TableField("learn_user_count") |
| | | private Integer learnUserCount; |
| | | |
| | | @TableField("studying_count") |
| | | private Integer studyingCount; |
| | | |
| | | @TableField("favorite_count") |
| | | private Integer favoriteCount; |
| | | |
| | |
| | | private Integer salesCount; |
| | | |
| | | @ExcelProperty("学习人数") |
| | | private Integer learnUserCount; |
| | | private Integer studyingCount; |
| | | |
| | | @ExcelProperty("共计课时") |
| | | private String totalDuration; |
| | |
| | | private String teacherName; |
| | | private String teacherAvatar; |
| | | private Integer learnUserCount; |
| | | private Integer studyingCount; |
| | | private LocalDateTime favoritedAt; |
| | | } |
| | |
| | | export.setDifficultyName(defaultText(row.getDifficultyName())); |
| | | export.setSalePrice(row.getSalePrice() == null ? BigDecimal.ZERO : row.getSalePrice()); |
| | | export.setSalesCount(resolveSalesCount(row.getBaseSalesCount(), row.getRealSalesCount())); |
| | | export.setLearnUserCount(defaultNumber(row.getLearnUserCount())); |
| | | export.setStudyingCount(defaultNumber(row.getStudyingCount())); |
| | | export.setTotalDuration(formatDuration(defaultNumber(row.getTotalDurationSeconds()))); |
| | | export.setFavoriteCount(defaultNumber(row.getFavoriteCount())); |
| | | export.setExpertName(defaultText(row.getExpertName())); |
| | |
| | | course.setBannerImgs(req.getBannerImgs()); |
| | | course.setContent(req.getContent().trim()); |
| | | course.setBaseSalesCount(req.getBaseSalesCount() == null ? 0 : req.getBaseSalesCount()); |
| | | // 正在学习人数由后台表单配置初始值,App 端每次进入课程详情会在此基础上自增 |
| | | course.setStudyingCount(req.getStudyingCount() == null ? 0 : req.getStudyingCount()); |
| | | course.setStatus(req.getStatus()); |
| | | course.setIsHomeRecommend(req.getIsHomeRecommend()); |
| | | course.setSort(defaultNumber(req.getSort())); |
| | |
| | | vo.setSalePrice(row.getSalePrice() == null ? BigDecimal.ZERO : row.getSalePrice()); |
| | | vo.setSalesCount(resolveSalesCount(row.getBaseSalesCount(), row.getRealSalesCount())); |
| | | vo.setLearnUserCount(defaultNumber(row.getLearnUserCount())); |
| | | vo.setStudyingCount(defaultNumber(row.getStudyingCount())); |
| | | vo.setTotalDurationSeconds(defaultNumber(row.getTotalDurationSeconds())); |
| | | vo.setFavoriteCount(defaultNumber(row.getFavoriteCount())); |
| | | vo.setExpert(new ExpertVO(row.getExpertId(), row.getExpertName(), row.getExpertAvatar())); |
| | |
| | | vo.setRealSalesCount(defaultNumber(row.getRealSalesCount())); |
| | | vo.setSalesCount(resolveSalesCount(row.getBaseSalesCount(), row.getRealSalesCount())); |
| | | vo.setLearnUserCount(defaultNumber(row.getLearnUserCount())); |
| | | vo.setStudyingCount(defaultNumber(row.getStudyingCount())); |
| | | vo.setFavoriteCount(defaultNumber(row.getFavoriteCount())); |
| | | vo.setTotalDurationSeconds(defaultNumber(row.getTotalDurationSeconds())); |
| | | vo.setVideoCount(defaultNumber(row.getVideoCount())); |
| | |
| | | private Integer baseSalesCount; |
| | | private Integer realSalesCount; |
| | | private Integer learnUserCount; |
| | | private Integer studyingCount; |
| | | private Integer favoriteCount; |
| | | private Integer totalDurationSeconds; |
| | | private Integer videoCount; |
| | |
| | | private Integer realSalesCount; |
| | | private Integer salesCount; |
| | | private Integer learnUserCount; |
| | | private Integer studyingCount; |
| | | private Integer favoriteCount; |
| | | private Integer totalDurationSeconds; |
| | | private Integer videoCount; |
| | |
| | | private BigDecimal salePrice; |
| | | private Integer salesCount; |
| | | private Integer learnUserCount; |
| | | private Integer studyingCount; |
| | | private Integer totalDurationSeconds; |
| | | private Integer favoriteCount; |
| | | private ExpertVO expert; |
| | |
| | | private Integer baseSalesCount; |
| | | private Integer realSalesCount; |
| | | private Integer learnUserCount; |
| | | private Integer studyingCount; |
| | | private Integer totalDurationSeconds; |
| | | private Integer favoriteCount; |
| | | private Long expertId; |
| | |
| | | e.name AS teacherName, |
| | | e.avatar AS teacherAvatar, |
| | | c.learn_user_count AS learnUserCount, |
| | | c.studying_count AS studyingCount, |
| | | latest.action_time AS favoritedAt |
| | | FROM ( |
| | | SELECT r.course_id, r.action_time, r.id |
| | |
| | | c.base_sales_count AS baseSalesCount, |
| | | c.real_sales_count AS realSalesCount, |
| | | c.learn_user_count AS learnUserCount, |
| | | c.studying_count AS studyingCount, |
| | | c.favorite_count AS favoriteCount, |
| | | c.total_duration_seconds AS totalDurationSeconds, |
| | | c.video_count AS videoCount, |
| | |
| | | salePrice?: number; |
| | | salesCount?: number; |
| | | learnUserCount?: number; |
| | | studyingCount?: number; |
| | | totalDurationSeconds?: number; |
| | | favoriteCount?: number; |
| | | coverImg?: string; |
| | |
| | | }, |
| | | { |
| | | title: '学习人数', |
| | | dataIndex: 'learnUserCount', |
| | | dataIndex: 'studyingCount', |
| | | width: 100, |
| | | render: (value) => `${value || 0}人`, |
| | | }, |
| | |
| | | isHomeRecommend: false, |
| | | salePrice: 0, |
| | | baseSalesCount: 0, |
| | | studyingCount: 0, |
| | | sort: 0, |
| | | summary: '', |
| | | targetUsers: '', |
| | |
| | | coverImg: course.coverImg, |
| | | bannerImgs: course.bannerImgs || [], |
| | | baseSalesCount: course.baseSalesCount, |
| | | studyingCount: course.studyingCount ?? 0, |
| | | sort: course.sort ?? 0, |
| | | status: course.status === 1, |
| | | isHomeRecommend: course.isHomeRecommend === 1, |
| | |
| | | bannerImgs, |
| | | content: values.content, |
| | | sort: Number(values.sort || 0), |
| | | studyingCount: Number(values.studyingCount || 0), |
| | | status: values.status ? 1 : 0, |
| | | isHomeRecommend: values.isHomeRecommend ? 1 : 0, |
| | | }; |
| | |
| | | > |
| | | <InputNumber min={0} precision={0} placeholder="请输入" className="health-course-edit-form__number" /> |
| | | </Form.Item> |
| | | <Form.Item |
| | | label="学习人数" |
| | | name="studyingCount" |
| | | rules={[{ required: true, message: '请输入学习人数' }]} |
| | | > |
| | | <InputNumber |
| | | min={0} |
| | | precision={0} |
| | | placeholder="课程初始学习人数" |
| | | className="health-course-edit-form__number" |
| | | /> |
| | | </Form.Item> |
| | | <Form.Item label="课程介绍" name="summary"> |
| | | <Input.TextArea rows={4} maxLength={500} placeholder="请输入" disabled={loading} /> |
| | | </Form.Item> |
| | |
| | | <image class="person-icon" |
| | | src="http://obs.haizhentong.com/file/person-f8f5e24d3abe449c95b58cd6253aabfd.png" |
| | | mode="aspectFit"></image> |
| | | <text class="stats-text">{{ formatLearningCount(courseDetail.learnUserCount) }}人正在学</text> |
| | | <text class="stats-text">{{ formatLearningCount(courseDetail.studyingCount) }}人正在学</text> |
| | | </view> |
| | | </view> |
| | | |
| | |
| | | difficultyName : string |
| | | salePrice : number | string |
| | | learnUserCount : number |
| | | /** 正在学习人数:后端在返回详情时会自增,用于「X人正在学」展示 */ |
| | | studyingCount : number |
| | | summary : string |
| | | targetUsers : string |
| | | content : string |
| | |
| | | difficultyName: '', |
| | | salePrice: 0, |
| | | learnUserCount: 0, |
| | | studyingCount: 0, |
| | | summary: '', |
| | | targetUsers: '', |
| | | content: '', |
| | |
| | | |
| | | onMounted(() => { |
| | | setTimeout(() => { |
| | | const pageOptions = getCurrentPageOptions() |
| | | if (!courseId.value) { |
| | | courseId.value = resolveCourseId(pageOptions) |
| | | shouldPlayLatestVideoOnLoad.value = resolveBooleanRouteParam(pageOptions, 'playLatest') |
| | | shouldPlayFirstVideoOnLoad.value = resolveBooleanRouteParam(pageOptions, 'playFirst') |
| | | // onLoad 已解析出课程 id 时不再重复请求,避免详情接口被调用两次导致学习人数重复自增 |
| | | if (courseId.value) { |
| | | return |
| | | } |
| | | const pageOptions = getCurrentPageOptions() |
| | | courseId.value = resolveCourseId(pageOptions) |
| | | shouldPlayLatestVideoOnLoad.value = resolveBooleanRouteParam(pageOptions, 'playLatest') |
| | | shouldPlayFirstVideoOnLoad.value = resolveBooleanRouteParam(pageOptions, 'playFirst') |
| | | loadCourseDetail() |
| | | }, 0) |
| | | }) |
| | |
| | | </view> |
| | | <view class="student-count"> |
| | | <wd-icon name="user" size="12px" color="#999" custom-class="user-icon"></wd-icon> |
| | | <text>{{ formatLearnUserCount(item.learnUserCount) }}人正在学</text> |
| | | <text>{{ formatLearnUserCount(item.studyingCount) }}人正在学</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | |
| | | teacherName?: string |
| | | teacherAvatar?: string |
| | | learnUserCount?: number | null |
| | | /** 正在学习人数:收藏接口返回,用于「X人正在学」展示 */ |
| | | studyingCount?: number | null |
| | | selected?: boolean |
| | | } |
| | | |
| | |
| | | teacherName: normalizeOptionalText(record.teacherName), |
| | | teacherAvatar: normalizeOptionalText(record.teacherAvatar), |
| | | learnUserCount: normalizeOptionalNumber(record.learnUserCount), |
| | | studyingCount: normalizeOptionalNumber(record.studyingCount), |
| | | selected: false, |
| | | } |
| | | }) |
| | |
| | | <view class="course-bottom"> |
| | | <view class="people-box"> |
| | | <image class="person-icon" src="/static/image/home/person.png" mode="aspectFit"></image> |
| | | <text class="people-text">{{ formatLearningCount(item.learnUserCount) }}人正在学</text> |
| | | <text class="people-text">{{ formatLearningCount(item.studyingCount) }}人正在学</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | |
| | | difficultyName?: string |
| | | salePrice?: number | string |
| | | learnUserCount?: number |
| | | /** 正在学习人数:列表接口返回,用于「X人正在学」展示 */ |
| | | studyingCount?: number |
| | | } |
| | | |
| | | interface DifficultyOptionItem { |
| | |
| | | <view class="course-bottom"> |
| | | <view class="people"> |
| | | <image src="/static/image/home/person.png" class="person-icon"></image> |
| | | <text>{{ formatLearningCount(item.learnUserCount) }}人正在学</text> |
| | | <text>{{ formatLearningCount(item.studyingCount) }}人正在学</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | |
| | | difficultyName ?: string; |
| | | salePrice ?: number | string; |
| | | learnUserCount ?: number; |
| | | /** 正在学习人数:首页精选课程接口返回,用于「X人正在学」展示 */ |
| | | studyingCount ?: number; |
| | | } |
| | | |
| | | interface HomeFeaturedPopularizationItem { |
| | |
| | | <view class="course-bottom"> |
| | | <view class="people-box"> |
| | | <image class="person-icon" src="/static/image/home/person.png" mode="aspectFit"></image> |
| | | <text class="people-text">{{ formatLearningCount(item.learnUserCount) }}人正在学</text> |
| | | <text class="people-text">{{ formatLearningCount(item.studyingCount) }}人正在学</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | |
| | | difficultyName?: string |
| | | salePrice?: number | string |
| | | learnUserCount?: number |
| | | /** 正在学习人数:搜索接口返回,用于「X人正在学」展示 */ |
| | | studyingCount?: number |
| | | } |
| | | |
| | | interface SearchPopularizationItem { |