فهرست منبع

直播飘屏和置顶

xw 1 روز پیش
والد
کامیت
4bc9b75fad
42فایلهای تغییر یافته به همراه1377 افزوده شده و 0 حذف شده
  1. 49 0
      fs-admin/src/main/java/com/fs/live/controller/LiveCommentFeatureConfigController.java
  2. 56 0
      fs-admin/src/main/java/com/fs/live/controller/LiveCommentPinAdminController.java
  3. 30 0
      fs-admin/src/main/java/com/fs/live/controller/LiveFloatMsgLogController.java
  4. 5 0
      fs-common/src/main/java/com/fs/common/constant/LiveKeysConstant.java
  5. 37 0
      fs-live-app/src/main/java/com/fs/live/controller/LiveCommentPushController.java
  6. 44 0
      fs-live-app/src/main/java/com/fs/live/task/LiveCommentPinExpireScheduler.java
  7. 4 0
      fs-live-app/src/main/java/com/fs/live/websocket/bean/SendMsgVo.java
  8. 102 0
      fs-live-app/src/main/java/com/fs/live/websocket/service/WebSocketServer.java
  9. 5 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyRoleMapper.java
  10. 3 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyUserRoleMapper.java
  11. 5 0
      fs-service/src/main/java/com/fs/company/service/ICompanyRoleService.java
  12. 4 0
      fs-service/src/main/java/com/fs/company/service/impl/CompanyRoleServiceImpl.java
  13. 6 0
      fs-service/src/main/java/com/fs/his/domain/FsUser.java
  14. 13 0
      fs-service/src/main/java/com/fs/live/constant/LiveCommentPinEndReason.java
  15. 27 0
      fs-service/src/main/java/com/fs/live/domain/LiveCommentFeatureConfig.java
  16. 16 0
      fs-service/src/main/java/com/fs/live/domain/LiveCommentPinActive.java
  17. 26 0
      fs-service/src/main/java/com/fs/live/domain/LiveCommentPinLog.java
  18. 19 0
      fs-service/src/main/java/com/fs/live/domain/LiveFloatMsgLog.java
  19. 13 0
      fs-service/src/main/java/com/fs/live/mapper/LiveCommentFeatureConfigMapper.java
  20. 28 0
      fs-service/src/main/java/com/fs/live/mapper/LiveCommentPinActiveMapper.java
  21. 18 0
      fs-service/src/main/java/com/fs/live/mapper/LiveCommentPinLogMapper.java
  22. 12 0
      fs-service/src/main/java/com/fs/live/mapper/LiveFloatMsgLogMapper.java
  23. 18 0
      fs-service/src/main/java/com/fs/live/service/ILiveCommentFeatureConfigService.java
  24. 12 0
      fs-service/src/main/java/com/fs/live/service/ILiveCommentFloatScreenService.java
  25. 27 0
      fs-service/src/main/java/com/fs/live/service/ILiveCommentPinService.java
  26. 13 0
      fs-service/src/main/java/com/fs/live/service/ILiveCommentWsUserTypeService.java
  27. 12 0
      fs-service/src/main/java/com/fs/live/service/ILiveFloatMsgLogService.java
  28. 73 0
      fs-service/src/main/java/com/fs/live/service/LiveAppWebSocketNotifyService.java
  29. 88 0
      fs-service/src/main/java/com/fs/live/service/impl/LiveCommentFeatureConfigServiceImpl.java
  30. 94 0
      fs-service/src/main/java/com/fs/live/service/impl/LiveCommentFloatScreenServiceImpl.java
  31. 227 0
      fs-service/src/main/java/com/fs/live/service/impl/LiveCommentPinServiceImpl.java
  32. 21 0
      fs-service/src/main/java/com/fs/live/service/impl/LiveCommentWsUserTypeServiceImpl.java
  33. 30 0
      fs-service/src/main/java/com/fs/live/service/impl/LiveFloatMsgLogServiceImpl.java
  34. 22 0
      fs-service/src/main/java/com/fs/live/util/LiveCommentWsMessageBuilder.java
  35. 11 0
      fs-service/src/main/java/com/fs/live/vo/LiveCommentPinExpireEvent.java
  36. 14 0
      fs-service/src/main/java/com/fs/live/vo/LiveCommentPinMonitorVo.java
  37. 5 0
      fs-service/src/main/resources/mapper/company/CompanyRoleMapper.xml
  38. 44 0
      fs-service/src/main/resources/mapper/live/LiveCommentFeatureConfigMapper.xml
  39. 54 0
      fs-service/src/main/resources/mapper/live/LiveCommentPinActiveMapper.xml
  40. 42 0
      fs-service/src/main/resources/mapper/live/LiveCommentPinLogMapper.xml
  41. 31 0
      fs-service/src/main/resources/mapper/live/LiveFloatMsgLogMapper.xml
  42. 17 0
      fs-user-app/src/main/java/com/fs/app/controller/UserController.java

+ 49 - 0
fs-admin/src/main/java/com/fs/live/controller/LiveCommentFeatureConfigController.java

@@ -0,0 +1,49 @@
+package com.fs.live.controller;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.SecurityUtils;
+import com.fs.company.service.ICompanyRoleService;
+import com.fs.live.domain.LiveCommentFeatureConfig;
+import com.fs.live.service.ILiveCommentFeatureConfigService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 直播评论飘屏/置顶 — 全局规则(库表 config_id=1)
+ */
+@RestController
+@RequestMapping("/live/commentFeature")
+public class LiveCommentFeatureConfigController extends BaseController {
+
+    @Autowired
+    private ILiveCommentFeatureConfigService liveCommentFeatureConfigService;
+    @Autowired
+    private ICompanyRoleService companyRoleService;
+
+    @GetMapping("/config")
+    public AjaxResult getConfig() {
+        return AjaxResult.success(liveCommentFeatureConfigService.getEffectiveConfig());
+    }
+
+    /**
+     * 可飘屏/可置顶角色多选:下拉选项(去重 role_name)
+     */
+    @GetMapping("/roles")
+    public AjaxResult listDistinctRoleNames() {
+        List<String> names = companyRoleService.selectDistinctRoleNames();
+        return AjaxResult.success(names);
+    }
+
+    @Log(title = "直播评论功能全局配置", businessType = BusinessType.UPDATE)
+    @PutMapping("/config")
+    public AjaxResult updateConfig(@RequestBody LiveCommentFeatureConfig config) {
+        config.setUpdateBy(SecurityUtils.getUsername());
+        liveCommentFeatureConfigService.updateConfig(config);
+        return AjaxResult.success();
+    }
+}

+ 56 - 0
fs-admin/src/main/java/com/fs/live/controller/LiveCommentPinAdminController.java

@@ -0,0 +1,56 @@
+package com.fs.live.controller;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.utils.SecurityUtils;
+import com.fs.live.domain.LiveCommentPinLog;
+import com.fs.live.service.ILiveCommentPinService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 评论置顶:记录、实时监控、后台强制取消
+ */
+@RestController
+@RequestMapping("/live/commentPin")
+public class LiveCommentPinAdminController extends BaseController {
+
+    @Autowired
+    private ILiveCommentPinService liveCommentPinService;
+
+    /**
+     * 全站当前生效置顶(含剩余时间、评论摘要)
+     */
+    @GetMapping("/monitor/active")
+    public AjaxResult monitorActive() {
+        return AjaxResult.success(liveCommentPinService.listActiveGlobalMonitor());
+    }
+
+    @GetMapping("/log/list")
+    public TableDataInfo logList(LiveCommentPinLog query) {
+        startPage();
+        List<LiveCommentPinLog> list = liveCommentPinService.listPinLogs(query);
+        return getDataTable(list);
+    }
+
+    /**
+     * 某直播间当前置顶列表
+     */
+    @GetMapping("/active/{liveId}")
+    public AjaxResult activeByLive(@PathVariable Long liveId) {
+        return AjaxResult.success(liveCommentPinService.listActiveByLiveId(liveId));
+    }
+
+    @PostMapping("/active/{activeId}/forceCancel")
+    public AjaxResult forceCancel(@PathVariable Long activeId) {
+        R r = liveCommentPinService.forceUnpinByActiveId(activeId, SecurityUtils.getUsername());
+        if (!Integer.valueOf(200).equals(r.get("code"))) {
+            return AjaxResult.error(String.valueOf(r.get("msg")));
+        }
+        return AjaxResult.success();
+    }
+}

+ 30 - 0
fs-admin/src/main/java/com/fs/live/controller/LiveFloatMsgLogController.java

