Ver Fonte

fix: 视频帮办模块调整

zhangying há 1 semana atrás
pai
commit
7426c0ec3e
14 ficheiros alterados com 615 adições e 5 exclusões
  1. 127 0
      .docs/20260626-视频播放计数Redis+定时刷库设计方案.md
  2. 61 0
      .docs/sql/建表语句/视频帮办.sql
  3. 9 0
      jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroConfig.java
  4. 0 1
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/spbb/feedback/controller/SpbbFeedbackController.java
  5. 80 0
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/spbb/video/controller/SppbVideoController.java
  6. 47 0
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/spbb/video/entity/SppbFavorite.java
  7. 61 0
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/spbb/video/job/SppbVideoPlayFlushJob.java
  8. 24 0
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/spbb/video/mapper/SppbFavoriteMapper.java
  9. 15 0
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/spbb/video/mapper/xml/SppbFavoriteMapper.xml
  10. 56 0
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/spbb/video/service/ISppbFavoriteService.java
  11. 5 0
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/spbb/video/service/ISppbVideoService.java
  12. 86 0
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/spbb/video/service/impl/SppbFavoriteServiceImpl.java
  13. 37 4
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/spbb/video/service/impl/SppbVideoServiceImpl.java
  14. 7 0
      jeecgboot-vue3/src/views/spbb/video/components/SppbVideoForm.vue

+ 127 - 0
.docs/20260626-视频播放计数Redis+定时刷库设计方案.md