@@ -0,0 +1,30 @@
+package com.fs.live.controller;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.live.domain.LiveFloatMsgLog;
+import com.fs.live.service.ILiveFloatMsgLogService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 飘屏发送记录
+ */
+@RestController
+@RequestMapping("/live/floatMsgLog")
+public class LiveFloatMsgLogController extends BaseController {
+
+    @Autowired
+    private ILiveFloatMsgLogService liveFloatMsgLogService;
+
+    @GetMapping("/list")
+    public TableDataInfo list(LiveFloatMsgLog query) {
+        startPage();
+        List<LiveFloatMsgLog> list = liveFloatMsgLogService.selectList(query);
+        return getDataTable(list);
+    }
+}

+ 5 - 0
fs-common/src/main/java/com/fs/common/constant/LiveKeysConstant.java

@@ -40,5 +40,10 @@ public class LiveKeysConstant {
     //记录用户观看直播间信息 直播间id、用户id、外部联系人id、qwUserId
     public static final String LIVE_USER_WATCH_LOG_CACHE = "live:user:watch:log:%s:%s:%s:%s";
 
+    /** 直播评论飘屏/置顶全局配置缓存(单条) */
+    public static final String LIVE_COMMENT_FEATURE_CONFIG_ROW = "live:comment:feature:config:row";
+    public static final int LIVE_COMMENT_FEATURE_CONFIG_EXPIRE_SEC = 300;
+    /** 飘屏冷却 liveId userId */
+    public static final String LIVE_FLOAT_COOLDOWN = "live:float:cooldown:%s:%s";
 
 }

+ 37 - 0
fs-live-app/src/main/java/com/fs/live/controller/LiveCommentPushController.java

@@ -0,0 +1,37 @@
+package com.fs.live.controller;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.R;
+import com.fs.live.websocket.service.WebSocketServer;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+/**
+ * 总后台通过 HTTP 通知 fs-live-app 做 WebSocket 广播(需配置 liveWebSocketUrl)
+ */
+@Slf4j
+@RestController
+@RequestMapping("/app/live/comment")
+public class LiveCommentPushController extends BaseController {
+
+    @Autowired
+    private WebSocketServer webSocketServer;
+
+    /** 全量广播全局评论配置(cmd=liveCommentConfig) */
+    @PostMapping("/broadcastConfig")
+    public R broadcastConfig() {
+        webSocketServer.broadcastLiveCommentConfigToAll();
+        return R.ok();
+    }
+
+    @PostMapping("/broadcastToLive")
+    public R broadcastToLive(@RequestBody Map<String, Object> body) {
+        Long liveId = Long.valueOf(body.get("liveId").toString());
+        String message = body.get("message").toString();
+        webSocketServer.broadcastMessage(liveId, message);
+        return R.ok();
+    }
+}

+ 44 - 0
fs-live-app/src/main/java/com/fs/live/task/LiveCommentPinExpireScheduler.java

@@ -0,0 +1,44 @@
+package com.fs.live.task;
+
+import com.fs.live.service.ILiveCommentPinService;
+import com.fs.live.util.LiveCommentWsMessageBuilder;
+import com.fs.live.vo.LiveCommentPinExpireEvent;
+import com.fs.live.websocket.service.WebSocketServer;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 置顶到期自动结束并广播 commentUnpinned
+ */
+@Slf4j
+@Component
+public class LiveCommentPinExpireScheduler {
+
+    @Autowired
+    private ILiveCommentPinService liveCommentPinService;
+    @Autowired
+    private WebSocketServer webSocketServer;
+
+    @Scheduled(fixedDelay = 30000)
+    public void expirePins() {
+        try {
+            List<LiveCommentPinExpireEvent> events = liveCommentPinService.expireDuePins();
+            for (LiveCommentPinExpireEvent e : events) {
+                Map<String, Object> payload = new LinkedHashMap<>();
+                payload.put("msgId", e.getMsgId());
+                payload.put("pinLogId", e.getPinLogId());
+                payload.put("reason", "EXPIRED");
+                String ws = LiveCommentWsMessageBuilder.build(e.getLiveId(), "commentUnpinned", payload);
+                webSocketServer.broadcastMessage(e.getLiveId(), ws);
+            }
+        } catch (Exception ex) {
+            log.error("[置顶到期] 处理失败", ex);
+        }
+    }
+}

+ 4 - 0
fs-live-app/src/main/java/com/fs/live/websocket/bean/SendMsgVo.java

@@ -34,5 +34,9 @@ public class SendMsgVo {
     private boolean on = false;
     private Integer status;
     private Integer duration;
+    /** App 端直播间角色编码,与总后台配置中的角色列表对应,如 USER、ASSISTANT、ANCHOR、ADMIN */
+    private String liveRoleCode;
+    /** 置顶/取消置顶目标评论 msg_id */
+    private Long targetMsgId;
 
 }

+ 102 - 0
fs-live-app/src/main/java/com/fs/live/websocket/service/WebSocketServer.java

@@ -332,6 +332,8 @@ public class WebSocketServer {
             startConsumerThread(liveId);
         }
 
+        pushLiveCommentBootstrap(session, liveId, userType);
+
     }
 
     //关闭连接时调用