@@ -0,0 +1,127 @@
+# 视频播放计数设计方案(Redis + 定时刷库)
+
+> 日期:2026-06-26
+
+## 一、设计背景
+
+视频播放次数的统计需要避免"每播放一次就写一次数据库"的笨重做法,减少数据库写入压力。项目已集成 Redis,且提供了 `RedisUtil` 工具类和 `@Scheduled` 定时任务支持,采用 **Redis 计数器 + 定时批量刷库** 方案。
+
+## 二、技术选型
+
+| 组件 | 用途 | 依据 |
+|------|------|------|
+| **RedisUtil** | 播放计数自增(内存级操作,毫秒响应) | 项目已集成 Redis,`RedisUtil` 位于 `jeecg-boot-common` 依赖 |
+| **@Scheduled** | 定时将 Redis 增量刷入数据库 | 项目已有 `UserInfoJob`、`RedisServiceImpl` 等 `@Scheduled` 使用先例 |
+| **MySQL/DM8** | 持久化存储最终播放次数 | `SppbVideo.playCount` 字段已存在 |
+
+## 三、整体架构
+
+```
+用户播放视频
+    │
+    ▼
+PUT /spbb/sppbVideo/{videoId}/play
+    │
+    ▼
+SppbVideoServiceImpl.recordPlay(videoId)
+    │
+    ├── redisUtil.get("spbb:play:count:{videoId}")  读取当前计数
+    └── redisUtil.set("spbb:play:count:{videoId}", +1)  写入新计数
+    │  操作耗时 ≈ 1ms,不涉及数据库写入
+    │
+    ┌──────────────────────────────────────────────┐
+    │        @Scheduled(fixedRate = 300000)         │  每5分钟
+    │          SppbVideoPlayFlushJob                │
+    │                                               │
+    │  1. redisUtil.scan("spbb:play:count:*")       │  扫描所有计数key
+    │  2. 遍历每个 key,解析 videoId                │
+    │  3. UPDATE sppb_video SET play_count += ?     │  批量更新数据库
+    │  4. redisUtil.del(key)                        │  清理已落库的key
+    └──────────────────────────────────────────────┘
+```
+
+## 四、涉及的文件
+
+### 4.1 新增文件
+
+| 文件 | 说明 |
+|------|------|
+| `jeecg-boot/.../spbb/video/job/SppbVideoPlayFlushJob.java` | 定时刷库任务,每 5 分钟执行一次,将 Redis 中的播放计数增量批量更新到 `sppb_video.play_count` 字段 |
+
+### 4.2 修改文件
+
+| 文件 | 操作 | 原因 |
+|------|------|------|
+| `jeecg-boot/.../spbb/video/service/ISppbVideoService.java` | 新增 `recordPlay(String videoId)` 方法声明 | Service 接口层暴露播放计数接口 |
+| `jeecg-boot/.../spbb/video/service/impl/SppbVideoServiceImpl.java` | 新增 `RedisUtil` 注入 + `recordPlay` 方法实现 | 使用 Redis 计数器自增播放次数 |
+| `jeecg-boot/.../spbb/video/controller/SppbVideoController.java` | 新增 `PUT /{videoId}/play` 接口 | 对外提供播放计数 API |
+| `jeecg-boot-base-core/.../shiro/ShiroConfig.java` | 新增 `/spbb/sppbVideo/*/play` 至 anon 白名单 | 外部系统免认证调用 |
+
+## 五、详细实现
+
+### 5.1 播放计数接口
+
+```java
+// Controller 层 - 接收播放请求
+@PutMapping(value = "/{videoId}/play")
+public Result<String> recordPlay(@PathVariable String videoId) {
+    sppbVideoService.recordPlay(videoId);
+    return Result.OK("ok");
+}
+
+// Service 层 - Redis 计数器自增(毫秒级,不写数据库)
+@Override
+public void recordPlay(String videoId) {
+    String key = "spbb:play:count:" + videoId;
+    Integer count = oConvertUtils.getInt(redisUtil.get(key), 0);
+    redisUtil.set(key, count + 1);
+}
+```
+
+### 5.2 定时刷库任务
+
+```java
+@Component
+@Slf4j
+public class SppbVideoPlayFlushJob {
+
+    @Scheduled(fixedRate = 300000) // 每5分钟
+    public void flushPlayCount() {
+        // 1. 扫描所有播放计数 key
+        Collection<String> keys = redisUtil.scan("spbb:play:count:*");
+        // 2. 逐 key 处理:解析 videoId → 获取增量 → 更新 DB → 删除 key
+        for (String key : keys) {
+            String videoId = key.substring("spbb:play:count:".length());
+            Integer increment = oConvertUtils.getInt(redisUtil.get(key), 0);
+            SppbVideo video = sppbVideoService.getById(videoId);
+            if (video != null) {
+                video.setPlayCount(video.getPlayCount() == null
+                    ? increment : video.getPlayCount() + increment);
+                sppbVideoService.updateById(video);
+            }
+            redisUtil.del(key);
+        }
+    }
+}
+```
+
+## 六、关键设计决策
+
+| 决策 | 选择 | 理由 |
+|------|------|------|
+| 计数存储 | Redis String(非 List/Set) | 单 key 单值,读写简单,`scan` 可批量扫描 |
+| 原子性 | 非原子自增(read + set) | `RedisUtil` 无 `incr` 方法,播放次数非关键数据,少量并发误差可接受 |
+| 刷库频率 | 5 分钟 | 平衡实时性与数据库压力;可在配置中调整 |
+| 刷库后处理 | 删除 key | 避免 key 无限堆积,下一周期重新计数 |
+| 服务重启恢复 | Redis RDB/AOF 持久化 | 计数不丢失;若丢失也仅是少量播放次数,影响极小 |
+
+## 七、调用方式
+
+```
+PUT /jeecg-boot/spbb/sppbVideo/{videoId}/play
+```
+
+- 无需认证(已加入 Shiro anon 白名单)
+- 无请求体
+- 响应:`{"success": true, "code": 200, "message": "ok"}`
+- 前端可在视频播放结束时调用此接口

+ 61 - 0
.docs/sql/建表语句/视频帮办.sql

@@ -0,0 +1,61 @@
+CREATE TABLE "SPBB_FEEDBACK"
+(
+"ID" VARCHAR(36) NOT NULL,
+"VIDEO_DATA_ID" VARCHAR(50),
+"FEEDBACK_TYPE" VARCHAR(32),
+"VIDEO_CATEGORY" VARCHAR(32),
+"FEEDBACK_CONTENT" VARCHAR(500),
+"CONTACT_INFO" VARCHAR(50),
+"CREATE_BY" VARCHAR(50),
+"CREATE_TIME" DATETIME(6),
+"UPDATE_BY" VARCHAR(50),
+"UPDATE_TIME" DATETIME(6),
+"SYS_ORG_CODE" VARCHAR(64),
+NOT CLUSTER PRIMARY KEY("ID")) STORAGE(ON "MAIN", CLUSTERBTR) ;
+
+CREATE TABLE "SPPB_FAVORITE"
+(
+"ID" VARCHAR(36) NOT NULL,
+"VIDEO_ID" VARCHAR(36),
+"USER_ID" VARCHAR(50),
+"USER_SOURCE" VARCHAR(32),
+"FAVORITE_TIME" DATETIME(6),
+NOT CLUSTER PRIMARY KEY("ID")) STORAGE(ON "MAIN", CLUSTERBTR) ;
+
+COMMENT ON TABLE "SPPB_FAVORITE" IS '视频帮办-收藏表';
+COMMENT ON COLUMN "SPPB_FAVORITE"."ID" IS '主键ID';
+COMMENT ON COLUMN "SPPB_FAVORITE"."VIDEO_ID" IS '视频ID';
+COMMENT ON COLUMN "SPPB_FAVORITE"."USER_ID" IS '用户ID';
+COMMENT ON COLUMN "SPPB_FAVORITE"."USER_SOURCE" IS '用户来源';
+COMMENT ON COLUMN "SPPB_FAVORITE"."FAVORITE_TIME" IS '收藏时间';
+
+CREATE TABLE "SPPB_VIDEO"
+(
+"ID" VARCHAR(36) NOT NULL,
+"TITLE" VARCHAR(100),
+"COVER" VARCHAR(255),
+"VIDEO_FILE" VARCHAR(500),
+"FORMAT" VARCHAR(20),
+"FILE_SIZE" VARCHAR(100),
+"DURATION" VARCHAR(50),
+"DATA_ID" VARCHAR(100),
+"DATA_NAME" VARCHAR(100),
+"STEP_SUMMARY" VARCHAR(500),
+"CONTENT_DESC" VARCHAR(500),
+"RECORDING_TIME" DATETIME(6),
+"VIDEO_UPDATE_TIME" DATETIME(6),
+"STATE" NUMERIC(1,0),
+"BUSINESS_CATEGORY" NUMERIC(1,0),
+"KEYWORD" VARCHAR(255),
+"TARGET_AUDIENCE" VARCHAR(32),
+"PLAY_COUNT" NUMERIC(10,0),
+"FAVORITE_COUNT" NUMERIC(10,0),
+"PLAY_COMPLETION_RATE" VARCHAR(100),
+"REMARK" VARCHAR(255),
+"CREATE_BY" VARCHAR(50),
+"CREATE_TIME" DATETIME(6),
+"UPDATE_BY" VARCHAR(50),
+"UPDATE_TIME" DATETIME(6),
+"SYS_ORG_CODE" VARCHAR(64),
+NOT CLUSTER PRIMARY KEY("ID")) STORAGE(ON "MAIN", CLUSTERBTR) ;
+

+ 9 - 0
jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroConfig.java