@@ -425,6 +427,10 @@ public class WebSocketServer {
 
         long liveId = (long) userProperties.get("liveId");
         long userType = (long) userProperties.get("userType");
+        long companyUserId = -1L;
+        if (!Objects.isNull(userProperties.get("companyUserId"))) {
+            companyUserId = (long) userProperties.get("companyUserId");
+        }
         boolean isAdmin = false;
 
         SendMsgVo msg = JSONObject.parseObject(message, SendMsgVo.class);
@@ -656,6 +662,51 @@ public class WebSocketServer {
                 case "coupon":
                     processCoupon(liveId, msg);
                     break;
+                case "floatScreenMsg":
+                    msg.setMsg(productionWordFilter.filter(msg.getMsg()).getFilteredText());
+                    if (StringUtils.isEmpty(msg.getMsg())) {
+                        return;
+                    }
+                    ILiveCommentFloatScreenService floatScreenService = SpringUtils.getBean(ILiveCommentFloatScreenService.class);
+                    Long checkCompanyUserId = (companyUserId > 0)
+                            ? companyUserId
+                            : (msg.getCompanyUserId() != null && msg.getCompanyUserId() > 0 ? msg.getCompanyUserId() : msg.getUserId());
+                    R floatR = floatScreenService.handleFloatScreen(liveId, msg.getUserId(), msg.getNickName(), msg.getAvatar(),
+                            msg.getMsg(), msg.getLiveRoleCode(), checkCompanyUserId);
+                    if (!Integer.valueOf(200).equals(floatR.get("code"))) {
+                        sendMessage(session, JSONObject.toJSONString(floatR));
+                        break;
+                    }
+                    LiveMsg flm = (LiveMsg) floatR.get("liveMsg");
+                    msg.setCmd("floatScreenDisplay");
+                    msg.setOn(true);
+                    msg.setData(JSONObject.toJSONString(flm));
+                    enqueueMessage(liveId, JSONObject.toJSONString(R.ok().put("data", msg)), true);
+                    break;
+                case "pinComment":
+                    if (msg.getTargetMsgId() == null) {
+                        sendMessage(session, JSONObject.toJSONString(R.error("缺少 targetMsgId")));
+                        break;
+                    }
+                    int pinDur = msg.getDuration() != null ? msg.getDuration() : -1;
+                    ILiveCommentPinService pinService = SpringUtils.getBean(ILiveCommentPinService.class);
+                    R pinR = pinService.pinComment(liveId, msg.getTargetMsgId(), msg.getUserId(), msg.getNickName(),
+                            msg.getLiveRoleCode(), (int) userType, pinDur,
+                            (companyUserId > 0 ? companyUserId :
+                                    (msg.getCompanyUserId() != null && msg.getCompanyUserId() > 0 ? msg.getCompanyUserId() : msg.getUserId())));
+                    sendMessage(session, JSONObject.toJSONString(pinR));
+                    break;
+                case "unpinComment":
+                    if (msg.getTargetMsgId() == null) {
+                        sendMessage(session, JSONObject.toJSONString(R.error("缺少 targetMsgId")));
+                        break;
+                    }
+                    ILiveCommentPinService pinService2 = SpringUtils.getBean(ILiveCommentPinService.class);
+                    R unpinR = pinService2.unpinComment(liveId, msg.getTargetMsgId(), msg.getUserId(), msg.getLiveRoleCode(), (int) userType,
+                            (companyUserId > 0 ? companyUserId :
+                                    (msg.getCompanyUserId() != null && msg.getCompanyUserId() > 0 ? msg.getCompanyUserId() : msg.getUserId())));
+                    sendMessage(session, JSONObject.toJSONString(unpinR));
+                    break;
                 case "delAutoTask":
                     if (userType == 1) {
                         delAutoTask(liveId, DateUtils.parseDate(msg.getData(),"yyyy-MM-dd'T'HH:mm:ss.SSSZ").getTime());
@@ -2208,5 +2259,56 @@ public class WebSocketServer {
         }
     }
 
+    private void pushLiveCommentBootstrap(Session session, long liveId, long userType) {
+        try {
+            ILiveCommentFeatureConfigService cfgSvc = SpringUtils.getBean(ILiveCommentFeatureConfigService.class);
+            String cfgJson = cfgSvc.buildConfigPushJson();
+            SendMsgVo cfgVo = SendMsgVo.builder().liveId(liveId).cmd("liveCommentConfig").data(cfgJson).on(true).build();
+            sendMessage(session, JSONObject.toJSONString(R.ok().put("data", cfgVo)));
+            if (userType == 0) {
+                ILiveCommentPinService pinSvc = SpringUtils.getBean(ILiveCommentPinService.class);
+                List<LiveCommentPinActive> pins = pinSvc.listActiveByLiveId(liveId);
+                SendMsgVo pvo = SendMsgVo.builder().liveId(liveId).cmd("commentPinList").data(JSON.toJSONString(pins)).on(true).build();
+                sendMessage(session, JSONObject.toJSONString(R.ok().put("data", pvo)));
+            }
+        } catch (IOException e) {
+            log.warn("推送评论扩展配置 IO 异常 liveId={}", liveId, e);
+        } catch (Exception ex) {
+            log.warn("推送评论扩展配置失败 liveId={}", liveId, ex);
+        }
+    }
+
+    /**
+     * 总后台修改全局规则或样式后由 HTTP 触发:向所有在线连接广播(cmd=liveCommentConfig)
+     */
+    public void broadcastLiveCommentConfigToAll() {
+        try {
+            ILiveCommentFeatureConfigService cfgSvc = SpringUtils.getBean(ILiveCommentFeatureConfigService.class);
+            String cfgJson = cfgSvc.buildConfigPushJson();
+            SendMsgVo vo = SendMsgVo.builder().liveId(0L).cmd("liveCommentConfig").data(cfgJson).on(true).build();
+            String message = JSONObject.toJSONString(R.ok().put("data", vo));
+            broadcastToAllLiveConnections(message);
+        } catch (Exception e) {
+            log.error("全量广播评论配置失败", e);
+        }
+    }
+
+    public void broadcastToAllLiveConnections(String message) {
+        for (ConcurrentHashMap<Long, Session> room : rooms.values()) {
+            for (Session s : room.values()) {
+                if (s != null && s.isOpen()) {
+                    sendWithRetry(s, message, 1);
+                }
+            }
+        }
+        for (CopyOnWriteArrayList<Session> adminRoom : adminRooms.values()) {
+            for (Session s : adminRoom) {
+                if (s != null && s.isOpen()) {
+                    sendWithRetry(s, message, 1);
+                }
+            }
+        }
+    }
+
 }
 

+ 5 - 0
fs-service/src/main/java/com/fs/company/mapper/CompanyRoleMapper.java

@@ -85,4 +85,9 @@ public interface CompanyRoleMapper
      * **/
     CompanyRole selectCompanyRoleByRoleKey(@Param("roleKey") String roleKey);
     Long selectRolesByUserNameAndCompanyId(@Param("roleName") String roleName,@Param("companyId") Long companyId);
+
+    /**
+     * 去重角色名称(直播评论飘屏/置顶配置下拉用)
+     */
+    List<String> selectDistinctRoleNames();
 }

+ 3 - 0
fs-service/src/main/java/com/fs/company/mapper/CompanyUserRoleMapper.java

@@ -14,6 +14,9 @@ import java.util.List;
  */
 public interface CompanyUserRoleMapper
 {
+    @Select("SELECT COUNT(1) FROM company_user_role WHERE user_id = #{userId}")
+    int countByUserId(@Param("userId") Long userId);
+
     @Select("\n" +
             "SELECT \n" +
             "   cur.user_id\n" +

+ 5 - 0
fs-service/src/main/java/com/fs/company/service/ICompanyRoleService.java

@@ -109,4 +109,9 @@ public interface ICompanyRoleService
      * @return
      * **/
     int insertDefaultRole(CompanyRole role);
+
+    /**
+     * 去重角色名称(直播评论飘屏/置顶配置下拉用)
+     */
+    List<String> selectDistinctRoleNames();
 }

+ 4 - 0
fs-service/src/main/java/com/fs/company/service/impl/CompanyRoleServiceImpl.java

@@ -123,6 +123,10 @@ public class CompanyRoleServiceImpl implements ICompanyRoleService
         return permsSet;
     }
 