@@ -103,6 +103,15 @@ public class ShiroConfig {
         filterChainDefinitionMap.put("/recruitmentFair/detail", "anon");//小程序-招聘会详情
         filterChainDefinitionMap.put("/policyfile/file/queryById", "anon");//小程序-政策文件详情
         filterChainDefinitionMap.put("/policyfile/file/view", "anon");//小程序-政策文件在线查看
+        filterChainDefinitionMap.put("/spbb/spbbFeedback/add", "anon");//外部系统-视频帮办反馈信息新增
+        filterChainDefinitionMap.put("/spbb/sppbVideo/list", "anon");//外部系统-视频帮办视频分页列表
+        filterChainDefinitionMap.put("/spbb/sppbVideo/queryById", "anon");//外部系统-视频帮办视频详情
+        filterChainDefinitionMap.put("/spbb/sppbVideo/listForSelect", "anon");//外部系统-视频帮办视频下拉列表
+        filterChainDefinitionMap.put("/spbb/sppbVideo/favorite", "anon");//外部系统-视频帮办收藏视频
+        filterChainDefinitionMap.put("/spbb/sppbVideo/unfavorite", "anon");//外部系统-视频帮办取消收藏
+        filterChainDefinitionMap.put("/spbb/sppbVideo/listFavorites", "anon");//外部系统-视频帮办查询用户收藏列表
+        filterChainDefinitionMap.put("/spbb/sppbVideo/hasFavorited", "anon");//外部系统-视频帮办查询是否已收藏
+        filterChainDefinitionMap.put("/spbb/sppbVideo/*/play", "anon");//外部系统-视频帮办记录播放
         filterChainDefinitionMap.put("/sys/user/checkOnlyUser", "anon");//校验用户是否存在
         filterChainDefinitionMap.put("/sys/user/register", "anon");//用户注册
         filterChainDefinitionMap.put("/sys/user/phoneVerification", "anon");//用户忘记密码验证手机号

+ 0 - 1
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/spbb/feedback/controller/SpbbFeedbackController.java

@@ -71,7 +71,6 @@ public class SpbbFeedbackController extends JeecgController<SpbbFeedback, ISpbbF
 	 */
 	@AutoLog(value = "视频帮办-反馈信息-添加")
 	@Operation(summary="视频帮办-反馈信息-添加")
-	@RequiresPermissions("spbb:spbb_feedback:add")
 	@PostMapping(value = "/add")
 	public Result<String> add(@RequestBody SpbbFeedback spbbFeedback) {
 		spbbFeedbackService.save(spbbFeedback);

+ 80 - 0
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/spbb/video/controller/SppbVideoController.java

@@ -14,6 +14,7 @@ import org.jeecg.common.system.base.controller.JeecgController;
 import org.jeecg.common.system.vo.LoginUser;
 import org.jeecg.common.util.oConvertUtils;
 import org.jeecg.modules.zjrs.spbb.video.entity.SppbVideo;
+import org.jeecg.modules.zjrs.spbb.video.service.ISppbFavoriteService;
 import org.jeecg.modules.zjrs.spbb.video.service.ISppbVideoService;
 import org.jeecgframework.poi.excel.def.NormalExcelConstants;
 import org.jeecgframework.poi.excel.entity.ExportParams;
@@ -40,6 +41,8 @@ import java.util.Map;
 public class SppbVideoController extends JeecgController<SppbVideo, ISppbVideoService> {
 	@Autowired
 	private ISppbVideoService sppbVideoService;
+	@Autowired
+	private ISppbFavoriteService sppbFavoriteService;
 
 	/**
 	 * 分页列表查询
@@ -204,4 +207,81 @@ public class SppbVideoController extends JeecgController<SppbVideo, ISppbVideoSe
         return Result.OK(resultList);
     }
 
+    /**
+     * 收藏视频
+     */
+    @AutoLog(value = "视频帮办-视频管理-收藏视频")
+    @Operation(summary="视频帮办-视频管理-收藏视频")
+    @PostMapping(value = "/favorite")
+    public Result<String> favorite(@RequestBody Map<String, String> params) {
+        String videoId = params.get("videoId");
+        String userId = params.get("userId");
+        String userSource = params.get("userSource");
+        if (oConvertUtils.isEmpty(videoId) || oConvertUtils.isEmpty(userId)) {
+            return Result.error("参数videoId和userId不能为空");
+        }
+        sppbFavoriteService.favorite(videoId, userId, userSource);
+        return Result.OK("收藏成功");
+    }
+
+    /**
+     * 取消收藏
+     */
+    @AutoLog(value = "视频帮办-视频管理-取消收藏")
+    @Operation(summary="视频帮办-视频管理-取消收藏")
+    @DeleteMapping(value = "/unfavorite")
+    public Result<String> unfavorite(@RequestParam(name="videoId") String videoId,
+                                      @RequestParam(name="userId") String userId) {
+        if (oConvertUtils.isEmpty(videoId) || oConvertUtils.isEmpty(userId)) {
+            return Result.error("参数videoId和userId不能为空");
+        }
+        sppbFavoriteService.unfavorite(videoId, userId);
+        return Result.OK("取消收藏成功");
+    }
+
+    /**
+     * 查询用户收藏视频列表
+     */
+    @Operation(summary="视频帮办-视频管理-查询用户收藏视频列表")
+    @GetMapping(value = "/listFavorites")
+    public Result<IPage<SppbVideo>> listFavorites(@RequestParam(name="userId") String userId,
+                                                             @RequestParam(name="userSource") String userSource,
+                                                             @RequestParam(name="pageNo", defaultValue="1") Integer pageNo,
+                                                             @RequestParam(name="pageSize", defaultValue="10") Integer pageSize) {
+        if (oConvertUtils.isEmpty(userId)) {
+            return Result.error("参数userId不能为空");
+        }
+        IPage<SppbVideo> pageList = sppbFavoriteService.listFavorites(userId, userSource, pageNo, pageSize);
+        return Result.OK(pageList);
+    }
+
+    /**
+     * 查询用户是否已收藏某个视频
+     */
+    @Operation(summary="视频帮办-视频管理-查询是否已收藏")
+    @GetMapping(value = "/hasFavorited")
+    public Result<Boolean> hasFavorited(@RequestParam(name="videoId") String videoId,
+                                         @RequestParam(name="userId") String userId,
+                                         @RequestParam(name="userSource") String userSource) {
+        if (oConvertUtils.isEmpty(videoId) || oConvertUtils.isEmpty(userId)) {
+            return Result.error("参数videoId和userId不能为空");
+        }
+        boolean favorited = sppbFavoriteService.hasFavorited(videoId, userId, userSource);
+        return Result.OK(favorited);
+    }
+
+    /**
+     * 记录视频播放次数(Redis 计数,由定时任务批量刷库)
+     */
+    @AutoLog(value = "视频帮办-视频管理-记录播放")
+    @Operation(summary="视频帮办-视频管理-记录播放")
+    @PutMapping(value = "/{videoId}/play")
+    public Result<String> recordPlay(@PathVariable(name="videoId") String videoId) {
+        if (oConvertUtils.isEmpty(videoId)) {
+            return Result.error("参数videoId不能为空");
+        }
+        sppbVideoService.recordPlay(videoId);
+        return Result.OK("ok");
+    }
+
 }

+ 47 - 0
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/spbb/video/entity/SppbFavorite.java

@@ -0,0 +1,47 @@
+package org.jeecg.modules.zjrs.spbb.video.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.experimental.Accessors;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.io.Serializable;
+
+/**
+ * @Description: 视频帮办-收藏表
+ * @Author: jeecg-boot
+ * @Date:   2026-06-26
+ * @Version: V1.0
+ */
+@Data
+@TableName("sppb_favorite")
+@Accessors(chain = true)
+@EqualsAndHashCode(callSuper = false)
+@Schema(description="视频帮办-收藏表")
+public class SppbFavorite implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /**主键*/
+    @TableId(type = IdType.ASSIGN_ID)
+    @Schema(description = "主键")
+    private java.lang.String id;
+    /**视频ID*/
+    @Schema(description = "视频ID")
+    private java.lang.String videoId;
+    /**用户ID*/
+    @Schema(description = "用户ID")
+    private java.lang.String userId;
+    /**用户来源*/
+    @Schema(description = "用户来源")
+    private java.lang.String userSource;
+    /**收藏时间*/
+    @JsonFormat(timezone = "GMT+8",pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss")
+    @Schema(description = "收藏时间")
+    private java.util.Date favoriteTime;
+}

+ 61 - 0
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/spbb/video/job/SppbVideoPlayFlushJob.java

@@ -0,0 +1,61 @@
+package org.jeecg.modules.zjrs.spbb.video.job;
+
+import lombok.extern.slf4j.Slf4j;
+import org.jeecg.common.util.RedisUtil;
+import org.jeecg.common.util.oConvertUtils;
+import org.jeecg.modules.zjrs.spbb.video.entity.SppbVideo;
+import org.jeecg.modules.zjrs.spbb.video.service.ISppbVideoService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.Collection;
+
+/**
+ * 视频播放计数定时刷库任务
+ * 每5分钟将 Redis 中的播放次数增量批量更新到数据库
+ */
+@Component
+@Slf4j
+public class SppbVideoPlayFlushJob {
+
+    @Autowired
+    private RedisUtil redisUtil;
+
+    @Autowired
+    private ISppbVideoService sppbVideoService;
+
+    @Scheduled(fixedRate = 300000) // 每5分钟执行一次
+    public void flushPlayCount() {
+        // 扫描所有播放计数 Redis key
+        Collection<String> keys = redisUtil.scan("spbb:play:count:*");
+        if (keys == null || keys.isEmpty()) {
+            return;
+        }
+
+        int totalCount = 0;
+        for (String key : keys) {
+            // 从 key 中提取 videoId(格式: spbb:play:count:{videoId})
+            String videoId = key.substring("spbb:play:count:".length());
+            Integer increment = oConvertUtils.getInt(redisUtil.get(key), 0);
+            if (increment <= 0) {
+                redisUtil.del(key);
+                continue;
+            }
+
+            // 更新数据库中的播放次数
+            SppbVideo video = sppbVideoService.getById(videoId);
+            if (video != null) {
+                video.setPlayCount(video.getPlayCount() == null ? increment : video.getPlayCount() + increment);
+                sppbVideoService.updateById(video);
+            }
+
+            redisUtil.del(key);
+            totalCount += increment;
+        }
+
+        if (totalCount > 0) {
+            log.info("视频播放计数同步完成,共处理 {} 个视频,累计 {} 次播放", keys.size(), totalCount);
+        }
+    }
+}

+ 24 - 0
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/spbb/video/mapper/SppbFavoriteMapper.java

@@ -0,0 +1,24 @@
+package org.jeecg.modules.zjrs.spbb.video.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.apache.ibatis.annotations.Param;
+import org.jeecg.modules.zjrs.spbb.video.entity.SppbFavorite;
+import org.jeecg.modules.zjrs.spbb.video.entity.SppbVideo;
+
+import java.util.Map;
+
+/**
+ * @Description: 视频帮办-收藏表
+ * @Author: jeecg-boot
+ * @Date:   2026-06-26
+ * @Version: V1.0
+ */
+public interface SppbFavoriteMapper extends BaseMapper<SppbFavorite> {
+
+    /**
+     * 分页查询用户收藏的视频列表(关联视频表)
+     */
+    IPage<SppbVideo> selectFavoriteVideos(Page<?> page, @Param("userId") String userId, @Param("userSource") String userSource);
+}

+ 15 - 0
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/spbb/video/mapper/xml/SppbFavoriteMapper.xml

@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="org.jeecg.modules.zjrs.spbb.video.mapper.SppbFavoriteMapper">
+
+    <!-- 分页查询用户收藏的视频列表(关联视频表) -->
+    <select id="selectFavoriteVideos" resultType="org.jeecg.modules.zjrs.spbb.video.entity.SppbVideo">
+        SELECT v.*
+        FROM sppb_favorite f
+        INNER JOIN sppb_video v ON f.video_id = v.id
+        WHERE f.user_id = #{userId}
+          AND f.user_source = #{userSource}
+        ORDER BY f.favorite_time DESC
+    </select>
+
+</mapper>

+ 56 - 0
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/spbb/video/service/ISppbFavoriteService.java

@@ -0,0 +1,56 @@
+package org.jeecg.modules.zjrs.spbb.video.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.service.IService;
+import org.jeecg.modules.zjrs.spbb.video.entity.SppbFavorite;
+
+import org.jeecg.modules.zjrs.spbb.video.entity.SppbVideo;
+
+import java.util.Map;
+
+/**
+ * @Description: 视频帮办-收藏表
+ * @Author: jeecg-boot
+ * @Date:   2026-06-26
+ * @Version: V1.0
+ */
+public interface ISppbFavoriteService extends IService<SppbFavorite> {
+
+    /**
+     * 收藏视频
+     *
+     * @param videoId 视频ID
+     * @param userId 用户ID
+     * @param userSource 用户来源
+     */
+    void favorite(String videoId, String userId, String userSource);
+
+    /**
+     * 取消收藏
+     *
+     * @param videoId 视频ID
+     * @param userId 用户ID
+     */
+    void unfavorite(String videoId, String userId);
+
+    /**
+     * 分页查询用户收藏的视频列表
+     *
+     * @param userId 用户ID
+     * @param userSource 用户来源
+     * @param pageNo 页码
+     * @param pageSize 每页条数
+     * @return 收藏视频列表
+     */
+    IPage<SppbVideo> listFavorites(String userId, String userSource, int pageNo, int pageSize);
+
+    /**
+     * 查询用户是否已收藏某个视频
+     *
+     * @param videoId 视频ID
+     * @param userId 用户ID
+     * @param userSource 用户来源
+     * @return true-已收藏,false-未收藏
+     */
+    boolean hasFavorited(String videoId, String userId, String userSource);
+}

+ 5 - 0
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/spbb/video/service/ISppbVideoService.java

@@ -40,4 +40,9 @@ public interface ISppbVideoService extends IService<SppbVideo> {
      */
     List<SppbVideo> exportList(SppbVideo sppbVideo, HttpServletRequest request);
 
+    /**
+     * 记录视频播放(Redis 计数,定时刷库)
+     */
+    void recordPlay(String videoId);
+
 }

+ 86 - 0
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/spbb/video/service/impl/SppbFavoriteServiceImpl.java

@@ -0,0 +1,86 @@
+package org.jeecg.modules.zjrs.spbb.video.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import org.jeecg.modules.zjrs.spbb.video.entity.SppbFavorite;
+import org.jeecg.modules.zjrs.spbb.video.entity.SppbVideo;
+import org.jeecg.modules.zjrs.spbb.video.mapper.SppbFavoriteMapper;
+import org.jeecg.modules.zjrs.spbb.video.service.ISppbFavoriteService;
+import org.jeecg.modules.zjrs.spbb.video.service.ISppbVideoService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Date;
+
+/**
+ * @Description: 视频帮办-收藏表
+ * @Author: jeecg-boot
+ * @Date:   2026-06-26
+ * @Version: V1.0
+ */
+@Service
+public class SppbFavoriteServiceImpl extends ServiceImpl<SppbFavoriteMapper, SppbFavorite> implements ISppbFavoriteService {
+
+    @Autowired
+    private ISppbVideoService sppbVideoService;
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void favorite(String videoId, String userId, String userSource) {
+        // 检查是否已收藏,已收藏则直接返回
+        QueryWrapper<SppbFavorite> queryWrapper = new QueryWrapper<>();
+        queryWrapper.eq("video_id", videoId).eq("user_id", userId);
+        if (this.count(queryWrapper) > 0) {
+            return;
+        }
+
+        // 新增收藏记录
+        SppbFavorite favorite = new SppbFavorite();
+        favorite.setVideoId(videoId);
+        favorite.setUserId(userId);
+        favorite.setUserSource(userSource);
+        favorite.setFavoriteTime(new Date());
+        this.save(favorite);
+
+        // 同步更新视频收藏次数+1
+        SppbVideo video = sppbVideoService.getById(videoId);
+        if (video != null) {
+            video.setFavoriteCount(video.getFavoriteCount() == null ? 1 : video.getFavoriteCount() + 1);
+            sppbVideoService.updateById(video);
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void unfavorite(String videoId, String userId) {
+        // 删除收藏记录
+        QueryWrapper<SppbFavorite> queryWrapper = new QueryWrapper<>();
+        queryWrapper.eq("video_id", videoId).eq("user_id", userId);
+        this.remove(queryWrapper);
+
+        // 同步更新视频收藏次数-1
+        SppbVideo video = sppbVideoService.getById(videoId);
+        if (video != null && video.getFavoriteCount() != null && video.getFavoriteCount() > 0) {
+            video.setFavoriteCount(video.getFavoriteCount() - 1);
+            sppbVideoService.updateById(video);
+        }
+    }
+
+    @Override
+    public IPage<SppbVideo> listFavorites(String userId, String userSource, int pageNo, int pageSize) {
+        Page<SppbVideo> page = new Page<>(pageNo, pageSize);
+        return this.baseMapper.selectFavoriteVideos(page, userId, userSource);
+    }
+
+    @Override
+    public boolean hasFavorited(String videoId, String userId, String userSource) {
+        QueryWrapper<SppbFavorite> queryWrapper = new QueryWrapper<>();
+        queryWrapper.eq("video_id", videoId)
+                .eq("user_id", userId)
+                .eq("user_source", userSource);
+        return this.count(queryWrapper) > 0;
+    }
+}

+ 37 - 4
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/spbb/video/service/impl/SppbVideoServiceImpl.java

@@ -5,11 +5,14 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import jakarta.servlet.http.HttpServletRequest;
+import lombok.extern.slf4j.Slf4j;
 import org.jeecg.common.system.query.QueryGenerator;
 import org.jeecg.common.util.oConvertUtils;
+import org.jeecg.common.util.RedisUtil;
 import org.jeecg.modules.zjrs.spbb.video.entity.SppbVideo;
 import org.jeecg.modules.zjrs.spbb.video.mapper.SppbVideoMapper;
 import org.jeecg.modules.zjrs.spbb.video.service.ISppbVideoService;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -26,8 +29,12 @@ import java.util.stream.Collectors;
  * @Version: V1.0
  */
 @Service
+@Slf4j
 public class SppbVideoServiceImpl extends ServiceImpl<SppbVideoMapper, SppbVideo> implements ISppbVideoService {
 
+    @Autowired
+    private RedisUtil redisUtil;
+
     @Override
     public List<Map<String, String>> listForSelect() {
         QueryWrapper<SppbVideo> queryWrapper = new QueryWrapper<>();
@@ -67,12 +74,28 @@ public class SppbVideoServiceImpl extends ServiceImpl<SppbVideoMapper, SppbVideo
 
     @Override
     public IPage<SppbVideo> queryPageList(SppbVideo sppbVideo, int pageNo, int pageSize, HttpServletRequest req) {
-        // 根据请求参数构造查询条件
+        // 将需要模糊查询的字段暂存并清空,避免 QueryGenerator 生成等值条件
+        String title = sppbVideo.getTitle();
+        String dataName = sppbVideo.getDataName();
+        String keyword = sppbVideo.getKeyword();
+        sppbVideo.setTitle(null);
+        sppbVideo.setDataName(null);
+        sppbVideo.setKeyword(null);
+
+        // 根据请求参数构造查询条件(排除已清空的三个模糊字段)
         QueryWrapper<SppbVideo> queryWrapper = QueryGenerator.initQueryWrapper(sppbVideo, req.getParameterMap());
-        // 视频标题模糊查询
-        if (oConvertUtils.isNotEmpty(sppbVideo.getTitle())) {
-            queryWrapper.like("title", sppbVideo.getTitle());
+
+        // 恢复模糊查询字段并添加 LIKE '%value%' 条件
+        if (oConvertUtils.isNotEmpty(title)) {
+            queryWrapper.like("title", title);
+        }
+        if (oConvertUtils.isNotEmpty(dataName)) {
+            queryWrapper.like("data_name", dataName);
         }
+        if (oConvertUtils.isNotEmpty(keyword)) {
+            queryWrapper.like("keyword", keyword);
+        }
+
         Page<SppbVideo> page = new Page<>(pageNo, pageSize);
         return this.page(page, queryWrapper);
     }
@@ -89,4 +112,14 @@ public class SppbVideoServiceImpl extends ServiceImpl<SppbVideoMapper, SppbVideo
         }
         return this.list(queryWrapper);
     }
+
+    @Override
+    public void recordPlay(String videoId) {
+        // Redis 计数器自增,后续由定时任务批量刷入数据库
+        String key = "spbb:play:count:" + videoId;
+        Integer count = oConvertUtils.getInt(redisUtil.get(key), 0);
+        redisUtil.set(key, count + 1);
+        // 记录日志以供排查
+        log.info("播放计数已记录, videoId={}, key={}, newCount={}", videoId, key, count + 1);
+    }
 }

+ 7 - 0
jeecgboot-vue3/src/views/spbb/video/components/SppbVideoForm.vue

@@ -294,6 +294,13 @@
           tmpData[key] = record[key];
         }
       });
+      // 字典字段值为 number 类型,需转为 string 以匹配 getDictOptions 返回的选项 value
+      const dictFields = ['state', 'businessCategory', 'targetAudience'];
+      dictFields.forEach((field) => {
+        if (tmpData[field] !== null && tmpData[field] !== undefined) {
+          tmpData[field] = String(tmpData[field]);
+        }
+      });
       //赋值
       Object.assign(formData, tmpData);
     });