+    @Override
+    public List<String> selectDistinctRoleNames() {
+        return companyRoleMapper.selectDistinctRoleNames();
+    }
 
     @Override
     public String checkRoleNameUnique(CompanyRole role) {

+ 6 - 0
fs-service/src/main/java/com/fs/his/domain/FsUser.java

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.TableField;
 import com.fasterxml.jackson.annotation.JsonFormat;
 import com.fs.common.annotation.Excel;
 import com.fs.common.core.domain.BaseEntity;
+import io.swagger.annotations.ApiModelProperty;
 import org.apache.commons.lang3.StringUtils;
 import com.vdurmont.emoji.EmojiParser;
 import lombok.Data;
@@ -101,6 +102,11 @@ public class FsUser extends BaseEntity
     private Long companyUserId;
     private String companyUserName;
 
+    /** 与直播 WebSocket 参数 userType 一致:0 观众,1 具备飘屏或置顶权限的企业用户(不落库) */
+    @TableField(exist = false)
+    @ApiModelProperty(value = "直播间 userType:0 观众 1 管理(与 WS 连接参数一致)")
+    private Integer liveUserType;
+
     /** 公司用户ID,逗号拼接*/
     @TableField(exist = false)
     private String companyUserIdMulti;

+ 13 - 0
fs-service/src/main/java/com/fs/live/constant/LiveCommentPinEndReason.java

@@ -0,0 +1,13 @@
+package com.fs.live.constant;
+
+public final class LiveCommentPinEndReason {
+
+    private LiveCommentPinEndReason() {}
+
+    // 置顶到期
+    public static final int EXPIRED = 1;
+    // APP取消
+    public static final int APP_CANCEL = 2;
+    // 管理员强制取消
+    public static final int ADMIN_FORCE = 3;
+}

+ 27 - 0
fs-service/src/main/java/com/fs/live/domain/LiveCommentFeatureConfig.java

@@ -0,0 +1,27 @@
+package com.fs.live.domain;
+
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 直播评论飘屏/置顶全局配置(库表固定 config_id=1)
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class LiveCommentFeatureConfig extends BaseEntity {
+
+    /** 固定为 1 */
+    private Long configId;
+    @Excel(name = "飘屏开关")
+    private Integer floatEnabled;
+    @Excel(name = "飘屏冷却秒")
+    private Integer floatCooldownSec;
+    // 角色
+    private String floatRoleCodes;
+    @Excel(name = "单房间最大置顶")
+    private Integer pinMaxPerRoom;
+    private String pinDurationOptions;
+    private String pinRoleCodes;
+}

+ 16 - 0
fs-service/src/main/java/com/fs/live/domain/LiveCommentPinActive.java

@@ -0,0 +1,16 @@
+package com.fs.live.domain;
+
+import lombok.Data;
+
+import java.util.Date;
+
+@Data
+public class LiveCommentPinActive {
+
+    private Long id;
+    private Long liveId;
+    private Long msgId;
+    private Long pinLogId;
+    private Date expireAt;
+    private Date createTime;
+}

+ 26 - 0
fs-service/src/main/java/com/fs/live/domain/LiveCommentPinLog.java

@@ -0,0 +1,26 @@
+package com.fs.live.domain;
+
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.util.Date;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class LiveCommentPinLog extends BaseEntity {
+
+    private Long logId;
+    private Long liveId;
+    private Long msgId;
+    private Long operatorUserId;
+    private String operatorNickName;
+    private String operatorRoleCode;
+    private Integer durationMinutes;
+    private Date startTime;
+    /** 置顶结束时间 */
+    private Date pinEndTime;
+    /** 1到期 2App取消 3后台强制 */
+    private Integer endReason;
+}

+ 19 - 0
fs-service/src/main/java/com/fs/live/domain/LiveFloatMsgLog.java

@@ -0,0 +1,19 @@
+package com.fs.live.domain;
+
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class LiveFloatMsgLog extends BaseEntity {
+
+    private Long logId;
+    private Long liveId;
+    private Long userId;
+    private String nickName;
+    private Long msgId;
+    private String liveRoleCode;
+    private String msgContent;
+}

+ 13 - 0
fs-service/src/main/java/com/fs/live/mapper/LiveCommentFeatureConfigMapper.java

@@ -0,0 +1,13 @@
+package com.fs.live.mapper;
+
+import com.fs.live.domain.LiveCommentFeatureConfig;
+
+/**
+ * 直播评论飘屏/置顶全局配置
+ */
+public interface LiveCommentFeatureConfigMapper {
+
+    LiveCommentFeatureConfig selectByConfigId(Long configId);
+
+    int updateLiveCommentFeatureConfig(LiveCommentFeatureConfig config);
+}

+ 28 - 0
fs-service/src/main/java/com/fs/live/mapper/LiveCommentPinActiveMapper.java

@@ -0,0 +1,28 @@
+package com.fs.live.mapper;
+
+import com.fs.live.domain.LiveCommentPinActive;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.Date;
+import java.util.List;
+
+public interface LiveCommentPinActiveMapper {
+
+    int insertLiveCommentPinActive(LiveCommentPinActive row);
+
+    int deleteById(Long id);
+
+    int deleteByLiveIdAndMsgId(@Param("liveId") Long liveId, @Param("msgId") Long msgId);
+
+    int countByLiveId(Long liveId);
+
+    List<LiveCommentPinActive> selectByLiveId(Long liveId);
+
+    LiveCommentPinActive selectByLiveIdAndMsgId(@Param("liveId") Long liveId, @Param("msgId") Long msgId);
+
+    LiveCommentPinActive selectById(Long id);
+
+    List<LiveCommentPinActive> selectExpired(@Param("now") Date now);
+
+    List<LiveCommentPinActive> selectAllActive();
+}

+ 18 - 0
fs-service/src/main/java/com/fs/live/mapper/LiveCommentPinLogMapper.java

@@ -0,0 +1,18 @@
+package com.fs.live.mapper;
+
+import com.fs.live.domain.LiveCommentPinLog;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.Date;
+import java.util.List;
+
+public interface LiveCommentPinLogMapper {
+
+    int insertLiveCommentPinLog(LiveCommentPinLog row);
+
+    int updatePinLogEnd(@Param("logId") Long logId,
+                        @Param("endTime") Date endTime,
+                        @Param("endReason") Integer endReason);
+
+    List<LiveCommentPinLog> selectLiveCommentPinLogList(LiveCommentPinLog query);
+}

+ 12 - 0
fs-service/src/main/java/com/fs/live/mapper/LiveFloatMsgLogMapper.java

@@ -0,0 +1,12 @@
+package com.fs.live.mapper;
+
+import com.fs.live.domain.LiveFloatMsgLog;
+
+import java.util.List;
+
+public interface LiveFloatMsgLogMapper {
+
+    int insertLiveFloatMsgLog(LiveFloatMsgLog row);
+
+    List<LiveFloatMsgLog> selectLiveFloatMsgLogList(LiveFloatMsgLog query);
+}

+ 18 - 0
fs-service/src/main/java/com/fs/live/service/ILiveCommentFeatureConfigService.java

@@ -0,0 +1,18 @@
+package com.fs.live.service;
+
+import com.fs.live.domain.LiveCommentFeatureConfig;
+
+public interface ILiveCommentFeatureConfigService {
+
+    /** 无库表记录时返回内存默认规则 */
+    LiveCommentFeatureConfig getEffectiveConfig();
+
+    void evictConfigCache();
+
+    /** 推送给 App 的 data 字段 JSON(全局 config) */
+    String buildConfigPushJson();
+
+    int updateConfig(LiveCommentFeatureConfig config);
+
+    void notifyLiveAppConfigChanged();
+}

+ 12 - 0
fs-service/src/main/java/com/fs/live/service/ILiveCommentFloatScreenService.java

@@ -0,0 +1,12 @@
+package com.fs.live.service;
+
+import com.fs.common.core.domain.R;
+
+public interface ILiveCommentFloatScreenService {
+
+    /**
+     * 校验规则并落库(live_msg + 飘屏日志),返回 liveMsg 供 WS 广播
+     */
+    R handleFloatScreen(Long liveId, Long userId, String nickName, String avatar, String msg,
+                        String liveRoleCode, Long companyUserId);
+}

+ 27 - 0
fs-service/src/main/java/com/fs/live/service/ILiveCommentPinService.java

@@ -0,0 +1,27 @@
+package com.fs.live.service;
+
+import com.fs.common.core.domain.R;
+import com.fs.live.domain.LiveCommentPinActive;
+import com.fs.live.domain.LiveCommentPinLog;
+import com.fs.live.vo.LiveCommentPinExpireEvent;
+import com.fs.live.vo.LiveCommentPinMonitorVo;
+
+import java.util.List;
+
+public interface ILiveCommentPinService {
+
+    R pinComment(Long liveId, Long msgId, Long operatorUserId, String operatorNickName,
+                 String liveRoleCode, int userType, int durationMinutes, Long companyUserId);
+
+    R unpinComment(Long liveId, Long msgId, Long operatorUserId, String liveRoleCode, int userType, Long companyUserId);
+
+    R forceUnpinByActiveId(Long activeId, String updateBy);
+
+    List<LiveCommentPinActive> listActiveByLiveId(Long liveId);
+
+    List<LiveCommentPinLog> listPinLogs(LiveCommentPinLog query);
+
+    List<LiveCommentPinMonitorVo> listActiveGlobalMonitor();
+
+    List<LiveCommentPinExpireEvent> expireDuePins();
+}

+ 13 - 0
fs-service/src/main/java/com/fs/live/service/ILiveCommentWsUserTypeService.java

@@ -0,0 +1,13 @@
+package com.fs.live.service;
+
+/**
+ * 直播评论场景下与 WebSocket 连接参数 userType 对齐:0 观众,1 具备飘屏或置顶任一权限的企业用户。
+ */
+public interface ILiveCommentWsUserTypeService {
+
+    /**
+     * @param companyUserId 企业用户(销售)ID,与 WS 上 companyUserId、置顶/飘屏鉴权一致
+     * @return 0 或 1
+     */
+    int resolveLiveCommentUserType(Long companyUserId);
+}

+ 12 - 0
fs-service/src/main/java/com/fs/live/service/ILiveFloatMsgLogService.java

@@ -0,0 +1,12 @@
+package com.fs.live.service;
+
+import com.fs.live.domain.LiveFloatMsgLog;
+
+import java.util.List;
+
+public interface ILiveFloatMsgLogService {
+
+    void insertLog(LiveFloatMsgLog log);
+
+    List<LiveFloatMsgLog> selectList(LiveFloatMsgLog query);
+}

+ 73 - 0
fs-service/src/main/java/com/fs/live/service/LiveAppWebSocketNotifyService.java

@@ -0,0 +1,73 @@
+package com.fs.live.service;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.utils.StringUtils;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.env.Environment;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 通过 HTTP 调用 fs-live-app,驱动 WebSocket 广播(与行为推送同源配置 liveWebSocketUrl)
+ */
+@Slf4j
+@Service
+public class LiveAppWebSocketNotifyService {
+
+    @Autowired(required = false)
+    private Environment environment;
+
+    /** 通知 fs-live-app 向所有在线连接广播全局评论配置(cmd=liveCommentConfig) */
+    public void broadcastCommentConfigRefresh() {
+        postJson("/app/live/comment/broadcastConfig", Collections.emptyMap());
+    }
+
+    public void broadcastToLive(Long liveId, String fullMessageJson) {
+        if (liveId == null || StringUtils.isEmpty(fullMessageJson)) {
+            return;
+        }
+        Map<String, Object> body = new HashMap<>(2);
+        body.put("liveId", liveId);
+        body.put("message", fullMessageJson);
+        postJson("/app/live/comment/broadcastToLive", body);
+    }
+
+    private void postJson(String path, Object body) {
+        if (environment == null) {
+            return;
+        }
+        String base = environment.getProperty("liveWebSocketUrl");
+        if (StringUtils.isEmpty(base)) {
+            log.debug("[live-app通知] 未配置 liveWebSocketUrl,跳过 {}", path);
+            return;
+        }
+        String url = trimSlash(base) + path;
+        try {
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            String json = JSONObject.toJSONString(body == null ? Collections.emptyMap() : body);
+            HttpEntity<String> request = new HttpEntity<>(json, headers);
+            RestTemplate restTemplate = new RestTemplate();
+            ResponseEntity<String> response = restTemplate.postForEntity(url, request, String.class);
+            log.info("[live-app通知] {} -> {}", path, response.getStatusCode());
+        } catch (Exception e) {
+            log.error("[live-app通知] 调用失败 url={}", url, e);
+        }
+    }
+
+    private static String trimSlash(String base) {
+        if (base.endsWith("/")) {
+            return base.substring(0, base.length() - 1);
+        }
+        return base;
+    }
+}

+ 88 - 0
fs-service/src/main/java/com/fs/live/service/impl/LiveCommentFeatureConfigServiceImpl.java

@@ -0,0 +1,88 @@
+package com.fs.live.service.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.DateUtils;
+import com.fs.live.domain.LiveCommentFeatureConfig;
+import com.fs.live.mapper.LiveCommentFeatureConfigMapper;
+import com.fs.live.service.ILiveCommentFeatureConfigService;
+import com.fs.live.service.LiveAppWebSocketNotifyService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+@Service
+public class LiveCommentFeatureConfigServiceImpl implements ILiveCommentFeatureConfigService {
+
+    private static final Long CONFIG_ID = 1L;
+
+    @Autowired
+    private LiveCommentFeatureConfigMapper configMapper;
+    @Autowired
+    private RedisCache redisCache;
+    @Autowired
+    private LiveAppWebSocketNotifyService liveAppWebSocketNotifyService;
+
+    @Override
+    public LiveCommentFeatureConfig getEffectiveConfig() {
+        LiveCommentFeatureConfig cached = redisCache.getCacheObject(LiveKeysConstant.LIVE_COMMENT_FEATURE_CONFIG_ROW);
+        if (cached != null) {
+            return cached;
+        }
+        LiveCommentFeatureConfig row = configMapper.selectByConfigId(CONFIG_ID);
+        if (row == null) {
+            row = defaultConfig();
+        }
+        redisCache.setCacheObject(LiveKeysConstant.LIVE_COMMENT_FEATURE_CONFIG_ROW, row,
+                LiveKeysConstant.LIVE_COMMENT_FEATURE_CONFIG_EXPIRE_SEC, TimeUnit.SECONDS);
+        return row;
+    }
+
+    @Override
+    public void evictConfigCache() {
+        redisCache.deleteObject(LiveKeysConstant.LIVE_COMMENT_FEATURE_CONFIG_ROW);
+    }
+
+    @Override
+    public String buildConfigPushJson() {
+        LiveCommentFeatureConfig cfg = getEffectiveConfig();
+        Map<String, Object> wrap = new HashMap<>(2);
+        wrap.put("config", cfg);
+        return JSON.toJSONString(wrap);
+    }
+
+    @Override
+    public int updateConfig(LiveCommentFeatureConfig config) {
+        config.setConfigId(CONFIG_ID);
+        if (config.getUpdateTime() == null) {
+            config.setUpdateTime(DateUtils.getNowDate());
+        }
+        int rows = configMapper.updateLiveCommentFeatureConfig(config);
+        evictConfigCache();
+        if (rows > 0) {
+            notifyLiveAppConfigChanged();
+        }
+        return rows;
+    }
+
+    @Override
+    public void notifyLiveAppConfigChanged() {
+        liveAppWebSocketNotifyService.broadcastCommentConfigRefresh();
+    }
+
+    private static LiveCommentFeatureConfig defaultConfig() {
+        LiveCommentFeatureConfig c = new LiveCommentFeatureConfig();
+        c.setConfigId(CONFIG_ID);
+        c.setFloatEnabled(0);
+        c.setFloatCooldownSec(60);
+        c.setFloatRoleCodes("USER,ASSISTANT,ANCHOR,ADMIN");
+        c.setPinMaxPerRoom(3);
+        c.setPinDurationOptions("5,10,30,-1");
+        c.setPinRoleCodes("ADMIN,ASSISTANT,ANCHOR");
+        return c;
+    }
+}

+ 94 - 0
fs-service/src/main/java/com/fs/live/service/impl/LiveCommentFloatScreenServiceImpl.java

@@ -0,0 +1,94 @@
+package com.fs.live.service.impl;
+
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.live.domain.LiveCommentFeatureConfig;
+import com.fs.live.domain.LiveFloatMsgLog;
+import com.fs.live.domain.LiveMsg;
+import com.fs.live.domain.LiveWatchUser;
+import com.fs.live.mapper.LiveMsgMapper;
+import com.fs.live.service.ILiveCommentFeatureConfigService;
+import com.fs.live.service.ILiveCommentFloatScreenService;
+import com.fs.live.service.ILiveFloatMsgLogService;
+import com.fs.live.service.ILiveWatchUserService;
+import com.fs.company.mapper.CompanyUserRoleMapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+@Service
+public class LiveCommentFloatScreenServiceImpl implements ILiveCommentFloatScreenService {
+
+    @Autowired
+    private ILiveCommentFeatureConfigService featureConfigService;
+    @Autowired
+    private ILiveFloatMsgLogService floatMsgLogService;
+    @Autowired
+    private LiveMsgMapper liveMsgMapper;
+    @Autowired
+    private ILiveWatchUserService liveWatchUserService;
+    @Autowired
+    private RedisCache redisCache;
+    @Autowired
+    private CompanyUserRoleMapper companyUserRoleMapper;
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public R handleFloatScreen(Long liveId, Long userId, String nickName, String avatar, String msg,
+                               String liveRoleCode, Long companyUserId) {
+        LiveCommentFeatureConfig cfg = featureConfigService.getEffectiveConfig();
+        if (cfg.getFloatEnabled() == null || cfg.getFloatEnabled() != 1) {
+            return R.error("飘屏功能未开放");
+        }
+        Long checkUserId = (companyUserId != null && companyUserId > 0) ? companyUserId : userId;
+        if (!hasAnyRoleRelation(checkUserId)) {
+            return R.error("无飘屏权限");
+        }
+        String cooldownKey = String.format(LiveKeysConstant.LIVE_FLOAT_COOLDOWN, liveId, userId);
+        if (redisCache.getCacheObject(cooldownKey) != null) {
+            return R.error("飘屏冷却中,请稍后再试");
+        }
+        List<LiveWatchUser> wu = liveWatchUserService.getByLiveIdAndUserId(liveId, userId);
+        if (!wu.isEmpty() && wu.get(0).getMsgStatus() != null && wu.get(0).getMsgStatus() == 1) {
+            return R.error("你已被禁言");
+        }
+        Map<String, Integer> flagMap = liveWatchUserService.getLiveFlagWithCache(liveId);
+        LiveMsg liveMsg = new LiveMsg();
+        liveMsg.setLiveId(liveId);
+        liveMsg.setUserId(userId);
+        liveMsg.setNickName(nickName);
+        liveMsg.setAvatar(avatar);
+        liveMsg.setMsg(msg);
+        liveMsg.setCreateTime(new Date());
+        liveMsg.setLiveFlag(flagMap.get("liveFlag"));
+        liveMsg.setReplayFlag(flagMap.get("replayFlag"));
+        liveMsgMapper.insertLiveMsg(liveMsg);
+
+        LiveFloatMsgLog log = new LiveFloatMsgLog();
+        log.setLiveId(liveId);
+        log.setUserId(userId);
+        log.setNickName(nickName);
+        log.setMsgId(liveMsg.getMsgId());
+        log.setLiveRoleCode(liveRoleCode);
+        log.setMsgContent(msg);
+        floatMsgLogService.insertLog(log);
+
+        int sec = cfg.getFloatCooldownSec() != null && cfg.getFloatCooldownSec() > 0 ? cfg.getFloatCooldownSec() : 60;
+        redisCache.setCacheObject(cooldownKey, 1, sec, TimeUnit.SECONDS);
+
+        return R.ok().put("liveMsg", liveMsg);
+    }
+
+    private boolean hasAnyRoleRelation(Long companyUserId) {
+        if (companyUserId == null || companyUserId <= 0) {
+            return false;
+        }
+        return companyUserRoleMapper.countByUserId(companyUserId) > 0;
+    }
+}

+ 227 - 0
fs-service/src/main/java/com/fs/live/service/impl/LiveCommentPinServiceImpl.java

@@ -0,0 +1,227 @@
+package com.fs.live.service.impl;
+
+import com.fs.common.core.domain.R;
+import com.fs.common.utils.DateUtils;
+import com.fs.live.constant.LiveCommentPinEndReason;
+import com.fs.live.domain.LiveCommentFeatureConfig;
+import com.fs.live.domain.LiveCommentPinActive;
+import com.fs.live.domain.LiveCommentPinLog;
+import com.fs.live.domain.LiveMsg;
+import com.fs.live.mapper.LiveCommentPinActiveMapper;
+import com.fs.live.mapper.LiveCommentPinLogMapper;
+import com.fs.live.mapper.LiveMsgMapper;
+import com.fs.live.service.ILiveCommentFeatureConfigService;
+import com.fs.live.service.ILiveCommentPinService;
+import com.fs.live.service.LiveAppWebSocketNotifyService;
+import com.fs.live.util.LiveCommentWsMessageBuilder;
+import com.fs.live.vo.LiveCommentPinExpireEvent;
+import com.fs.live.vo.LiveCommentPinMonitorVo;
+import com.fs.company.mapper.CompanyUserRoleMapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+
+@Service
+public class LiveCommentPinServiceImpl implements ILiveCommentPinService {
+
+    @Autowired
+    private LiveCommentPinActiveMapper pinActiveMapper;
+    @Autowired
+    private LiveCommentPinLogMapper pinLogMapper;
+    @Autowired
+    private LiveMsgMapper liveMsgMapper;
+    @Autowired
+    private ILiveCommentFeatureConfigService featureConfigService;
+    @Autowired
+    private LiveAppWebSocketNotifyService liveAppWebSocketNotifyService;
+    @Autowired
+    private CompanyUserRoleMapper companyUserRoleMapper;
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public R pinComment(Long liveId, Long msgId, Long operatorUserId, String operatorNickName,
+                        String liveRoleCode, int userType, int durationMinutes, Long companyUserId) {
+        LiveCommentFeatureConfig cfg = featureConfigService.getEffectiveConfig();
+        Long checkUserId = (companyUserId != null && companyUserId > 0) ? companyUserId : operatorUserId;
+        if (!hasAnyRoleRelation(checkUserId)) {
+            return R.error("无置顶权限");
+        }
+        if (!durationAllowed(durationMinutes, cfg.getPinDurationOptions())) {
+            return R.error("不支持的置顶时长");
+        }
+        LiveMsg msg = liveMsgMapper.selectLiveMsgByMsgId(msgId);
+        if (msg == null || !Objects.equals(msg.getLiveId(), liveId)) {
+            return R.error("评论不存在或不属于该直播间");
+        }
+        if (pinActiveMapper.selectByLiveIdAndMsgId(liveId, msgId) != null) {
+            return R.error("该评论已在置顶中");
+        }
+        int cnt = pinActiveMapper.countByLiveId(liveId);
+        if (cnt >= cfg.getPinMaxPerRoom()) {
+            return R.error("置顶数量已达上限");
+        }
+
+        Date now = DateUtils.getNowDate();
+        LiveCommentPinLog log = new LiveCommentPinLog();
+        log.setLiveId(liveId);
+        log.setMsgId(msgId);
+        log.setOperatorUserId(operatorUserId);
+        log.setOperatorNickName(operatorNickName);
+        log.setOperatorRoleCode(liveRoleCode);
+        log.setDurationMinutes(durationMinutes);
+        log.setStartTime(now);
+        log.setCreateTime(now);
+        pinLogMapper.insertLiveCommentPinLog(log);
+
+        LiveCommentPinActive active = new LiveCommentPinActive();
+        active.setLiveId(liveId);
+        active.setMsgId(msgId);
+        active.setPinLogId(log.getLogId());
+        active.setCreateTime(now);
+        if (durationMinutes < 0) {
+            active.setExpireAt(null);
+        } else {
+            Calendar cal = Calendar.getInstance();
+            cal.setTime(now);
+            cal.add(Calendar.MINUTE, durationMinutes);
+            active.setExpireAt(cal.getTime());
+        }
+        pinActiveMapper.insertLiveCommentPinActive(active);
+
+        Map<String, Object> payload = buildPinPayload(msg, log.getLogId(), active.getExpireAt(), active.getId());
+        String ws = LiveCommentWsMessageBuilder.build(liveId, "commentPinned", payload);
+        liveAppWebSocketNotifyService.broadcastToLive(liveId, ws);
+        return R.ok().put("pinLogId", log.getLogId()).put("activeId", active.getId());
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public R unpinComment(Long liveId, Long msgId, Long operatorUserId, String liveRoleCode, int userType, Long companyUserId) {
+        LiveCommentFeatureConfig cfg = featureConfigService.getEffectiveConfig();
+        Long checkUserId = (companyUserId != null && companyUserId > 0) ? companyUserId : operatorUserId;
+        if (!hasAnyRoleRelation(checkUserId)) {
+            return R.error("无取消置顶权限");
+        }
+        LiveCommentPinActive active = pinActiveMapper.selectByLiveIdAndMsgId(liveId, msgId);
+        if (active == null) {
+            return R.error("当前未置顶该评论");
+        }
+        Date now = DateUtils.getNowDate();
+        pinLogMapper.updatePinLogEnd(active.getPinLogId(), now, LiveCommentPinEndReason.APP_CANCEL);
+        pinActiveMapper.deleteByLiveIdAndMsgId(liveId, msgId);
+
+        Map<String, Object> payload = new LinkedHashMap<>();
+        payload.put("msgId", msgId);
+        payload.put("pinLogId", active.getPinLogId());
+        payload.put("reason", "APP_CANCEL");
+        String ws = LiveCommentWsMessageBuilder.build(liveId, "commentUnpinned", payload);
+        liveAppWebSocketNotifyService.broadcastToLive(liveId, ws);
+        return R.ok();
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public R forceUnpinByActiveId(Long activeId, String updateBy) {
+        LiveCommentPinActive active = pinActiveMapper.selectById(activeId);
+        if (active == null) {
+            return R.error("置顶记录不存在");
+        }
+        Date now = DateUtils.getNowDate();
+        pinLogMapper.updatePinLogEnd(active.getPinLogId(), now, LiveCommentPinEndReason.ADMIN_FORCE);
+        pinActiveMapper.deleteById(activeId);
+
+        Map<String, Object> payload = new LinkedHashMap<>();
+        payload.put("msgId", active.getMsgId());
+        payload.put("pinLogId", active.getPinLogId());
+        payload.put("reason", "ADMIN_FORCE");
+        payload.put("updateBy", updateBy);
+        String ws = LiveCommentWsMessageBuilder.build(active.getLiveId(), "commentUnpinned", payload);
+        liveAppWebSocketNotifyService.broadcastToLive(active.getLiveId(), ws);
+        return R.ok();
+    }
+
+    @Override
+    public List<LiveCommentPinActive> listActiveByLiveId(Long liveId) {
+        return pinActiveMapper.selectByLiveId(liveId);
+    }
+
+    @Override
+    public List<LiveCommentPinLog> listPinLogs(LiveCommentPinLog query) {
+        return pinLogMapper.selectLiveCommentPinLogList(query);
+    }
+
+    @Override
+    public List<LiveCommentPinMonitorVo> listActiveGlobalMonitor() {
+        List<LiveCommentPinActive> actives = pinActiveMapper.selectAllActive();
+        Date now = new Date();
+        List<LiveCommentPinMonitorVo> out = new ArrayList<>();
+        for (LiveCommentPinActive a : actives) {
+            LiveCommentPinMonitorVo v = new LiveCommentPinMonitorVo();
+            v.setActive(a);
+            LiveMsg m = liveMsgMapper.selectLiveMsgByMsgId(a.getMsgId());
+            if (m != null) {
+                v.setMsgContent(m.getMsg());
+                v.setMsgNickName(m.getNickName());
+            }
+            if (a.getExpireAt() != null) {
+                long sec = (a.getExpireAt().getTime() - now.getTime()) / 1000L;
+                v.setRemainingSeconds(Math.max(sec, 0L));
+            }
+            out.add(v);
+        }
+        return out;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public List<LiveCommentPinExpireEvent> expireDuePins() {
+        Date now = DateUtils.getNowDate();
+        List<LiveCommentPinActive> due = pinActiveMapper.selectExpired(now);
+        List<LiveCommentPinExpireEvent> events = new ArrayList<>();
+        for (LiveCommentPinActive a : due) {
+            pinLogMapper.updatePinLogEnd(a.getPinLogId(), now, LiveCommentPinEndReason.EXPIRED);
+            pinActiveMapper.deleteById(a.getId());
+            LiveCommentPinExpireEvent e = new LiveCommentPinExpireEvent();
+            e.setLiveId(a.getLiveId());
+            e.setMsgId(a.getMsgId());
+            e.setPinLogId(a.getPinLogId());
+            events.add(e);
+        }
+        return events;
+    }
+
+    private static Map<String, Object> buildPinPayload(LiveMsg msg, Long pinLogId, Date expireAt, Long activeId) {
+        Map<String, Object> payload = new LinkedHashMap<>();
+        payload.put("msgId", msg.getMsgId());
+        payload.put("pinLogId", pinLogId);
+        payload.put("activeId", activeId);
+        payload.put("liveMsg", msg);
+        payload.put("expireAt", expireAt == null ? null : expireAt.getTime());
+        return payload;
+    }
+
+    private boolean hasAnyRoleRelation(Long companyUserId) {
+        if (companyUserId == null || companyUserId <= 0) {
+            return false;
+        }
+        return companyUserRoleMapper.countByUserId(companyUserId) > 0;
+    }
+
+    private static boolean durationAllowed(int minutes, String optionsCsv) {
+        if (optionsCsv == null || optionsCsv.isEmpty()) {
+            return false;
+        }
+        for (String p : optionsCsv.split(",")) {
+            try {
+                if (Integer.parseInt(p.trim()) == minutes) {
+                    return true;
+                }
+            } catch (NumberFormatException ignored) {
+                // skip
+            }
+        }
+        return false;
+    }
+}

+ 21 - 0
fs-service/src/main/java/com/fs/live/service/impl/LiveCommentWsUserTypeServiceImpl.java

@@ -0,0 +1,21 @@
+package com.fs.live.service.impl;
+
+import com.fs.company.mapper.CompanyUserRoleMapper;
+import com.fs.live.service.ILiveCommentWsUserTypeService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+@Service
+public class LiveCommentWsUserTypeServiceImpl implements ILiveCommentWsUserTypeService {
+
+    @Autowired
+    private CompanyUserRoleMapper companyUserRoleMapper;
+
+    @Override
+    public int resolveLiveCommentUserType(Long companyUserId) {
+        if (companyUserId == null || companyUserId <= 0) {
+            return 0;
+        }
+        return companyUserRoleMapper.countByUserId(companyUserId) > 0 ? 1 : 0;
+    }
+}

+ 30 - 0
fs-service/src/main/java/com/fs/live/service/impl/LiveFloatMsgLogServiceImpl.java

@@ -0,0 +1,30 @@
+package com.fs.live.service.impl;
+
+import com.fs.common.utils.DateUtils;
+import com.fs.live.domain.LiveFloatMsgLog;
+import com.fs.live.mapper.LiveFloatMsgLogMapper;
+import com.fs.live.service.ILiveFloatMsgLogService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+public class LiveFloatMsgLogServiceImpl implements ILiveFloatMsgLogService {
+
+    @Autowired
+    private LiveFloatMsgLogMapper liveFloatMsgLogMapper;
+
+    @Override
+    public void insertLog(LiveFloatMsgLog log) {
+        if (log.getCreateTime() == null) {
+            log.setCreateTime(DateUtils.getNowDate());
+        }
+        liveFloatMsgLogMapper.insertLiveFloatMsgLog(log);
+    }
+
+    @Override
+    public List<LiveFloatMsgLog> selectList(LiveFloatMsgLog query) {
+        return liveFloatMsgLogMapper.selectLiveFloatMsgLogList(query);
+    }
+}

+ 22 - 0
fs-service/src/main/java/com/fs/live/util/LiveCommentWsMessageBuilder.java

@@ -0,0 +1,22 @@
+package com.fs.live.util;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.core.domain.R;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/** 构造与 fs-live-app SendMsgVo 结构一致的 WebSocket 外层 JSON */
+public final class LiveCommentWsMessageBuilder {
+
+    private LiveCommentWsMessageBuilder() {}
+
+    public static String build(Long liveId, String cmd, Object dataObject) {
+        Map<String, Object> vo = new LinkedHashMap<>();
+        vo.put("liveId", liveId);
+        vo.put("cmd", cmd);
+        vo.put("data", dataObject instanceof String ? dataObject : JSONObject.toJSONString(dataObject));
+        vo.put("on", true);
+        return JSONObject.toJSONString(R.ok().put("data", vo));
+    }
+}

+ 11 - 0
fs-service/src/main/java/com/fs/live/vo/LiveCommentPinExpireEvent.java

@@ -0,0 +1,11 @@
+package com.fs.live.vo;
+
+import lombok.Data;
+
+@Data
+public class LiveCommentPinExpireEvent {
+
+    private Long liveId;
+    private Long msgId;
+    private Long pinLogId;
+}

+ 14 - 0
fs-service/src/main/java/com/fs/live/vo/LiveCommentPinMonitorVo.java

@@ -0,0 +1,14 @@
+package com.fs.live.vo;
+
+import com.fs.live.domain.LiveCommentPinActive;
+import lombok.Data;
+
+@Data
+public class LiveCommentPinMonitorVo {
+
+    private LiveCommentPinActive active;
+    private String msgContent;
+    private String msgNickName;
+    /** 剩余秒数,永久为 null */
+    private Long remainingSeconds;
+}

+ 5 - 0
fs-service/src/main/resources/mapper/company/CompanyRoleMapper.xml

@@ -186,4 +186,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         select role_id from company_role where  del_flag = '0' and status = '0' and  role_name= #{roleName}  and company_id = #{companyId}
     </select>
 
+    <select id="selectDistinctRoleNames" resultType="java.lang.String">
+        SELECT distinct role_name
+        FROM company_role
+    </select>
+
 </mapper>

+ 44 - 0
fs-service/src/main/resources/mapper/live/LiveCommentFeatureConfigMapper.xml

@@ -0,0 +1,44 @@
+<?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="com.fs.live.mapper.LiveCommentFeatureConfigMapper">
+
+    <resultMap type="LiveCommentFeatureConfig" id="LiveCommentFeatureConfigResult">
+        <result property="configId" column="config_id"/>
+        <result property="floatEnabled" column="float_enabled"/>
+        <result property="floatCooldownSec" column="float_cooldown_sec"/>
+        <result property="floatRoleCodes" column="float_role_codes"/>
+        <result property="pinMaxPerRoom" column="pin_max_per_room"/>
+        <result property="pinDurationOptions" column="pin_duration_options"/>
+        <result property="pinRoleCodes" column="pin_role_codes"/>
+        <result property="remark" column="remark"/>
+        <result property="updateBy" column="update_by"/>
+        <result property="updateTime" column="update_time"/>
+    </resultMap>
+
+    <sql id="selectVo">
+        select config_id, float_enabled, float_cooldown_sec, float_role_codes, pin_max_per_room,
+               pin_duration_options, pin_role_codes, remark, update_by, update_time
+        from live_comment_feature_config
+    </sql>
+
+    <select id="selectByConfigId" resultMap="LiveCommentFeatureConfigResult">
+        <include refid="selectVo"/>
+        where config_id = #{configId}
+    </select>
+
+    <update id="updateLiveCommentFeatureConfig" parameterType="LiveCommentFeatureConfig">
+        update live_comment_feature_config
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="floatEnabled != null">float_enabled = #{floatEnabled},</if>
+            <if test="floatCooldownSec != null">float_cooldown_sec = #{floatCooldownSec},</if>
+            <if test="floatRoleCodes != null">float_role_codes = #{floatRoleCodes},</if>
+            <if test="pinMaxPerRoom != null">pin_max_per_room = #{pinMaxPerRoom},</if>
+            <if test="pinDurationOptions != null">pin_duration_options = #{pinDurationOptions},</if>
+            <if test="pinRoleCodes != null">pin_role_codes = #{pinRoleCodes},</if>
+            <if test="remark != null">remark = #{remark},</if>
+            <if test="updateBy != null">update_by = #{updateBy},</if>
+            <if test="updateTime != null">update_time = #{updateTime},</if>
+        </trim>
+        where config_id = #{configId}
+    </update>
+</mapper>

+ 54 - 0
fs-service/src/main/resources/mapper/live/LiveCommentPinActiveMapper.xml

@@ -0,0 +1,54 @@
+<?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="com.fs.live.mapper.LiveCommentPinActiveMapper">
+
+    <resultMap type="LiveCommentPinActive" id="LiveCommentPinActiveResult">
+        <result property="id" column="id"/>
+        <result property="liveId" column="live_id"/>
+        <result property="msgId" column="msg_id"/>
+        <result property="pinLogId" column="pin_log_id"/>
+        <result property="expireAt" column="expire_at"/>
+        <result property="createTime" column="create_time"/>
+    </resultMap>
+
+    <insert id="insertLiveCommentPinActive" parameterType="LiveCommentPinActive" useGeneratedKeys="true" keyProperty="id">
+        insert into live_comment_pin_active (live_id, msg_id, pin_log_id, expire_at, create_time)
+        values (#{liveId}, #{msgId}, #{pinLogId}, #{expireAt}, #{createTime})
+    </insert>
+
+    <delete id="deleteById">delete from live_comment_pin_active where id = #{id}</delete>
+
+    <delete id="deleteByLiveIdAndMsgId">
+        delete from live_comment_pin_active where live_id = #{liveId} and msg_id = #{msgId}
+    </delete>
+
+    <select id="countByLiveId" resultType="int">
+        select count(1) from live_comment_pin_active where live_id = #{liveId}
+    </select>
+
+    <select id="selectByLiveId" resultMap="LiveCommentPinActiveResult">
+        select id, live_id, msg_id, pin_log_id, expire_at, create_time
+        from live_comment_pin_active where live_id = #{liveId} order by create_time asc
+    </select>
+
+    <select id="selectByLiveIdAndMsgId" resultMap="LiveCommentPinActiveResult">
+        select id, live_id, msg_id, pin_log_id, expire_at, create_time
+        from live_comment_pin_active where live_id = #{liveId} and msg_id = #{msgId} limit 1
+    </select>
+
+    <select id="selectById" resultMap="LiveCommentPinActiveResult">
+        select id, live_id, msg_id, pin_log_id, expire_at, create_time
+        from live_comment_pin_active where id = #{id}
+    </select>
+
+    <select id="selectExpired" resultMap="LiveCommentPinActiveResult">
+        select id, live_id, msg_id, pin_log_id, expire_at, create_time
+        from live_comment_pin_active
+        where expire_at is not null and expire_at &lt;= #{now}
+    </select>
+
+    <select id="selectAllActive" resultMap="LiveCommentPinActiveResult">
+        select id, live_id, msg_id, pin_log_id, expire_at, create_time
+        from live_comment_pin_active order by live_id, create_time asc
+    </select>
+</mapper>

+ 42 - 0
fs-service/src/main/resources/mapper/live/LiveCommentPinLogMapper.xml

@@ -0,0 +1,42 @@
+<?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="com.fs.live.mapper.LiveCommentPinLogMapper">
+
+    <resultMap type="LiveCommentPinLog" id="LiveCommentPinLogResult">
+        <result property="logId" column="log_id"/>
+        <result property="liveId" column="live_id"/>
+        <result property="msgId" column="msg_id"/>
+        <result property="operatorUserId" column="operator_user_id"/>
+        <result property="operatorNickName" column="operator_nick_name"/>
+        <result property="operatorRoleCode" column="operator_role_code"/>
+        <result property="durationMinutes" column="duration_minutes"/>
+        <result property="startTime" column="start_time"/>
+        <result property="pinEndTime" column="end_time"/>
+        <result property="endReason" column="end_reason"/>
+        <result property="createTime" column="create_time"/>
+    </resultMap>
+
+    <insert id="insertLiveCommentPinLog" parameterType="LiveCommentPinLog" useGeneratedKeys="true" keyProperty="logId">
+        insert into live_comment_pin_log
+        (live_id, msg_id, operator_user_id, operator_nick_name, operator_role_code, duration_minutes, start_time, create_time)
+        values (#{liveId}, #{msgId}, #{operatorUserId}, #{operatorNickName}, #{operatorRoleCode}, #{durationMinutes}, #{startTime}, #{createTime})
+    </insert>
+
+    <update id="updatePinLogEnd">
+        update live_comment_pin_log
+        set end_time = #{endTime}, end_reason = #{endReason}
+        where log_id = #{logId}
+    </update>
+
+    <select id="selectLiveCommentPinLogList" resultMap="LiveCommentPinLogResult">
+        select log_id, live_id, msg_id, operator_user_id, operator_nick_name, operator_role_code,
+               duration_minutes, start_time, end_time, end_reason, create_time
+        from live_comment_pin_log
+        <where>
+            <if test="liveId != null">and live_id = #{liveId}</if>
+            <if test="msgId != null">and msg_id = #{msgId}</if>
+            <if test="operatorUserId != null">and operator_user_id = #{operatorUserId}</if>
+        </where>
+        order by create_time desc
+    </select>
+</mapper>

+ 31 - 0
fs-service/src/main/resources/mapper/live/LiveFloatMsgLogMapper.xml

@@ -0,0 +1,31 @@
+<?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="com.fs.live.mapper.LiveFloatMsgLogMapper">
+
+    <resultMap type="LiveFloatMsgLog" id="LiveFloatMsgLogResult">
+        <result property="logId" column="log_id"/>
+        <result property="liveId" column="live_id"/>
+        <result property="userId" column="user_id"/>
+        <result property="nickName" column="nick_name"/>
+        <result property="msgId" column="msg_id"/>
+        <result property="liveRoleCode" column="live_role_code"/>
+        <result property="msgContent" column="msg_content"/>
+        <result property="createTime" column="create_time"/>
+    </resultMap>
+
+    <insert id="insertLiveFloatMsgLog" parameterType="LiveFloatMsgLog" useGeneratedKeys="true" keyProperty="logId">
+        insert into live_float_msg_log
+        (live_id, user_id, nick_name, msg_id, live_role_code, msg_content, create_time)
+        values (#{liveId}, #{userId}, #{nickName}, #{msgId}, #{liveRoleCode}, #{msgContent}, #{createTime})
+    </insert>
+
+    <select id="selectLiveFloatMsgLogList" resultMap="LiveFloatMsgLogResult">
+        select log_id, live_id, user_id, nick_name, msg_id, live_role_code, msg_content, create_time
+        from live_float_msg_log
+        <where>
+            <if test="liveId != null">and live_id = #{liveId}</if>
+            <if test="userId != null">and user_id = #{userId}</if>
+        </where>
+        order by create_time desc
+    </select>
+</mapper>

+ 17 - 0
fs-user-app/src/main/java/com/fs/app/controller/UserController.java

@@ -22,6 +22,7 @@ import com.fs.his.service.IFsDoctorService;
 import com.fs.his.service.IFsPackageService;
 import com.fs.his.service.IFsUserCouponService;
 import com.fs.his.service.IFsUserService;
+import com.fs.live.service.ILiveCommentWsUserTypeService;
 import com.fs.his.utils.PhoneUtil;
 import com.fs.his.vo.FsUserCouponCountUVO;
 import com.fs.his.vo.FsUserCouponListUVO;
@@ -78,6 +79,8 @@ public class UserController extends  AppBaseController {
     @Autowired
     private IFsUserCourseVideoService courseVideoService;
 
+    @Autowired
+    private ILiveCommentWsUserTypeService liveCommentWsUserTypeService;
 
     @Autowired
     private IFsUserService fsUserService;
@@ -120,6 +123,7 @@ public class UserController extends  AppBaseController {
             if (user.getPhone()!=null&&user.getPhone().length()>11&&!user.getPhone().matches("\\d+")){
                 user.setPhone(decryptPhoneMk(user.getPhone()));
             }
+            applyLiveCommentUserType(user);
             Map<String,Object> map=new HashMap<>();
             map.put("user",user);
             return R.ok(map);
@@ -138,6 +142,7 @@ public class UserController extends  AppBaseController {
             if (user.getPhone()!=null&&user.getPhone().length()>11&&!user.getPhone().matches("\\d+")){
                 user.setPhone(decryptPhoneMk(user.getPhone()));
             }
+            applyLiveCommentUserType(user);
             Map<String,Object> map=new HashMap<>();
             map.put("user",user);
             return R.ok(map);
@@ -365,4 +370,16 @@ public class UserController extends  AppBaseController {
     public R removeUser(){
         return userService.removeUser(Long.parseLong(getUserId()));
     }
+
+    /** 直播评论飘屏/置顶:与 WS userType 对齐 */
+    private void applyLiveCommentUserType(FsUser user) {
+        if (user == null) {
+            return;
+        }
+        Long companyUserId = user.getCompanyUserId();
+        int liveUt = liveCommentWsUserTypeService.resolveLiveCommentUserType(
+                companyUserId != null && companyUserId > 0 ? companyUserId : null);
+        user.setLiveUserType(liveUt);
+        user.setUserType(Integer.toString(liveUt));
+    }
 }