فهرست منبع

Merge remote-tracking branch 'origin/bjcz_his_scrm' into 北京存在

吴树波 1 هفته پیش
والد
کامیت
7d5ec9f273
47فایلهای تغییر یافته به همراه1894 افزوده شده و 128 حذف شده
  1. 55 0
      fs-admin/src/main/java/com/fs/live/controller/LiveGoodsController.java
  2. 77 0
      fs-admin/src/main/java/com/fs/live/controller/LiveOrderTipConfigController.java
  3. 10 1
      fs-admin/src/main/java/com/fs/qw/controller/QwUserController.java
  4. 13 0
      fs-common/src/main/java/com/fs/common/constant/LiveKeysConstant.java
  5. 9 1
      fs-company/src/main/java/com/fs/company/controller/qw/QwUserController.java
  6. 15 0
      fs-live-app/src/main/java/com/fs/live/controller/LiveCommentPushController.java
  7. 92 0
      fs-live-app/src/main/java/com/fs/live/task/LiveOrderTipScheduler.java
  8. 40 0
      fs-live-app/src/main/java/com/fs/live/websocket/service/WebSocketServer.java
  9. 19 3
      fs-qw-mq/src/main/java/com/fs/framework/config/DataSourceConfig.java
  10. 4 0
      fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java
  11. 121 15
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseFinishTempServiceImpl.java
  12. 45 3
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreOrderScrmServiceImpl.java
  13. 2 0
      fs-service/src/main/java/com/fs/im/service/OpenIMService.java
  14. 50 0
      fs-service/src/main/java/com/fs/im/service/impl/OpenIMServiceImpl.java
  15. 67 0
      fs-service/src/main/java/com/fs/live/domain/LiveOrderTipConfig.java
  16. 15 0
      fs-service/src/main/java/com/fs/live/mapper/LiveOrderTipConfigMapper.java
  17. 18 0
      fs-service/src/main/java/com/fs/live/service/ILiveOrderTipConfigService.java
  18. 40 0
      fs-service/src/main/java/com/fs/live/service/ILiveOrderTipService.java
  19. 14 0
      fs-service/src/main/java/com/fs/live/service/LiveAppWebSocketNotifyService.java
  20. 44 4
      fs-service/src/main/java/com/fs/live/service/impl/LiveGoodsServiceImpl.java
  21. 9 0
      fs-service/src/main/java/com/fs/live/service/impl/LiveOrderServiceImpl.java
  22. 83 0
      fs-service/src/main/java/com/fs/live/service/impl/LiveOrderTipConfigServiceImpl.java
  23. 481 0
      fs-service/src/main/java/com/fs/live/service/impl/LiveOrderTipServiceImpl.java
  24. 5 0
      fs-service/src/main/java/com/fs/live/vo/LiveGoodsVo.java
  25. 45 0
      fs-service/src/main/java/com/fs/live/vo/LiveOrderTipVo.java
  26. 7 0
      fs-service/src/main/java/com/fs/qw/domain/QwCompany.java
  27. 4 5
      fs-service/src/main/java/com/fs/qw/mapper/QwUserMapper.java
  28. 14 0
      fs-service/src/main/java/com/fs/qw/param/ImFsUserRemarkTagsParam.java
  29. 6 0
      fs-service/src/main/java/com/fs/qw/param/QwUserListParam.java
  30. 5 0
      fs-service/src/main/java/com/fs/qw/service/IQwExternalContactService.java
  31. 6 0
      fs-service/src/main/java/com/fs/qw/service/IQwTagService.java
  32. 55 47
      fs-service/src/main/java/com/fs/qw/service/impl/QwExternalContactServiceImpl.java
  33. 14 0
      fs-service/src/main/java/com/fs/qw/service/impl/QwTagServiceImpl.java
  34. 18 0
      fs-service/src/main/java/com/fs/qw/vo/ImFsUserRemarkTagsVO.java
  35. 4 0
      fs-service/src/main/java/com/fs/qw/vo/QwOptionsVO.java
  36. 26 24
      fs-service/src/main/java/com/fs/qwApi/service/impl/QwApiServiceImpl.java
  37. 4 1
      fs-service/src/main/java/com/fs/sop/mapper/QwSopMapper.java
  38. 10 0
      fs-service/src/main/java/com/fs/sop/service/IQwSopService.java
  39. 24 5
      fs-service/src/main/java/com/fs/sop/service/impl/QwSopLogsServiceImpl.java
  40. 18 0
      fs-service/src/main/java/com/fs/sop/service/impl/QwSopServiceImpl.java
  41. 5 0
      fs-service/src/main/java/com/fs/sop/vo/SopUserLogsVo.java
  42. 1 1
      fs-service/src/main/resources/mapper/live/LiveGoodsMapper.xml
  43. 74 0
      fs-service/src/main/resources/mapper/live/LiveOrderTipConfigMapper.xml
  44. 9 2
      fs-service/src/main/resources/mapper/qw/QwCompanyMapper.xml
  45. 2 1
      fs-service/src/main/resources/mapper/sop/SopUserLogsMapper.xml
  46. 24 15
      fs-user-app/src/main/java/com/fs/app/controller/AppLoginController.java
  47. 191 0
      fs-user-app/src/main/java/com/fs/app/controller/ImQwExternalContactController.java

+ 55 - 0
fs-admin/src/main/java/com/fs/live/controller/LiveGoodsController.java

@@ -16,6 +16,8 @@ import com.fs.live.domain.LiveGoods;
 import com.fs.live.service.ILiveGoodsService;
 import com.fs.live.vo.LiveGoodsListVo;
 import com.fs.live.vo.LiveGoodsVo;
+import com.fs.system.domain.SysConfig;
+import com.fs.system.service.ISysConfigService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
@@ -39,6 +41,9 @@ public class LiveGoodsController extends BaseController
     @Autowired
     private IFsStoreProductScrmService fsStoreProductService;
 
+    @Autowired
+    private ISysConfigService configService;
+
 
     /**
      * 查询直播商品列表
@@ -178,4 +183,54 @@ public class LiveGoodsController extends BaseController
     public R handleIsShowChange(@RequestBody LiveGoodsListVo listVo) {
         return liveGoodsService.handleIsShowChange(listVo);
     }
+
+    /**
+     * 查询直播低库存阈值
+     */
+    @GetMapping("/stockHintThreshold")
+    public AjaxResult getStockHintThreshold() {
+        final String configKey = "live.lowStockThreshold";
+        final int defaultThreshold = 10;
+        String configValue = configService.selectConfigByKey(configKey);
+        int threshold = defaultThreshold;
+        try {
+            if (configValue != null && !configValue.trim().isEmpty()) {
+                threshold = Integer.parseInt(configValue.trim());
+            }
+        } catch (NumberFormatException ignore) {
+            threshold = defaultThreshold;
+        }
+        AjaxResult result = AjaxResult.success();
+        result.put("configKey", configKey);
+        result.put("threshold", threshold);
+        return result;
+    }
+
+    /**
+     * 更新直播低库存阈值
+     */
+    @Log(title = "直播低库存阈值", businessType = BusinessType.UPDATE)
+    @PostMapping("/stockHintThreshold")
+    public AjaxResult updateStockHintThreshold(@RequestParam Integer threshold) {
+        final String configKey = "live.lowStockThreshold";
+        if (threshold == null || threshold < 1 || threshold > 9999) {
+            return AjaxResult.error("阈值范围需在1~9999");
+        }
+        SysConfig config = configService.selectConfigByConfigKey(configKey);
+        if (config == null) {
+            config = new SysConfig();
+            config.setConfigName("直播低库存阈值");
+            config.setConfigKey(configKey);
+            config.setConfigType("N");
+            config.setRemark("直播商品库存提示阈值,stock<=threshold时显示仅剩X件");
+            config.setCreateBy(SecurityUtils.getUsername());
+            config.setConfigValue(String.valueOf(threshold));
+            configService.insertConfig(config);
+        } else {
+            config.setConfigValue(String.valueOf(threshold));
+            config.setUpdateBy(SecurityUtils.getUsername());
+            configService.updateConfig(config);
+        }
+        return AjaxResult.success();
+    }
 }

+ 77 - 0
fs-admin/src/main/java/com/fs/live/controller/LiveOrderTipConfigController.java

@@ -0,0 +1,77 @@
+package com.fs.live.controller;
+
+import com.alibaba.fastjson.JSONObject;
+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.live.domain.LiveOrderTipConfig;
+import com.fs.live.service.ILiveOrderTipConfigService;
+import com.fs.live.service.ILiveOrderTipService;
+import com.fs.live.vo.LiveOrderTipVo;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 直播下单动效提示 —— 总后台全局配置
+ *
+ * <p>前端路径:<code>/live/orderTip</code></p>
+ * <ul>
+ *     <li>GET  /live/orderTip/config          - 获取当前配置</li>
+ *     <li>PUT  /live/orderTip/config          - 保存/更新配置</li>
+ *     <li>POST /live/orderTip/preview/{liveId}- 预览:对指定直播间生成一条假订单动效(用于总后台"测试效果"按钮)</li>
+ * </ul>
+ */
+@RestController
+@RequestMapping("/live/orderTip")
+public class LiveOrderTipConfigController extends BaseController {
+
+    @Autowired
+    private ILiveOrderTipConfigService tipConfigService;
+
+    @Autowired
+    private ILiveOrderTipService tipService;
+
+    /** 获取有效配置 */
+    @GetMapping("/config")
+    public AjaxResult getConfig() {
+        return AjaxResult.success(tipConfigService.getEffectiveConfig());
+    }
+
+    /** 保存/更新配置 */
+    @Log(title = "直播下单动效提示配置", businessType = BusinessType.UPDATE)
+    @PutMapping("/config")
+    public AjaxResult updateConfig(@RequestBody LiveOrderTipConfig config) {
+        if (config.getRateMinPerMinute() != null && config.getRateMaxPerMinute() != null
+                && config.getRateMinPerMinute() > config.getRateMaxPerMinute()) {
+            return AjaxResult.error("每分钟最少条数不能大于最多条数");
+        }
+        if (config.getRateMaxPerMinute() != null && config.getRateMaxPerMinute() > 30) {
+            return AjaxResult.error("每分钟最多条数不建议超过 30(避免刷屏)");
+        }
+        config.setUpdateBy(SecurityUtils.getUsername());
+        tipConfigService.updateConfig(config);
+        return AjaxResult.success();
+    }
+
+    /**
+     * 预览效果:对指定直播间立即生成并广播一条假订单动效,
+     * 方便后台运营在配置页做"测试效果"
+     */
+    @Log(title = "直播下单动效提示预览", businessType = BusinessType.OTHER)
+    @PostMapping("/preview/{liveId}")
+    public AjaxResult preview(@PathVariable("liveId") Long liveId) {
+        LiveOrderTipVo vo = tipService.publishFakeOrderTip(liveId);
+        if (vo == null) {
+            return AjaxResult.error("预览失败:配置未开启 / 已限流 / 无可用商品,请检查配置或直播间状态");
+        }
+        return AjaxResult.success(JSONObject.parseObject(JSONObject.toJSONString(vo)));
+    }
+}

+ 10 - 1
fs-admin/src/main/java/com/fs/qw/controller/QwUserController.java

@@ -14,6 +14,7 @@ import com.fs.common.enums.BusinessType;
 import com.fs.common.exception.ServiceException;
 import com.fs.common.exception.user.UserPasswordNotMatchException;
 import com.fs.common.utils.MessageUtils;
+import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.company.domain.CompanyUser;
 import com.fs.company.mapper.CompanyUserMapper;
@@ -23,6 +24,7 @@ import com.fs.fastGpt.domain.FastGptRole;
 import com.fs.fastGpt.mapper.FastGptRoleMapper;
 import com.fs.framework.manager.AsyncManager;
 import com.fs.framework.manager.factory.AsyncFactory;
+import com.fs.qw.domain.QwCompany;
 import com.fs.qw.domain.QwExternalContact;
 import com.fs.qw.domain.QwUser;
 import com.fs.qw.mapper.QwCompanyMapper;
@@ -488,7 +490,6 @@ public class QwUserController extends BaseController {
     @GetMapping("/list")
     public TableDataInfo list(QwUserListParam qwUser)
     {
-        startPage();
         qwUser.setCompanyId(qwUser.getCompanyId());
         if (ObjectUtil.isNotEmpty(qwUser.getIsRemark())&&qwUser.getIsRemark().equals("1")){
             qwUser.setCompanyUserId(getLoginUser().getUser().getUserId());
@@ -497,6 +498,14 @@ public class QwUserController extends BaseController {
             qwUser.setCorpId(null);
         }
 
+        if (StringUtils.isNotEmpty(qwUser.getCorpId())) {
+            QwCompany qwCompany = qwCompanyMapper.selectQwCompanyByCorpId(qwUser.getCorpId());
+            if (qwCompany != null) {
+                qwUser.setAllowOfficial(qwCompany.getAllowOfficial());
+            }
+        }
+
+        startPage();
         List<QwUserVO> list = qwUserService.selectQwUserListVO(qwUser);
         return getDataTable(list);
     }

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

@@ -46,4 +46,17 @@ public class LiveKeysConstant {
     /** 飘屏冷却 liveId userId */
     public static final String LIVE_FLOAT_COOLDOWN = "live:float:cooldown:%s:%s";
 
+    // ========== 直播下单动效提示 ==========
+    /** 订单动效提示全局配置缓存(单条) */
+    public static final String LIVE_ORDER_TIP_CONFIG_ROW = "live:order:tip:config:row";
+    public static final int LIVE_ORDER_TIP_CONFIG_EXPIRE_SEC = 300;
+    /** 订单动效滑动窗口(ZSet),记录最近 N 秒内已推送的时间戳,用于频率控制 %s=liveId */
+    public static final String LIVE_ORDER_TIP_WINDOW = "live:order:tip:win:%s";
+    /** 假订单填充节流:上次生成假订单时间 %s=liveId */
+    public static final String LIVE_ORDER_TIP_FAKE_LAST = "live:order:tip:fake:last:%s";
+    /** 假订单 scheduler 分布式锁(多节点防重复),%s=bucket */
+    public static final String LIVE_ORDER_TIP_FAKE_LOCK = "live:order:tip:fake:lock:%s";
+    /** 真实订单去重(防止同一订单多次推送),%s=orderId */
+    public static final String LIVE_ORDER_TIP_REAL_DEDUP = "live:order:tip:real:dedup:%s";
+
 }

+ 9 - 1
fs-company/src/main/java/com/fs/company/controller/qw/QwUserController.java

@@ -26,6 +26,7 @@ import com.fs.framework.manager.AsyncManager;
 import com.fs.framework.manager.factory.AsyncFactory;
 import com.fs.framework.security.LoginUser;
 import com.fs.framework.service.TokenService;
+import com.fs.qw.domain.QwCompany;
 import com.fs.qw.domain.QwExternalContact;
 import com.fs.qw.domain.QwUser;
 import com.fs.qw.mapper.QwCompanyMapper;
@@ -484,7 +485,6 @@ public class QwUserController extends BaseController
     @GetMapping("/list")
     public TableDataInfo list(QwUserListParam qwUser)
     {
-        startPage();
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         qwUser.setCompanyId(loginUser.getCompany().getCompanyId());
         if (ObjectUtil.isNotEmpty(qwUser.getIsRemark())&&qwUser.getIsRemark().equals("1")){
@@ -494,6 +494,14 @@ public class QwUserController extends BaseController
             qwUser.setCorpId(null);
         }
 
+        if (!StringUtil.strIsNullOrEmpty(qwUser.getCorpId())) {
+            QwCompany qwCompany = qwCompanyMapper.selectQwCompanyByCorpId(qwUser.getCorpId());
+            if (qwCompany != null) {
+                qwUser.setAllowOfficial(qwCompany.getAllowOfficial());
+            }
+        }
+
+        startPage();
         List<QwUserVO> list = qwUserService.selectQwUserListVO(qwUser);
         return getDataTable(list);
     }

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

@@ -34,4 +34,19 @@ public class LiveCommentPushController extends BaseController {
         webSocketServer.broadcastMessage(liveId, message);
         return R.ok();
     }
+
+    /**
+     * 订单动效提示广播(走现有优先级消息队列,具备背压 / 丢弃保护)。
+     * 高峰时(1万+在线)避免阻塞主线程并享受消费者线程池限流。
+     */
+    @PostMapping("/broadcastOrderTip")
+    public R broadcastOrderTip(@RequestBody Map<String, Object> body) {
+        if (body == null || body.get("liveId") == null || body.get("message") == null) {
+            return R.error("参数错误");
+        }
+        Long liveId = Long.valueOf(body.get("liveId").toString());
+        String message = body.get("message").toString();
+        boolean ok = webSocketServer.enqueueOrderTipMessage(liveId, message);
+        return R.ok().put("accepted", ok);
+    }
 }

+ 92 - 0
fs-live-app/src/main/java/com/fs/live/task/LiveOrderTipScheduler.java

@@ -0,0 +1,92 @@
+package com.fs.live.task;
+
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.live.domain.LiveOrderTipConfig;
+import com.fs.live.service.ILiveOrderTipConfigService;
+import com.fs.live.service.ILiveOrderTipService;
+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.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 直播下单动效提示 —— 假数据填充定时任务
+ *
+ * <p>策略(高并发友好):</p>
+ * <ul>
+ *   <li>只对"有在线用户"的直播间才生成假订单(节省资源)</li>
+ *   <li>多节点部署下,通过 Redis 分布式锁避免重复调度,确保同一时间窗口只有一个节点工作</li>
+ *   <li>频率控制由 {@link ILiveOrderTipService#publishFakeOrderTip(Long)} 内部基于 Redis 滑动窗口实现,
+ *       真实订单与假订单共享配额,保证总频率不超过 rateMaxPerMinute</li>
+ *   <li>每轮仅补齐"rateMinPerMinute"到"rateMaxPerMinute"之间的差额,防止刷屏</li>
+ * </ul>
+ *
+ * <p>任务节拍设为 15 秒一轮,配合 window=60 秒的滑动窗口,单直播间每分钟约有 4 次补单机会。</p>
+ */
+@Slf4j
+@Component
+public class LiveOrderTipScheduler {
+
+    @Autowired
+    private ILiveOrderTipService tipService;
+
+    @Autowired
+    private ILiveOrderTipConfigService tipConfigService;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    @Scheduled(fixedDelay = 15000, initialDelay = 20000)
+    public void generateFakeOrderTips() {
+        LiveOrderTipConfig cfg;
+        try {
+            cfg = tipConfigService.getEffectiveConfig();
+        } catch (Exception e) {
+            log.warn("[订单动效-定时] 读取配置异常: {}", e.getMessage());
+            return;
+        }
+        if (cfg == null
+                || cfg.getEnabled() == null || cfg.getEnabled() != 1
+                || cfg.getFakeEnabled() == null || cfg.getFakeEnabled() != 1) {
+            return;
+        }
+
+        // 获取当前节点"有在线用户"的直播间;在集群场景下每个节点只持有自己的 WebSocket 连接,
+        // 但分布式锁按 liveId 加锁,保证多节点对同一直播间不重复推送。
+        Map<Long, Integer> active = WebSocketServer.getActiveRoomsSnapshot();
+        if (active == null || active.isEmpty()) {
+            return;
+        }
+
+        int minPerMinute = cfg.getRateMinPerMinute() == null ? 1 : cfg.getRateMinPerMinute();
+        int maxPerMinute = cfg.getRateMaxPerMinute() == null ? 3 : cfg.getRateMaxPerMinute();
+        if (minPerMinute <= 0 || maxPerMinute <= 0) {
+            return;
+        }
+
+        for (Map.Entry<Long, Integer> entry : active.entrySet()) {
+            Long liveId = entry.getKey();
+            // 集群分布式锁:同一直播间、同一 15 秒轮次只在一个节点执行
+            String lockKey = String.format(LiveKeysConstant.LIVE_ORDER_TIP_FAKE_LOCK, liveId);
+            boolean locked = redisCache.setIfAbsent(lockKey, "1", 12, TimeUnit.SECONDS);
+            if (!locked) {
+                continue;
+            }
+            try {
+                // 补到"最小速率"即可,避免一直顶到 max 造成视觉疲劳
+                // publishFakeOrderTip 内部会再校验窗口剩余配额,保证不超过 max
+                int burst = Math.min(1, minPerMinute); // 单次只补一条,由定时任务节拍自然分散
+                for (int i = 0; i < burst; i++) {
+                    tipService.publishFakeOrderTip(liveId);
+                }
+            } catch (Exception e) {
+                log.warn("[订单动效-定时] 直播间 {} 假订单生成异常: {}", liveId, e.getMessage());
+            }
+        }
+    }
+}

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

@@ -817,6 +817,21 @@ public class WebSocketServer {
         return rooms.computeIfAbsent(liveId, k -> new ConcurrentHashMap<>());
     }
 
+    /**
+     * 获取当前所有"有在线用户"的直播间ID集合及其在线人数快照。
+     * 供订单动效、在线统计等外部模块按房间状态调度任务使用。
+     */
+    public static java.util.Map<Long, Integer> getActiveRoomsSnapshot() {
+        java.util.Map<Long, Integer> snapshot = new java.util.HashMap<>(rooms.size());
+        for (Map.Entry<Long, ConcurrentHashMap<Long, Session>> entry : rooms.entrySet()) {
+            ConcurrentHashMap<Long, Session> room = entry.getValue();
+            if (room != null && !room.isEmpty()) {
+                snapshot.put(entry.getKey(), room.size());
+            }
+        }
+        return snapshot;
+    }
+
     /**
      * 获取管理端房间
      * @param liveId  直播间ID
@@ -914,6 +929,31 @@ public class WebSocketServer {
         }
     }
 
+    /**
+     * 向指定直播间推送订单动效提示(走现有优先级消息队列,作为普通优先级消息)。
+     *
+     * <p>走队列的好处:</p>
+     * <ul>
+     *   <li>与管理员消息共享优先级机制 —— 管理员消息仍可插队</li>
+     *   <li>触发队列容量与大小保护 —— 高峰期可自动丢弃</li>
+     *   <li>由消费者线程异步广播,外部调用方不阻塞</li>
+     * </ul>
+     *
+     * @param liveId  直播间ID
+     * @param message 完整消息 JSON(R.ok().put("data", SendMsgVo) 的序列化结果)
+     */
+    public boolean enqueueOrderTipMessage(Long liveId, String message) {
+        if (liveId == null || message == null) {
+            return false;
+        }
+        // 仅在直播间有在线用户时才入队,避免无效堆积
+        ConcurrentHashMap<Long, Session> room = rooms.get(liveId);
+        if (room == null || room.isEmpty()) {
+            return false;
+        }
+        return enqueueMessage(liveId, message, false);
+    }
+
     /**
      * 广播消息
      * @param liveId   直播间ID

+ 19 - 3
fs-qw-mq/src/main/java/com/fs/framework/config/DataSourceConfig.java

@@ -40,15 +40,31 @@ public class DataSourceConfig {
         return new DruidDataSource();
     }
 
+    @Bean
+    @ConfigurationProperties(prefix = "spring.datasource.sop.druid.master")
+    public DataSource sopDataSource() {
+        return new DruidDataSource();
+    }
+
+    @Bean
+    @ConfigurationProperties(prefix = "spring.datasource.sop.druid.read")
+    public DataSource sopReadDataSource() {
+        return new DruidDataSource();
+    }
+
     @Bean
     @Primary
     public DynamicDataSource dataSource(@Qualifier("clickhouseDataSource") DataSource clickhouseDataSource,
                                         @Qualifier("masterDataSource") DataSource masterDataSource,
-                                        @Qualifier("slaveDataSource") DataSource slaveDataSource) {
+                                        @Qualifier("slaveDataSource") DataSource slaveDataSource,
+                                        @Qualifier("sopDataSource") DataSource sopDataSource,
+                                        @Qualifier("sopReadDataSource") DataSource sopReadDataSource) {
         Map<Object, Object> targetDataSources = new HashMap<>();
-        targetDataSources.put(DataSourceType.MASTER, masterDataSource);
+        targetDataSources.put(DataSourceType.MASTER.name(), masterDataSource);
         targetDataSources.put(DataSourceType.SLAVE.name(), slaveDataSource);
-        targetDataSources.put(DataSourceType.CLICKHOUSE.name(), clickhouseDataSource); // Ensure matching key
+        targetDataSources.put(DataSourceType.CLICKHOUSE.name(), clickhouseDataSource);
+        targetDataSources.put(DataSourceType.SOP.name(), sopDataSource);
+        targetDataSources.put(DataSourceType.SopREAD.name(), sopReadDataSource);
         return new DynamicDataSource(masterDataSource, targetDataSources);
     }
 

+ 4 - 0
fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java

@@ -792,6 +792,10 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         Long courseId = content.getCourseId();
         Long videoId = content.getVideoId();
         Long liveId = content.getLiveId();
+        if (logVo != null && StringUtils.isEmpty(logVo.getQwUserIds())) {
+            log.warn("SOP {} 未配置使用员工(qw_user_ids),跳过本条待发送记录生成", logVo.getSopId());
+            return;
+        }
         Integer isOfficial = content.getIsOfficial() != null ? Integer.valueOf(content.getIsOfficial()) : 0;
 
 

+ 121 - 15
fs-service/src/main/java/com/fs/course/service/impl/FsCourseFinishTempServiceImpl.java

@@ -12,6 +12,8 @@ import com.fs.course.vo.FsCourseFinishTempListVO;
 import com.fs.course.vo.FsCourseFinishTempVO;
 import com.fs.fastGpt.domain.FastGptChatReplaceWords;
 import com.fs.fastGpt.mapper.FastGptChatReplaceWordsMapper;
+import com.fs.im.dto.OpenImResponseDTO;
+import com.fs.im.service.OpenIMService;
 import com.fs.qw.domain.QwCourseFinishRemarkRty;
 import com.fs.qw.domain.QwExternalContact;
 import com.fs.qw.domain.QwUser;
@@ -25,6 +27,9 @@ import com.fs.qw.vo.QwUserVO;
 import com.fs.qwApi.domain.QwExternalContactRemarkResult;
 import com.fs.qwApi.param.QwExternalContactRemarkParam;
 import com.fs.qwApi.service.QwApiService;
+import com.fs.sop.mapper.SopUserLogsMapper;
+import com.fs.sop.params.SopUserLogsParam;
+import com.fs.qw.param.SopUserLogsVO;
 import com.fs.voice.utils.StringUtil;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -73,6 +78,11 @@ public class FsCourseFinishTempServiceImpl implements IFsCourseFinishTempService
 
     @Autowired
     private IQwCourseFinishRemarkRtyService finishRemarkRtyService;
+    @Autowired
+    private OpenIMService openIMService;
+
+    @Autowired
+    private SopUserLogsMapper sopUserLogsMapper;
 
     /**
      * 查询完课模板
@@ -238,6 +248,55 @@ public class FsCourseFinishTempServiceImpl implements IFsCourseFinishTempService
         }
 
         int isSendMsg = qwUserByRedisForId.getIsSendMsg();
+        log.info("完课备注-员工配置信息: qwUserId={}, isSendMsg={}, 客户ID={}",
+                watchLog.getQwUserId(), isSendMsg, qwExternalContactId);
+
+        // isSendMsg = 6 时,计算营期天数
+        Integer campDays = null;
+        if (isSendMsg == 6) {
+            log.info("【isSendMsg=6】开始计算营期天数,客户ID: {}", qwExternalContactId);
+            try {
+                if (watchLog == null || watchLog.getSopId() == null || watchLog.getQwExternalContactId() == null) {
+                    log.error("计算营期天数参数不完整,sopId={} externalId={}", watchLog == null ? null : watchLog.getSopId(), qwExternalContactId);
+                    return;
+                }
+
+                SopUserLogsParam param = new SopUserLogsParam();
+                param.setSopId(watchLog.getSopId());
+                param.setExternalId(qwExternalContactId);
+                param.setStatus(1);
+                if (qwUserByRedisForId != null) {
+                    param.setQwUserId(qwUserByRedisForId.getQwUserId());
+                    param.setCorpId(qwUserByRedisForId.getCorpId());
+                }
+
+                List<SopUserLogsVO> list = sopUserLogsMapper.selectSopUserLogsListByParam(param);
+                if (list == null || list.isEmpty()) {
+                    log.error("未找到完课对应的SOP营期countDays,sopId={} externalId={}",
+                            watchLog.getSopId(), qwExternalContactId);
+                    return;
+                }
+
+                SopUserLogsVO first = list.get(0);
+                campDays = first.getCountDays();
+                if (campDays == null || campDays <= 0) {
+                    log.error("查询到SOP营期记录但countDays无效,sopId={} externalId={} countDays={}",
+                            watchLog.getSopId(), qwExternalContactId, campDays);
+                    return;
+                }
+
+                log.info("读取SOP营期countDays成功,客户ID: {}, countDays: {}, startTime: {}",
+                        qwExternalContactId, campDays, first.getStartTime());
+            } catch (Exception e) {
+                log.error("计算营期天数异常,客户ID: {}", qwExternalContactId, e);
+                return;
+            }
+            if (campDays == null) {
+                log.error("【isSendMsg=6】无法获取营期天数,客户ID: {},跳过处理", qwExternalContactId);
+                return;
+            }
+            log.info("【isSendMsg=6】计算营期天数成功: {}", campDays);
+        }
 
         QwExternalContact externalContact = iQwExternalContactService.selectQwExternalContactByRemark(qwExternalContactId);
 
@@ -254,23 +313,32 @@ public class FsCourseFinishTempServiceImpl implements IFsCourseFinishTempService
 
             // 2. 提取所有旧标记(无论类型)
             List<String> allOldMarks = new ArrayList<>();
-            Pattern markPattern = Pattern.compile("\\*(\\d{1,4})完");
-            Matcher markMatcher = markPattern.matcher(oldRemark);
-            while (markMatcher.find()) {
-                allOldMarks.add(markMatcher.group());
-            }
 
             // 3. 检查是否需要更新
             boolean shouldUpdate = true;
 
-            for (String mark : allOldMarks) {
-
-                String normalizedOldMark = normalizeMarkFormat(mark);
-                // 直接比较字符串是否相等
-                if (normalizedOldMark.equals(newNotes) ||
-                        normalizedOldMark.equals(newNotesDay)) {
+            if (isSendMsg == 6) {
+                // 检查是否已有相同的「第N课完」标记(不含日期,与历史「*第N课MMDD完」区分后统一为新格式)
+                Pattern expectedPattern = Pattern.compile("\\*第" + campDays + "课完");
+                if (expectedPattern.matcher(oldRemark).find()) {
                     shouldUpdate = false;
-                    break;
+                }
+            } else {
+                // 原有逻辑:检查其他类型的标记
+                Pattern markPattern = Pattern.compile("\\*(\\d{1,4})完");
+                Matcher markMatcher = markPattern.matcher(oldRemark);
+                while (markMatcher.find()) {
+                    allOldMarks.add(markMatcher.group());
+                }
+
+                for (String mark : allOldMarks) {
+                    String normalizedOldMark = normalizeMarkFormat(mark);
+                    // 直接比较字符串是否相等
+                    if (normalizedOldMark.equals(newNotes) ||
+                            normalizedOldMark.equals(newNotesDay)) {
+                        shouldUpdate = false;
+                        break;
+                    }
                 }
             }
 
@@ -279,10 +347,22 @@ public class FsCourseFinishTempServiceImpl implements IFsCourseFinishTempService
             }
 
             // 根据 isSendMsg 决定标记格式
-            String markToAdd = (isSendMsg == 3 || isSendMsg == 4) ? newNotesDay : newNotes;
+            String markToAdd;
+            if (isSendMsg == 6) {
+                // isSendMsg = 6: *第N课完(不含月日)
+                markToAdd = "*第" + campDays + "课完";
+            } else {
+                markToAdd = (isSendMsg == 3 || isSendMsg == 4) ? newNotesDay : newNotes;
+            }
 
             // 先移除现有标记
-            String remarkWithoutMark = oldRemark.replaceAll("\\*\\d{2,4}完", "").trim();
+            String remarkWithoutMark;
+            if (isSendMsg == 6) {
+                // 移除「*第N课完」「*第N课MMDD完」(含历史带月日)及「*MMdd完」
+                remarkWithoutMark = oldRemark.replaceAll("\\*第\\d+课(?:\\s*\\d{4})?完|\\*\\d{2,4}完", "").trim();
+            } else {
+                remarkWithoutMark = oldRemark.replaceAll("\\*\\d{2,4}完", "").trim();
+            }
 
             // 添加新标记(考虑长度限制)
             int keepLength = 20 - markToAdd.length();
@@ -292,7 +372,7 @@ public class FsCourseFinishTempServiceImpl implements IFsCourseFinishTempService
                 // 添加到前面
                 newRemark = markToAdd + (remarkWithoutMark.length() > keepLength ?
                         remarkWithoutMark.substring(0, keepLength) : remarkWithoutMark);
-            } else { // isSendMsg == 2 或 4
+            } else { // isSendMsg == 2 或 4 或 6
                 // 添加到后面
                 newRemark = (remarkWithoutMark.length() > keepLength ?
                         remarkWithoutMark.substring(0, keepLength) : remarkWithoutMark) + markToAdd;
@@ -313,6 +393,7 @@ public class FsCourseFinishTempServiceImpl implements IFsCourseFinishTempService
                         contactNew.setId(externalContact.getId());
                         contactNew.setRemark(newRemark);
                         qwExternalContactMapper.updateQwExternalContact(contactNew);
+                        syncCourseFinishRemarkToIm(watchLog, newRemark);
 
                         log.info("完课成功添加备注:" + externalContact.getName() + "|" + externalContact.getExternalUserId() + "|" + externalContact.getCorpId() + "|" + externalContact.getUserId() + "|" + newRemark);
 
@@ -499,6 +580,28 @@ public class FsCourseFinishTempServiceImpl implements IFsCourseFinishTempService
         }
     }
 
+    /**
+     * 完课备注同步到IM用户信息
+     */
+    private void syncCourseFinishRemarkToIm(FsCourseWatchLog watchLog, String remark) {
+        if (watchLog == null || watchLog.getUserId() == null || StringUtil.strIsNullOrEmpty(remark)) {
+            return;
+        }
+        try {
+            OpenImResponseDTO responseDTO = openIMService.updateCourseFinishUserInfo(watchLog.getUserId(), remark);
+            if (responseDTO == null) {
+                log.warn("完课备注同步IM失败,返回为空,userId:{},remark:{}", watchLog.getUserId(), remark);
+                return;
+            }
+            if (responseDTO.getErrCode() != 0) {
+                log.error("完课备注同步IM失败,userId:{},remark:{},errCode:{},errMsg:{}",
+                        watchLog.getUserId(), remark, responseDTO.getErrCode(), responseDTO.getErrMsg());
+            }
+        } catch (Exception e) {
+            log.error("完课备注同步IM异常,userId:{},remark:{}", watchLog.getUserId(), remark, e);
+        }
+    }
+
     /**
      * 批量更新完课模板状态
      * @param fsCourseFinishTemp
@@ -507,4 +610,7 @@ public class FsCourseFinishTempServiceImpl implements IFsCourseFinishTempService
     public int updateFsCourseFinishTempBatch(FsCourseFinishTemp fsCourseFinishTemp){
         return fsCourseFinishTempMapper.updateFsCourseFinishTempBatch(fsCourseFinishTemp);
     }
+
+
+
 }

+ 45 - 3
fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreOrderScrmServiceImpl.java

@@ -143,6 +143,7 @@ import com.github.binarywang.wxpay.bean.request.WxPayRefundRequest;
 import com.github.binarywang.wxpay.bean.request.WxPayRefundV3Request;
 import com.github.binarywang.wxpay.bean.request.WxPayUnifiedOrderRequest;
 import com.github.binarywang.wxpay.bean.result.WxPayRefundQueryResult;
+import com.github.binarywang.wxpay.bean.result.WxPayRefundQueryV3Result;
 import com.github.binarywang.wxpay.bean.result.WxPayRefundResult;
 import com.github.binarywang.wxpay.bean.result.WxPayRefundV3Result;
 import com.github.binarywang.wxpay.config.WxPayConfig;
@@ -204,6 +205,7 @@ import static com.fs.hisStore.constants.StoreConstants.DELIVERY;
 public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
 
     Logger logger = LoggerFactory.getLogger(getClass());
+    private static final String SPECIAL_APP_REFUND_APP_ID = "wxfefe26a6c1ff71ba";
     @Autowired
     private CompanyMoneyLogsMapper moneyLogsMapper;
 
@@ -2639,12 +2641,41 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
                     if (StringUtils.isBlank(payment.getAppId())) {
                         throw new IllegalArgumentException("appId不能为空");
                     }
+                    boolean specialAppRefund = SPECIAL_APP_REFUND_APP_ID.equals(payment.getAppId());
                     String payType = payment.getPayMode();
                     if ("wxApp".equals(payment.getPayMode())){
                         payType = "wx";
                     }
 
-                    if (payment.getPayMode() == null || payment.getPayMode().equals("wx")) {
+                    if (specialAppRefund) {
+                        WxPayService appWxPayService = getWxPayService();
+                        WxPayRefundV3Request refundRequest = new WxPayRefundV3Request();
+                        refundRequest.setOutTradeNo("store-" + payment.getPayCode());
+                        refundRequest.setOutRefundNo("store-" + payment.getPayCode());
+                        WxPayRefundV3Request.Amount amount = new WxPayRefundV3Request.Amount();
+                        Integer money = WxPayUnifiedOrderRequest.yuanToFen(payment.getPayMoney().toString());
+                        amount.setRefund(money);
+                        amount.setTotal(money);
+                        amount.setCurrency("CNY");
+                        refundRequest.setAmount(amount);
+                        try {
+                            WxPayRefundV3Result refundResult = appWxPayService.refundV3(refundRequest);
+                            WxPayRefundQueryV3Result refundQueryResult = appWxPayService.refundQueryV3(refundResult.getOutRefundNo());
+                            if (refundQueryResult != null && "SUCCESS".equals(refundQueryResult.getStatus())) {
+                                FsStorePaymentScrm paymentMap = new FsStorePaymentScrm();
+                                paymentMap.setPaymentId(payment.getPaymentId());
+                                paymentMap.setStatus(-1);
+                                paymentMap.setRefundTime(DateUtils.getNowDate());
+                                paymentMap.setRefundMoney(payment.getPayMoney());
+                                paymentService.updateFsStorePayment(paymentMap);
+                            } else {
+                                String errMsg = refundQueryResult == null ? "退款查询为空" : refundQueryResult.getStatus();
+                                throw new CustomException("退款请求失败" + errMsg);
+                            }
+                        } catch (WxPayException e) {
+                            throw new CustomException("退款请求失败" + e.getCustomErrorMsg());
+                        }
+                    } else if (payment.getPayMode() == null || payment.getPayMode().equals("wx")) {
 
                         MerchantAppConfig merchantAppConfig = merchantAppConfigMapper.selectMerchantAppConfigByAppId(payment.getAppId(),payType);
                         FsPayConfig fsPayConfig = JSON.parseObject(merchantAppConfig.getDataJson(), FsPayConfig.class);
@@ -2653,8 +2684,11 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
                         payConfig.setMchId(fsPayConfig.getWxMchId());
                         payConfig.setMchKey(fsPayConfig.getWxMchKey());
                         payConfig.setKeyPath(fsPayConfig.getKeyPath());
-                        payConfig.setPublicKeyId(fsPayConfig.getPublicKeyId());
-                        payConfig.setPublicKeyPath(fsPayConfig.getPublicKeyPath());
+                        if (StringUtils.isNotBlank(fsPayConfig.getPublicKeyId())
+                                && StringUtils.isNotBlank(fsPayConfig.getPublicKeyPath())) {
+                            payConfig.setPublicKeyId(fsPayConfig.getPublicKeyId());
+                            payConfig.setPublicKeyPath(fsPayConfig.getPublicKeyPath());
+                        }
                         payConfig.setApiV3Key(fsPayConfig.getWxApiV3Key());
                         payConfig.setPrivateKeyPath(fsPayConfig.getPrivateKeyPath());
                         payConfig.setPrivateCertPath(fsPayConfig.getPrivateCertPath());
@@ -2877,6 +2911,14 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
         payConfig.setSubAppId(StringUtils.trimToNull(null));
         payConfig.setSubMchId(StringUtils.trimToNull(null));
         payConfig.setKeyPath(payConfig1.getKeyPath());
+        if (StringUtils.isNotBlank(payConfig1.getPublicKeyId())
+                && StringUtils.isNotBlank(payConfig1.getPublicKeyPath())) {
+            payConfig.setPublicKeyId(payConfig1.getPublicKeyId());
+            payConfig.setPublicKeyPath(payConfig1.getPublicKeyPath());
+        }
+        payConfig.setApiV3Key(payConfig1.getWxApiV3Key());
+        payConfig.setPrivateKeyPath(payConfig1.getPrivateKeyPath());
+        payConfig.setPrivateCertPath(payConfig1.getPrivateCertPath());
         payConfig.setNotifyUrl(payConfig1.getNotifyUrlScrm());
         WxPayServiceImpl payService = new WxPayServiceImpl();
         payService.setConfig(payConfig);

+ 2 - 0
fs-service/src/main/java/com/fs/im/service/OpenIMService.java

@@ -38,6 +38,8 @@ public interface OpenIMService {
 
     OpenImResponseDTO updateUserInfo(CompanyUser companyUser);
 
+    OpenImResponseDTO updateCourseFinishUserInfo(Long userId, String nickname);
+
     OpenImResponseDTO sendPackageUtil(String sendID, String recvID, Integer contentType, String payloadData,String packageName,String packageId);
 
     /**

+ 50 - 0
fs-service/src/main/java/com/fs/im/service/impl/OpenIMServiceImpl.java

@@ -474,6 +474,56 @@ public class OpenIMServiceImpl implements OpenIMService {
 
         return responseDTO;
     }
+
+    @Override
+    public OpenImResponseDTO updateCourseFinishUserInfo(Long userId, String nickname) {
+        if (userId == null || StringUtils.isEmpty(nickname)) {
+            return null;
+        }
+        String adminToken = getAdminToken();
+        if (StringUtils.isEmpty(adminToken)) {
+            log.error("完课同步IM用户信息失败,管理员token为空,userId:{}", userId);
+            return null;
+        }
+
+        String imUserId = "U" + userId;
+        Map<String, Object> queryParamMap = new HashMap<>();
+        queryParamMap.put("userIDs", Collections.singletonList(imUserId));
+        String queryBody = JSONUtil.toJsonStr(queryParamMap);
+
+        String queryResult = HttpRequest.post(IMConfig.URL + "/user/get_users_info")
+                .header("operationID", String.valueOf(System.currentTimeMillis()))
+                .header("token", adminToken)
+                .body(queryBody)
+                .execute()
+                .body();
+
+        OpenImResponseDTOTest queryResponse = JSONUtil.toBean(queryResult, OpenImResponseDTOTest.class);
+        if (queryResponse == null || queryResponse.getData() == null || CollectionUtils.isEmpty(queryResponse.getData().getUsersInfo())) {
+            log.warn("完课同步IM用户信息时,未查询到IM用户,imUserId:{}", imUserId);
+            return null;
+        }
+
+        UserInfo userInfo = queryResponse.getData().getUsersInfo().get(0);
+        UpdateUserInfo updateUserInfo = new UpdateUserInfo();
+        updateUserInfo.setUserID(userInfo.getUserID());
+        updateUserInfo.setNickname(nickname);
+        updateUserInfo.setFaceURL(Optional.ofNullable(userInfo.getFaceURL()).orElse(""));
+        updateUserInfo.setEx(Optional.ofNullable(userInfo.getEx()).orElse(""));
+
+        Map<String, Object> bodyMap = new HashMap<>();
+        bodyMap.put("userInfo", updateUserInfo);
+        String updateBody = JSONUtil.toJsonStr(bodyMap);
+
+        String updateResult = HttpRequest.post(IMConfig.URL + "/user/update_user_info_ex")
+                .header("operationID", String.valueOf(System.currentTimeMillis()))
+                .header("token", adminToken)
+                .body(updateBody)
+                .execute()
+                .body();
+        return JSONUtil.toBean(updateResult, OpenImResponseDTO.class);
+    }
+
     @Data
     public static class UpdateUserInfo {
         private String userID;

+ 67 - 0
fs-service/src/main/java/com/fs/live/domain/LiveOrderTipConfig.java

@@ -0,0 +1,67 @@
+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)
+ *
+ * <p>用于控制直播间"[用户昵称] 刚刚下单了 [商品名称]"类提示的推送规则,支持频率控制、假数据填充、
+ * 商品范围、昵称库、文案模板等配置。高并发下通过 Redis 限流保证每分钟不超过 rateMaxPerMinute 条。</p>
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class LiveOrderTipConfig extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 主键ID,固定为 1 */
+    private Long configId;
+
+    /** 动效提示总开关 0关闭 1开启 */
+    @Excel(name = "动效总开关")
+    private Integer enabled;
+
+    /** 每分钟最少显示条数(真实不足用假订单补齐) */
+    @Excel(name = "每分钟最少条数")
+    private Integer rateMinPerMinute;
+
+    /** 每分钟最多显示条数(超出丢弃避免刷屏) */
+    @Excel(name = "每分钟最多条数")
+    private Integer rateMaxPerMinute;
+
+    /** 频率控制滑动窗口秒数,默认60秒 */
+    private Integer windowSec;
+
+    /** 假数据填充开关 0关闭 1开启 */
+    @Excel(name = "假数据开关")
+    private Integer fakeEnabled;
+
+    /** 假订单最小生成间隔(秒),用于限制假数据定时任务 */
+    private Integer fakeMinIntervalSec;
+
+    /**
+     * 商品范围:all=全部在售商品 live=仅当前直播间在售 custom=指定商品ID
+     */
+    private String goodsScope;
+
+    /** 指定商品ID列表,英文逗号分隔,goodsScope=custom 时生效 */
+    private String customGoodsIds;
+
+    /** 昵称库(每行一个或英文逗号分隔) */
+    private String nicknameLibrary;
+
+    /** 真实昵称是否脱敏展示 0否 1是(仅显示首字+**) */
+    private Integer nicknameMask;
+
+    /** 刚刚下单模板(30秒内),占位符:[用户昵称] [商品名称] */
+    private String templateJustNow;
+
+    /** 时间前模板(>刚刚阈值):[时间] [用户昵称] [商品名称] */
+    private String templateAgo;
+
+    /** 刚刚下单阈值秒数,超过此阈值切换到 templateAgo */
+    private Integer justNowSeconds;
+}

+ 15 - 0
fs-service/src/main/java/com/fs/live/mapper/LiveOrderTipConfigMapper.java

@@ -0,0 +1,15 @@
+package com.fs.live.mapper;
+
+import com.fs.live.domain.LiveOrderTipConfig;
+
+/**
+ * 直播下单动效提示全局配置 Mapper(仅单条 config_id=1)
+ */
+public interface LiveOrderTipConfigMapper {
+
+    LiveOrderTipConfig selectByConfigId(Long configId);
+
+    int updateLiveOrderTipConfig(LiveOrderTipConfig config);
+
+    int insertLiveOrderTipConfig(LiveOrderTipConfig config);
+}

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

@@ -0,0 +1,18 @@
+package com.fs.live.service;
+
+import com.fs.live.domain.LiveOrderTipConfig;
+
+/**
+ * 直播下单动效提示 —— 全局配置 Service
+ */
+public interface ILiveOrderTipConfigService {
+
+    /** 获取有效配置(命中缓存 / 未命中查库 / 库里没有时返回默认) */
+    LiveOrderTipConfig getEffectiveConfig();
+
+    /** 清空配置缓存 */
+    void evictConfigCache();
+
+    /** 更新配置,成功后自动清缓存 */
+    int updateConfig(LiveOrderTipConfig config);
+}

+ 40 - 0
fs-service/src/main/java/com/fs/live/service/ILiveOrderTipService.java

@@ -0,0 +1,40 @@
+package com.fs.live.service;
+
+import com.fs.live.domain.LiveOrder;
+import com.fs.live.vo.LiveOrderTipVo;
+
+/**
+ * 直播下单动效提示业务 Service
+ *
+ * <p>负责:</p>
+ * <ul>
+ *     <li>真实订单支付成功后的提示推送(带幂等/去重/限流)</li>
+ *     <li>假订单填充(在真实订单稀疏时按配置自动生成)</li>
+ *     <li>按全局配置进行频率控制,确保单直播间不会刷屏</li>
+ * </ul>
+ */
+public interface ILiveOrderTipService {
+
+    /**
+     * 真实订单下单成功后触发(异步、非阻塞主事务)。
+     * 内部会按配置进行:去重 -> 频率限流 -> 模板渲染 -> WebSocket 广播。
+     *
+     * @param order 订单对象
+     */
+    void publishRealOrderTip(LiveOrder order);
+
+    /**
+     * 生成并推送一条假订单提示。通常由定时任务调用,调用前应保证:
+     * 1) 配置开启 enabled=1 且 fakeEnabled=1
+     * 2) 该直播间有在线用户
+     *
+     * @param liveId  直播间ID
+     * @return 推送的 Vo,若被限流或无可用商品则返回 null
+     */
+    LiveOrderTipVo publishFakeOrderTip(Long liveId);
+
+    /**
+     * 渲染展示文案(按模板 + 时间差自动切换"刚刚"/"X分钟前")
+     */
+    String renderContent(String nickname, String productName, long ageSeconds);
+}

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

@@ -41,6 +41,20 @@ public class LiveAppWebSocketNotifyService {
         postJson("/app/live/comment/broadcastToLive", body);
     }
 
+    /**
+     * 订单动效提示走消息队列的异步广播入口(高并发场景推荐)。
+     * 会被消费者线程池异步发送,内部具备背压保护,高峰期可能丢弃。
+     */
+    public void broadcastOrderTipToLive(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/broadcastOrderTip", body);
+    }
+
     private void postJson(String path, Object body) {
         if (environment == null) {
             return;

+ 44 - 4
fs-service/src/main/java/com/fs/live/service/impl/LiveGoodsServiceImpl.java

@@ -19,11 +19,13 @@ import com.fs.live.service.ILiveAutoTaskService;
 import com.fs.live.service.ILiveGoodsService;
 import com.fs.live.vo.LiveGoodsListVo;
 import com.fs.live.vo.LiveGoodsVo;
+import com.fs.system.service.ISysConfigService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
@@ -46,6 +48,8 @@ public class LiveGoodsServiceImpl extends ServiceImpl<LiveGoodsMapper, LiveGoods
     private LiveGoodsMapper baseMapper;
     @Autowired
     private ILiveAutoTaskService liveAutoTaskService;
+    @Autowired
+    private ISysConfigService configService;
 
     /**
      * 查询直播商品
@@ -155,6 +159,7 @@ public class LiveGoodsServiceImpl extends ServiceImpl<LiveGoodsMapper, LiveGoods
         liveGoods.setLiveId(liveId);
         liveGoods.setKeywords(key);
         List<LiveGoodsVo> liveGoodsVos = baseMapper.selectProductListByLiveId(liveGoods);
+        applyStockThreshold(liveGoodsVos);
         if (liveGoodsVos.isEmpty()) {
             return R.ok();
         }
@@ -165,17 +170,23 @@ public class LiveGoodsServiceImpl extends ServiceImpl<LiveGoodsMapper, LiveGoods
 
     @Override
     public List<LiveGoodsVo> selectProductListByLiveId(LiveGoods liveGoods) {
-        return baseMapper.selectProductListByLiveIdAll(liveGoods);
+        List<LiveGoodsVo> list = baseMapper.selectProductListByLiveIdAll(liveGoods);
+        applyStockThreshold(list);
+        return list;
     }
 
     @Override
     public LiveGoodsVo selectLiveGoodsVoByGoodsId(Long goodsId) {
-        return baseMapper.selectLiveGoodsVoByGoodsId(goodsId);
+        LiveGoodsVo liveGoodsVo = baseMapper.selectLiveGoodsVoByGoodsId(goodsId);
+        applyStockThreshold(Collections.singletonList(liveGoodsVo));
+        return liveGoodsVo;
     }
 
     @Override
     public LiveGoodsVo showGoods(Long liveId) {
-        return baseMapper.showGoods(liveId);
+        LiveGoodsVo liveGoodsVo = baseMapper.showGoods(liveId);
+        applyStockThreshold(Collections.singletonList(liveGoodsVo));
+        return liveGoodsVo;
     }
 
     @Override
@@ -200,6 +211,7 @@ public class LiveGoodsServiceImpl extends ServiceImpl<LiveGoodsMapper, LiveGoods
         liveGoods.setKeywords(key);
         liveGoods.setCompanyUserId(Long.parseLong(userId));
         List<LiveGoodsVo> liveGoodsVos = baseMapper.selectProductListByLiveId(liveGoods);
+        applyStockThreshold(liveGoodsVos);
         if (liveGoodsVos.isEmpty()) {
             return R.ok();
         }
@@ -424,7 +436,9 @@ public class LiveGoodsServiceImpl extends ServiceImpl<LiveGoodsMapper, LiveGoods
      */
     @Override
     public List<LiveGoodsVo> selectLiveGoodsListByMap(Map<String, Object> params) {
-        return baseMapper.selectLiveGoodsListByStoreId(params);
+        List<LiveGoodsVo> list = baseMapper.selectLiveGoodsListByStoreId(params);
+        applyStockThreshold(list);
+        return list;
     }
 
     @Override
@@ -472,4 +486,30 @@ public class LiveGoodsServiceImpl extends ServiceImpl<LiveGoodsMapper, LiveGoods
         baseMapper.handleIsShowChange(listVo);
         return R.ok().put("isShow", listVo.getIsShow());
     }
+
+    private void applyStockThreshold(List<LiveGoodsVo> liveGoodsVos) {
+        if (liveGoodsVos == null || liveGoodsVos.isEmpty()) {
+            return;
+        }
+        int lowStockThreshold = getLowStockThreshold();
+        for (LiveGoodsVo liveGoodsVo : liveGoodsVos) {
+            if (liveGoodsVo == null) {
+                continue;
+            }
+            liveGoodsVo.setShowStockHint(lowStockThreshold);
+        }
+    }
+
+    private int getLowStockThreshold() {
+        String threshold = configService.selectConfigByKey("live.lowStockThreshold");
+        if (threshold == null || threshold.trim().isEmpty()) {
+            return 0;
+        }
+        try {
+            int value = Integer.parseInt(threshold.trim());
+            return Math.max(1, value);
+        } catch (NumberFormatException ignore) {
+            return 0;
+        }
+    }
 }

+ 9 - 0
fs-service/src/main/java/com/fs/live/service/impl/LiveOrderServiceImpl.java

@@ -299,6 +299,9 @@ public class LiveOrderServiceImpl implements ILiveOrderService {
     @Autowired
     private FsWxExpressTaskMapper fsWxExpressTaskMapper;
 
+    @Autowired
+    private ILiveOrderTipService liveOrderTipService;
+
     //ERP 类型到服务的映射
     private Map<Integer, IErpOrderService> erpServiceMap;
     private final BlockingQueue<LiveGoods> liveGoodsQueue = new LinkedBlockingQueue<>();
@@ -871,6 +874,12 @@ public class LiveOrderServiceImpl implements ILiveOrderService {
             } catch (Exception e) {
                 log.error("推送erp失败:{}",e.getMessage());
             }
+            // 下单动效提示 —— 异步、非阻塞主事务;内部做限流+去重+无异常外抛
+            try {
+                liveOrderTipService.publishRealOrderTip(order);
+            } catch (Exception e) {
+                log.warn("[订单动效] 真实订单推送触发异常, orderId={}, err={}", order.getOrderId(), e.getMessage());
+            }
             return "SUCCESS";
         } catch (Exception e) {
             log.info("支付错误:" + e.getMessage());

+ 83 - 0
fs-service/src/main/java/com/fs/live/service/impl/LiveOrderTipConfigServiceImpl.java

@@ -0,0 +1,83 @@
+package com.fs.live.service.impl;
+
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.DateUtils;
+import com.fs.live.domain.LiveOrderTipConfig;
+import com.fs.live.mapper.LiveOrderTipConfigMapper;
+import com.fs.live.service.ILiveOrderTipConfigService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.concurrent.TimeUnit;
+
+@Service
+public class LiveOrderTipConfigServiceImpl implements ILiveOrderTipConfigService {
+
+    private static final Long CONFIG_ID = 1L;
+
+    @Autowired
+    private LiveOrderTipConfigMapper configMapper;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    @Override
+    public LiveOrderTipConfig getEffectiveConfig() {
+        LiveOrderTipConfig cached = redisCache.getCacheObject(LiveKeysConstant.LIVE_ORDER_TIP_CONFIG_ROW);
+        if (cached != null) {
+            return cached;
+        }
+        LiveOrderTipConfig row = configMapper.selectByConfigId(CONFIG_ID);
+        if (row == null) {
+            row = defaultConfig();
+        }
+        redisCache.setCacheObject(LiveKeysConstant.LIVE_ORDER_TIP_CONFIG_ROW, row,
+                LiveKeysConstant.LIVE_ORDER_TIP_CONFIG_EXPIRE_SEC, TimeUnit.SECONDS);
+        return row;
+    }
+
+    @Override
+    public void evictConfigCache() {
+        redisCache.deleteObject(LiveKeysConstant.LIVE_ORDER_TIP_CONFIG_ROW);
+    }
+
+    @Override
+    public int updateConfig(LiveOrderTipConfig config) {
+        config.setConfigId(CONFIG_ID);
+        if (config.getUpdateTime() == null) {
+            config.setUpdateTime(DateUtils.getNowDate());
+        }
+        int rows;
+        LiveOrderTipConfig exists = configMapper.selectByConfigId(CONFIG_ID);
+        if (exists == null) {
+            applyDefaults(config);
+            rows = configMapper.insertLiveOrderTipConfig(config);
+        } else {
+            rows = configMapper.updateLiveOrderTipConfig(config);
+        }
+        evictConfigCache();
+        return rows;
+    }
+
+    private static void applyDefaults(LiveOrderTipConfig c) {
+        if (c.getEnabled() == null) c.setEnabled(1);
+        if (c.getRateMinPerMinute() == null) c.setRateMinPerMinute(1);
+        if (c.getRateMaxPerMinute() == null) c.setRateMaxPerMinute(3);
+        if (c.getWindowSec() == null) c.setWindowSec(60);
+        if (c.getFakeEnabled() == null) c.setFakeEnabled(1);
+        if (c.getFakeMinIntervalSec() == null) c.setFakeMinIntervalSec(20);
+        if (c.getGoodsScope() == null) c.setGoodsScope("live");
+        if (c.getNicknameMask() == null) c.setNicknameMask(1);
+        if (c.getTemplateJustNow() == null) c.setTemplateJustNow("[用户昵称] 刚刚下单了 [商品名称]");
+        if (c.getTemplateAgo() == null) c.setTemplateAgo("[时间]前,[用户昵称] 购买了 [商品名称]");
+        if (c.getJustNowSeconds() == null) c.setJustNowSeconds(30);
+    }
+
+    private static LiveOrderTipConfig defaultConfig() {
+        LiveOrderTipConfig c = new LiveOrderTipConfig();
+        c.setConfigId(CONFIG_ID);
+        applyDefaults(c);
+        return c;
+    }
+}

+ 481 - 0
fs-service/src/main/java/com/fs/live/service/impl/LiveOrderTipServiceImpl.java

@@ -0,0 +1,481 @@
+package com.fs.live.service.impl;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.StringUtils;
+import com.fs.hisStore.domain.FsUserScrm;
+import com.fs.hisStore.service.IFsUserScrmService;
+import com.fs.live.domain.LiveOrder;
+import com.fs.live.domain.LiveOrderTipConfig;
+import com.fs.live.mapper.LiveGoodsMapper;
+import com.fs.live.service.ILiveOrderTipConfigService;
+import com.fs.live.service.ILiveOrderTipService;
+import com.fs.live.service.LiveAppWebSocketNotifyService;
+import com.fs.live.vo.LiveGoodsVo;
+import com.fs.live.vo.LiveOrderTipVo;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.script.DefaultRedisScript;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.stream.Collectors;
+
+/**
+ * 直播下单动效提示业务实现。
+ *
+ * <p><b>高并发优化要点:</b></p>
+ * <ol>
+ *   <li>限流:使用 Redis ZSet 做滑动窗口 + Lua 脚本原子操作,避免集群竞态。</li>
+ *   <li>真实订单去重:以 orderId 作为 Redis SETNX key(过期时间=2 倍窗口),防止回调重复推送。</li>
+ *   <li>配置缓存:全局配置采用 5 分钟 TTL 缓存,避免热点读 DB。</li>
+ *   <li>商品候选池:直播间商品列表缓存 30 秒,避免假订单每次都查 DB。</li>
+ *   <li>推送异步化:不阻塞支付事务,通过 @Async 或调用方自行异步。</li>
+ *   <li>消息载体复用现有 WebSocket 广播通道,单直播间 1 万人靠 fs-live-app 的消息队列/广播线程池承载。</li>
+ * </ol>
+ */
+@Slf4j
+@Service
+public class LiveOrderTipServiceImpl implements ILiveOrderTipService {
+
+    @Autowired
+    private ILiveOrderTipConfigService tipConfigService;
+
+    @Autowired
+    private LiveAppWebSocketNotifyService wsNotifyService;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    @Autowired
+    private LiveGoodsMapper liveGoodsMapper;
+
+    @Autowired
+    private IFsUserScrmService fsUserService;
+
+    /** 内置默认昵称库(前端/后台未配置时兜底使用) */
+    private static final List<String> DEFAULT_NICKNAMES = Collections.unmodifiableList(Arrays.asList(
+            "养生小达人", "健康路上", "乐活青年", "品味生活", "星月同辉",
+            "简单快乐", "暖阳微风", "一枕清风", "云淡风轻", "微笑向暖",
+            "岁月静好", "小确幸", "明月入怀", "山水有相逢", "梅子黄时雨",
+            "温柔星光", "初夏时光", "蒲公英的约定", "晚风轻拂", "清风明月"));
+
+    /**
+     * 原子滑动窗口限流脚本:
+     * KEYS[1] = 窗口键 (ZSet)
+     * ARGV[1] = 当前时间戳(毫秒)
+     * ARGV[2] = 窗口起点(毫秒)= now - windowMs
+     * ARGV[3] = 最大允许数量
+     * ARGV[4] = 唯一 member(避免 score 相同覆盖)
+     * ARGV[5] = 键 TTL 秒
+     * 返回:1=允许 0=拒绝
+     */
+    private static final String RATE_LIMIT_LUA = ""
+            + "redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[2])\n"
+            + "local cnt = redis.call('ZCARD', KEYS[1])\n"
+            + "if tonumber(cnt) >= tonumber(ARGV[3]) then return 0 end\n"
+            + "redis.call('ZADD', KEYS[1], ARGV[1], ARGV[4])\n"
+            + "redis.call('EXPIRE', KEYS[1], ARGV[5])\n"
+            + "return 1";
+
+    @Override
+    @Async
+    public void publishRealOrderTip(LiveOrder order) {
+        if (order == null || order.getLiveId() == null || order.getLiveId() <= 0) {
+            return;
+        }
+        try {
+            LiveOrderTipConfig cfg = tipConfigService.getEffectiveConfig();
+            if (!isEnabled(cfg)) {
+                return;
+            }
+
+            // 去重(同一订单只能推一次,TTL = 2 * window,足够覆盖支付回调重试)
+            String dedupKey = String.format(LiveKeysConstant.LIVE_ORDER_TIP_REAL_DEDUP,
+                    order.getOrderId() == null ? UUID.randomUUID().toString() : order.getOrderId());
+            boolean first = redisCache.setIfAbsent(dedupKey, "1",
+                    Math.max(60, safeInt(cfg.getWindowSec(), 60) * 2L), TimeUnit.SECONDS);
+            if (!first) {
+                return;
+            }
+
+            // 限流
+            if (!tryAcquire(order.getLiveId(), cfg)) {
+                log.debug("[订单动效] 限流丢弃真实订单, liveId={}, orderId={}", order.getLiveId(), order.getOrderId());
+                return;
+            }
+
+            // 取商品名称与昵称
+            LiveGoodsVo goods = findGoodsForRealOrder(order);
+            if (goods == null || StringUtils.isEmpty(goods.getProductName())) {
+                return;
+            }
+            String nickname = resolveNicknameForRealOrder(order, cfg);
+
+            long now = System.currentTimeMillis();
+            long orderTs = resolveOrderTs(order, now);
+            long ageSec = Math.max(0, (now - orderTs) / 1000);
+            String content = renderContent(nickname, goods.getProductName(), ageSec);
+
+            LiveOrderTipVo vo = LiveOrderTipVo.builder()
+                    .liveId(order.getLiveId())
+                    .nickname(nickname)
+                    .goodsId(goods.getGoodsId())
+                    .productName(goods.getProductName())
+                    .imgUrl(goods.getImgUrl())
+                    .content(content)
+                    .orderTime(orderTs)
+                    .fake(0)
+                    .build();
+
+            broadcast(vo);
+        } catch (Exception e) {
+            log.error("[订单动效] 推送真实订单异常, orderId={}, liveId={}",
+                    order.getOrderId(), order.getLiveId(), e);
+        }
+    }
+
+    @Override
+    public LiveOrderTipVo publishFakeOrderTip(Long liveId) {
+        if (liveId == null || liveId <= 0) {
+            return null;
+        }
+        try {
+            LiveOrderTipConfig cfg = tipConfigService.getEffectiveConfig();
+            if (!isEnabled(cfg) || !isFakeEnabled(cfg)) {
+                return null;
+            }
+
+            // 假订单自身的最小间隔(二级节流,避免定时任务短周期内重复生成)
+            int minInterval = safeInt(cfg.getFakeMinIntervalSec(), 20);
+            if (minInterval > 0) {
+                String fakeKey = String.format(LiveKeysConstant.LIVE_ORDER_TIP_FAKE_LAST, liveId);
+                boolean allowFake = redisCache.setIfAbsent(fakeKey, "1", minInterval, TimeUnit.SECONDS);
+                if (!allowFake) {
+                    return null;
+                }
+            }
+
+            // 共享全局窗口限流(真假数据共同计数,保证总频率不超阈值)
+            if (!tryAcquire(liveId, cfg)) {
+                return null;
+            }
+
+            LiveGoodsVo goods = pickGoodsForLive(liveId, cfg);
+            if (goods == null || StringUtils.isEmpty(goods.getProductName())) {
+                return null;
+            }
+            String nickname = pickNicknameFromLibrary(cfg);
+
+            long now = System.currentTimeMillis();
+            // 假订单时间在 [0, justNowSeconds*2] 秒前随机,更像真实发生
+            long justNow = safeInt(cfg.getJustNowSeconds(), 30);
+            long ageSec = ThreadLocalRandom.current().nextLong(0, Math.max(1, justNow * 2));
+            String content = renderContent(nickname, goods.getProductName(), ageSec);
+
+            LiveOrderTipVo vo = LiveOrderTipVo.builder()
+                    .liveId(liveId)
+                    .nickname(nickname)
+                    .goodsId(goods.getGoodsId())
+                    .productName(goods.getProductName())
+                    .imgUrl(goods.getImgUrl())
+                    .content(content)
+                    .orderTime(now - ageSec * 1000)
+                    .fake(1)
+                    .build();
+
+            broadcast(vo);
+            return vo;
+        } catch (Exception e) {
+            log.error("[订单动效] 生成假订单异常, liveId={}", liveId, e);
+            return null;
+        }
+    }
+
+    @Override
+    public String renderContent(String nickname, String productName, long ageSeconds) {
+        LiveOrderTipConfig cfg = tipConfigService.getEffectiveConfig();
+        int justNow = safeInt(cfg.getJustNowSeconds(), 30);
+
+        String tpl = (ageSeconds <= justNow)
+                ? (StringUtils.isEmpty(cfg.getTemplateJustNow())
+                    ? "[用户昵称] 刚刚下单了 [商品名称]" : cfg.getTemplateJustNow())
+                : (StringUtils.isEmpty(cfg.getTemplateAgo())
+                    ? "[时间]前,[用户昵称] 购买了 [商品名称]" : cfg.getTemplateAgo());
+
+        String timeText = humanizeTime(ageSeconds);
+        return tpl.replace("[用户昵称]", nickname == null ? "" : nickname)
+                .replace("[商品名称]", productName == null ? "" : productName)
+                .replace("[时间]", timeText);
+    }
+
+    // =============================================================================
+    // 内部辅助方法
+    // =============================================================================
+
+    /** 使用 Redis Lua 原子滑动窗口限流,超过 rateMax 则拒绝 */
+    private boolean tryAcquire(Long liveId, LiveOrderTipConfig cfg) {
+        int max = safeInt(cfg.getRateMaxPerMinute(), 3);
+        int windowSec = safeInt(cfg.getWindowSec(), 60);
+        if (max <= 0 || windowSec <= 0) {
+            return false;
+        }
+        String key = String.format(LiveKeysConstant.LIVE_ORDER_TIP_WINDOW, liveId);
+        long now = System.currentTimeMillis();
+        long start = now - windowSec * 1000L;
+        String member = now + "-" + UUID.randomUUID();
+
+        try {
+            @SuppressWarnings("unchecked")
+            RedisTemplate<String, Object> template = (RedisTemplate<String, Object>) redisCache.redisTemplate;
+            DefaultRedisScript<Long> script = new DefaultRedisScript<>(RATE_LIMIT_LUA, Long.class);
+            Long result = template.execute(
+                    script,
+                    Collections.singletonList(key),
+                    String.valueOf(now),
+                    String.valueOf(start),
+                    String.valueOf(max),
+                    member,
+                    String.valueOf(windowSec + 10)
+            );
+            return result != null && result == 1L;
+        } catch (Exception e) {
+            // 降级:出错时放行,保证功能可用(宁可少量刷屏也不能导致阻塞)
+            log.warn("[订单动效] 限流脚本执行异常,本次放行, liveId={}, err={}", liveId, e.getMessage());
+            return true;
+        }
+    }
+
+    /** 当前窗口内的剩余容量,供 scheduler 判断是否需要补充假订单 */
+    public int remainingQuota(Long liveId, LiveOrderTipConfig cfg) {
+        int max = safeInt(cfg.getRateMaxPerMinute(), 3);
+        int windowSec = safeInt(cfg.getWindowSec(), 60);
+        String key = String.format(LiveKeysConstant.LIVE_ORDER_TIP_WINDOW, liveId);
+        long now = System.currentTimeMillis();
+        long start = now - windowSec * 1000L;
+        try {
+            redisCache.redisTemplate.opsForZSet().removeRangeByScore(key, 0, start);
+            Long cnt = redisCache.redisTemplate.opsForZSet().zCard(key);
+            int used = cnt == null ? 0 : cnt.intValue();
+            return Math.max(0, max - used);
+        } catch (Exception e) {
+            log.warn("[订单动效] 查询剩余配额异常, liveId={}, err={}", liveId, e.getMessage());
+            return 1;
+        }
+    }
+
+    /**
+     * 广播到 fs-live-app(通过 HTTP 调 WebSocket 广播接口)。
+     *
+     * <p>消息结构与现有 WebSocket 广播保持一致:<code>R.ok().put("data", SendMsgVo)</code>,
+     * 前端按 <code>cmd=orderTip</code> 识别订单动效提示。</p>
+     */
+    private void broadcast(LiveOrderTipVo vo) {
+        JSONObject sendMsg = new JSONObject();
+        sendMsg.put("liveId", vo.getLiveId());
+        sendMsg.put("cmd", "orderTip");
+        sendMsg.put("msg", vo.getContent());
+        sendMsg.put("data", JSONObject.toJSONString(vo));
+        sendMsg.put("on", true);
+        String fullJson = JSONObject.toJSONString(R.ok().put("data", sendMsg));
+        // 走订单动效专用入口:内部进入 fs-live-app 的优先级消息队列,具备背压保护,
+        // 高峰(1万人+)下不阻塞调用方,避免 WebSocket 广播线程反压到业务主流程。
+        wsNotifyService.broadcastOrderTipToLive(vo.getLiveId(), fullJson);
+    }
+
+    /** 真实订单优先从订单的 itemJson 里取商品;没有则查 live_goods 展示中的商品 */
+    private LiveGoodsVo findGoodsForRealOrder(LiveOrder order) {
+        // 1) 按 productId 定位(准确)
+        if (order.getProductId() != null && order.getLiveId() != null) {
+            try {
+                com.fs.live.domain.LiveGoods lg = liveGoodsMapper.selectLiveGoodsByProductId(
+                        order.getLiveId(), order.getProductId());
+                if (lg != null) {
+                    LiveGoodsVo vo = liveGoodsMapper.selectLiveGoodsVoByGoodsId(lg.getGoodsId());
+                    if (vo != null) return vo;
+                }
+            } catch (Exception ignore) {
+                // 兜底继续走下方
+            }
+        }
+        // 2) 解析 itemJson 取 productName
+        if (StringUtils.isNotEmpty(order.getItemJson())) {
+            try {
+                JSONObject obj = JSONObject.parseObject(order.getItemJson());
+                String productName = obj.getString("productName");
+                if (StringUtils.isNotEmpty(productName)) {
+                    LiveGoodsVo vo = new LiveGoodsVo();
+                    vo.setGoodsId(null);
+                    vo.setProductName(productName);
+                    vo.setImgUrl(obj.getString("image"));
+                    return vo;
+                }
+            } catch (Exception ignore) {}
+        }
+        // 3) 随机直播间展示商品
+        return pickGoodsForLive(order.getLiveId(), tipConfigService.getEffectiveConfig());
+    }
+
+    /** 为假订单/兜底选择一个可用商品(随机) */
+    private LiveGoodsVo pickGoodsForLive(Long liveId, LiveOrderTipConfig cfg) {
+        String scope = cfg == null || StringUtils.isEmpty(cfg.getGoodsScope()) ? "live" : cfg.getGoodsScope();
+        // custom 指定商品池
+        if ("custom".equalsIgnoreCase(scope) && StringUtils.isNotEmpty(cfg.getCustomGoodsIds())) {
+            List<Long> ids = parseLongList(cfg.getCustomGoodsIds());
+            if (!ids.isEmpty()) {
+                Long goodsId = ids.get(ThreadLocalRandom.current().nextInt(ids.size()));
+                LiveGoodsVo vo = liveGoodsMapper.selectLiveGoodsVoByGoodsId(goodsId);
+                if (vo != null) return vo;
+            }
+        }
+        // live/all:从当前直播间在售商品里挑
+        List<LiveGoodsVo> pool = getLiveGoodsPool(liveId);
+        if (pool == null || pool.isEmpty()) {
+            return null;
+        }
+        return pool.get(ThreadLocalRandom.current().nextInt(pool.size()));
+    }
+
+    /** 直播间可用商品缓存池(30 秒 TTL) */
+    @SuppressWarnings("unchecked")
+    private List<LiveGoodsVo> getLiveGoodsPool(Long liveId) {
+        String cacheKey = "live:order:tip:goods:pool:" + liveId;
+        Object cached = redisCache.getCacheObject(cacheKey);
+        if (cached instanceof List) {
+            return (List<LiveGoodsVo>) cached;
+        }
+        com.fs.live.domain.LiveGoods query = new com.fs.live.domain.LiveGoods();
+        query.setLiveId(liveId);
+        List<LiveGoodsVo> list = liveGoodsMapper.selectProductListByLiveId(query);
+        if (list == null) {
+            list = Collections.emptyList();
+        } else {
+            // 只保留有效的:有商品名
+            list = list.stream()
+                    .filter(g -> g != null && StringUtils.isNotEmpty(g.getProductName()))
+                    .collect(Collectors.toList());
+        }
+        redisCache.setCacheObject(cacheKey, list, 30, TimeUnit.SECONDS);
+        return list;
+    }
+
+    private String resolveNicknameForRealOrder(LiveOrder order, LiveOrderTipConfig cfg) {
+        String nickname = null;
+        try {
+            if (order.getUserId() != null) {
+                FsUserScrm user = fsUserService.selectFsUserById(Long.valueOf(order.getUserId()));
+                if (user != null) {
+                    nickname = user.getNickname();
+                    if (StringUtils.isEmpty(nickname)) {
+                        nickname = user.getPhone();
+                    }
+                }
+            }
+        } catch (Exception ignore) {}
+
+        if (StringUtils.isEmpty(nickname)) {
+            // 用户信息缺失时,从昵称库随机(避免用户隐私信息泄露)
+            return pickNicknameFromLibrary(cfg);
+        }
+        return maskNickname(nickname, cfg);
+    }
+
+    private String pickNicknameFromLibrary(LiveOrderTipConfig cfg) {
+        List<String> lib = parseStringList(cfg == null ? null : cfg.getNicknameLibrary());
+        if (lib.isEmpty()) {
+            lib = DEFAULT_NICKNAMES;
+        }
+        return lib.get(ThreadLocalRandom.current().nextInt(lib.size()));
+    }
+
+    /** 脱敏:如 "张三" -> "张*"; "小明同学" -> "小***" ; 包含字母数字则保留首尾 */
+    private String maskNickname(String nickname, LiveOrderTipConfig cfg) {
+        if (cfg == null || cfg.getNicknameMask() == null || cfg.getNicknameMask() == 0) {
+            return nickname;
+        }
+        if (nickname == null) return "";
+        int len = nickname.codePointCount(0, nickname.length());
+        if (len <= 1) return nickname + "*";
+        int firstEnd = nickname.offsetByCodePoints(0, 1);
+        String head = nickname.substring(0, firstEnd);
+        int stars = Math.min(len - 1, 3);
+        StringBuilder sb = new StringBuilder(head);
+        for (int i = 0; i < stars; i++) {
+            sb.append('*');
+        }
+        return sb.toString();
+    }
+
+    private String humanizeTime(long ageSec) {
+        if (ageSec < 60) {
+            return ageSec + "秒";
+        }
+        long mins = ageSec / 60;
+        if (mins < 60) {
+            return mins + "分钟";
+        }
+        long hours = mins / 60;
+        return hours + "小时";
+    }
+
+    private long resolveOrderTs(LiveOrder order, long fallback) {
+        try {
+            if (order.getPayTime() != null) {
+                return order.getPayTime().atZone(java.time.ZoneId.systemDefault())
+                        .toInstant().toEpochMilli();
+            }
+            if (order.getCreateTime() != null) {
+                return order.getCreateTime().getTime();
+            }
+        } catch (Exception ignore) {}
+        return fallback;
+    }
+
+    private static boolean isEnabled(LiveOrderTipConfig cfg) {
+        return cfg != null && cfg.getEnabled() != null && cfg.getEnabled() == 1;
+    }
+
+    private static boolean isFakeEnabled(LiveOrderTipConfig cfg) {
+        return cfg != null && cfg.getFakeEnabled() != null && cfg.getFakeEnabled() == 1;
+    }
+
+    private static int safeInt(Integer v, int def) {
+        return v == null ? def : v;
+    }
+
+    private static List<Long> parseLongList(String raw) {
+        if (StringUtils.isEmpty(raw)) return Collections.emptyList();
+        List<Long> list = new ArrayList<>();
+        for (String part : raw.split("[,,\\s]+")) {
+            if (part == null || part.trim().isEmpty()) continue;
+            try {
+                list.add(Long.parseLong(part.trim()));
+            } catch (NumberFormatException ignore) {}
+        }
+        return list;
+    }
+
+    private static List<String> parseStringList(String raw) {
+        if (StringUtils.isEmpty(raw)) return Collections.emptyList();
+        List<String> list = new ArrayList<>();
+        for (String part : raw.split("[,,\\n\\r]+")) {
+            if (part == null) continue;
+            String p = part.trim();
+            if (!p.isEmpty()) {
+                list.add(p);
+            }
+        }
+        return list;
+    }
+
+}

+ 5 - 0
fs-service/src/main/java/com/fs/live/vo/LiveGoodsVo.java

@@ -28,4 +28,9 @@ public class LiveGoodsVo {
      * 仓库代码
      */
     private String warehouseCode;
+
+    /**
+     * 低库存提示阈值
+     */
+    private Integer showStockHint;
 }

+ 45 - 0
fs-service/src/main/java/com/fs/live/vo/LiveOrderTipVo.java

@@ -0,0 +1,45 @@
+package com.fs.live.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 直播下单动效提示消息 VO
+ *
+ * <p>通过 WebSocket 以 <code>cmd=orderTip</code> 推送,前端收到后以半透明气泡等形式展示。</p>
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class LiveOrderTipVo {
+
+    /** 直播间ID */
+    private Long liveId;
+
+    /** 用户昵称(可能已脱敏,如 "小*") */
+    private String nickname;
+
+    /** 用户头像(假订单时可为空) */
+    private String avatar;
+
+    /** 商品ID */
+    private Long goodsId;
+
+    /** 商品名称 */
+    private String productName;
+
+    /** 商品主图,便于前端展示 */
+    private String imgUrl;
+
+    /** 展示文案(已按模板渲染好,前端可直接展示) */
+    private String content;
+
+    /** 订单时间(毫秒),前端可用于自行计算相对时间 */
+    private Long orderTime;
+
+    /** 0 真实订单 1 假订单(假数据填充) */
+    private Integer fake;
+}

+ 7 - 0
fs-service/src/main/java/com/fs/qw/domain/QwCompany.java

@@ -100,6 +100,13 @@ public class QwCompany extends BaseEntity
 
     private String qwApiUrl;
 
+    /**
+     * 可选ipad:1-允许(不绑ipad走官方群发),0/null-必须绑ipad(默认)
+     * 销售端开启后,SOP(群发助手/课程模板)可不选员工,走 isOfficial=1 官方群发
+     */
+    @Excel(name = "可选ipad")
+    private Integer allowOfficial;
+
     /** 批量操作的ID数组 */
     private Long[] ids;
 

+ 4 - 5
fs-service/src/main/java/com/fs/qw/mapper/QwUserMapper.java

@@ -220,7 +220,7 @@ public interface QwUserMapper extends BaseMapper<QwUser>
             "            <if test=\"deptId != null \"> and qd.dept_id = #{deptId}</if>\n" +
             "            <if test=\"deptName != null \"> and qd.dept_name like concat('%', #{deptName}, '%') </if>\n" +
             "            <if test=\"status != null \"> and qu.status = #{status}</if>\n" +
-            "            <if test=\"type != null and sendType !=null and type==2 and (sendType==2 or sendType==4 or sendType==11) \"> and qu.app_key IS NOT NULL  </if>\n" +
+            "            <if test=\"type != null and sendType !=null and type==2 and (sendType==2 or sendType==4 or sendType==11) and (allowOfficial == null or allowOfficial != 1) \"> and qu.app_key IS NOT NULL  </if>\n" +
             "</script>"})
     List<QwUserVO> selectQwUserListVO(QwUserListParam qwUser);
 
@@ -302,10 +302,10 @@ public interface QwUserMapper extends BaseMapper<QwUser>
 //    @Select("select login_code_url from qw_user where app_key=#{key}")
     String selectQwUserByAppKeyToIP(String key);
 
-    @Select("select corp_id as dictValue,corp_name as dictLabel,corp_id,corp_name from qw_company where FIND_IN_SET(#{companyId},company_ids)")
+    @Select("select corp_id as dictValue,corp_name as dictLabel,corp_id,corp_name,allow_official from qw_company where FIND_IN_SET(#{companyId},company_ids)")
     List<QwOptionsVO> selectQwCompanyListOptionsVOByCompanyId(Long companyId);
 
-    @Select("select corp_id as dictValue,corp_name as dictLabel,corp_id,corp_name from qw_company where status=1")
+    @Select("select corp_id as dictValue,corp_name as dictLabel,corp_id,corp_name,allow_official from qw_company where status=1")
     List<QwOptionsVO> selectQwCompanyListOptionsVOAll();
 
     @Select("select  *  from qw_user where qw_hook_id=#{qwHookId} ")
@@ -324,7 +324,6 @@ public interface QwUserMapper extends BaseMapper<QwUser>
             "            <if test=\"corpId != null \"> and qu.corp_id = #{corpId}</if>\n" +
             "            <if test=\"qwUserId != null \"> and qu.qw_user_id  like concat( #{qwUserId}, '%') </if>\n" +
             "            <if test=\"status != null \"> and qu.status = #{status}</if>\n" +
-            "            <if test=\"type != null and sendType !=null and type==2 and sendType==2 \"> and qu.app_key IS NOT NULL  </if>\n" +
             "</script>"})
     List<QwUserVO> selectAllQwUserListVO(QwUserListParam qwUser);
 
@@ -341,7 +340,7 @@ public interface QwUserMapper extends BaseMapper<QwUser>
             "</script>")
     List<QwUserVO> selectQwUserVOByIds(Long[] ids);
 
-    @Select("select corp_id as dictValue,corp_name as dictLabel from qw_company")
+    @Select("select corp_id as dictValue,corp_name as dictLabel,allow_official from qw_company")
     List<QwOptionsVO> selectQwCompanyListAllOptionsVO();
 
     @Select("select qw_user_name from qw_user  WHERE qw_user_id=#{userId} and corp_id=#{corpId}")

+ 14 - 0
fs-service/src/main/java/com/fs/qw/param/ImFsUserRemarkTagsParam.java

@@ -0,0 +1,14 @@
+package com.fs.qw.param;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * IM 侧按 fs_user.user_id 批量查询企微备注、标签
+ */
+@Data
+public class ImFsUserRemarkTagsParam {
+    /** fs_user 表 user_id 集合 */
+    private List<String> userIds;
+}

+ 6 - 0
fs-service/src/main/java/com/fs/qw/param/QwUserListParam.java

@@ -54,6 +54,12 @@ public class QwUserListParam {
 
     private Integer sendType;
 
+    /**
+     * 企微主体「可选iPad」qw_company.allow_official;为 1 时选员工列表不强制 qu.app_key(走官方群发可不绑 iPad)
+     * 由后端根据 corpId 查询主体后写入,勿由前端随意传入
+     */
+    private Integer allowOfficial;
+
     /**
      * 企业微信李的部门
      */

+ 5 - 0
fs-service/src/main/java/com/fs/qw/service/IQwExternalContactService.java

@@ -101,6 +101,11 @@ public interface IQwExternalContactService extends IService<QwExternalContact> {
 
     List<QwExternalContactVOTime> selectQwExternalContactListVOByIds(List<Long> ids);
 
+    /**
+     * 按 fs_user_id 批量查询企微客户
+     */
+    List<QwExternalContact> selectExternalByFsUserIds(List<Long> userIds);
+
     R syncQwExternalContact(String corpId) throws IOException;
 
     R syncMyQwExternalContact(Long id) throws IOException;

+ 6 - 0
fs-service/src/main/java/com/fs/qw/service/IQwTagService.java

@@ -8,6 +8,7 @@ import com.fs.qw.param.newparam.ContactTagListParam;
 import com.fs.qw.vo.QwTagGroupListVO;
 import com.fs.qw.vo.QwTagVO;
 
+import java.util.Map;
 import java.util.List;
 
 /**
@@ -71,5 +72,10 @@ public interface IQwTagService
 
     public List<String> selectQwTagListByTagIds(QwTagSearchParam param);
 
+    /**
+     * 按标签ID批量查询标签名映射
+     */
+    Map<String, String> selectTagNameMapByTagIds(List<String> tagIds);
+
     List<QwTagVO> getTagListByUserId(ContactTagListParam param);
 }

+ 55 - 47
fs-service/src/main/java/com/fs/qw/service/impl/QwExternalContactServiceImpl.java

@@ -872,6 +872,14 @@ public class QwExternalContactServiceImpl extends ServiceImpl<QwExternalContactM
         return qwExternalContactMapper.selectQwExternalContactListVOByIds(ids);
     }
 
+    @Override
+    public List<QwExternalContact> selectExternalByFsUserIds(List<Long> userIds) {
+        if (userIds == null || userIds.isEmpty()) {
+            return new ArrayList<>();
+        }
+        return qwExternalContactMapper.selectExternalByFsUserIds(userIds);
+    }
+
     @Override
     public R syncQwExternalContact(String corpId) throws IOException {
 
@@ -2302,54 +2310,54 @@ public class QwExternalContactServiceImpl extends ServiceImpl<QwExternalContactM
         QwExternalContact contact=qwExternalContact;
         QwUser qwUser = qwUserMapper.selectQwUserByCorpIdAndUserId(corpId, userID);
 
-        if (state != null && state != "") {
-            String s = "way:" + corpId + ":";
-            if (state.contains(s)) {
-                if (welcomeCode != null && welcomeCode != "") {
-                    String substring = state.substring(state.indexOf(s) + s.length());
-                    QwContactWay qwContactWay = qwContactWayMapper.selectQwContactWayById(Long.parseLong(substring));
-                    logger.info("qwContactWay:"+qwContactWay);
-                    if (qwContactWay != null) {
-                        isWay = true;
-                        wayId = qwContactWay;
-                        if (qwContactWay.getIsWelcome() != null && qwContactWay.getIsWelcome() == 1) {
-                            Boolean isClose = true;
-                            if (wayId.getIsSpanWelcome() == 1) {
-                                ExternalContact externalContact = externalContactResult.getExternal_contact();
-                                String name = externalContact.getName();
-                                String closeWelcomeWord = wayId.getCloseWelcomeWord();
-                                if (closeWelcomeWord != null && closeWelcomeWord.length() > 0) {
-                                    List<String> strings = JSON.parseArray(closeWelcomeWord, String.class);
-                                    for (String string : strings) {
-                                        if (name.contains(string)) {
-                                            isClose = false;
-                                            break;
-                                        }
-                                    }
-                                }
-                            }
-                            if (qwContactWay.getIsWelcome() == 1 && isClose) {
-                                isSend = qwContactWayService.sendWelcomeMsg(qwContactWay, corpId, welcomeCode,qwUser,contact.getId());
-                            }
-                        }
-                        if (qwContactWay.getUserType() == 1 && qwContactWay.getIsUserLimit() == 1) {
-                            QwContactWayUser qwContactWayUser = qwContactWayUserMapper.selectQwContactWayUserByUserIdAndCompanyId(userID, corpId);
-                            if (qwContactWayUser != null) {
-                                qwContactWayUser.setDayCount(qwContactWayUser.getDayCount() - 1);
-                                qwContactWayUserMapper.updateQwContactWayUser(qwContactWayUser);
-                                if (qwContactWayUser.getDayCount() <= 0) {
-                                    //超过限制
-                                    qwContactWayService.updateQwContactWayBYLimit(qwContactWayUser.getWayId());
-                                }
-
-                            }
-                        }
-                    }
-                }
-            }
-        }
+//        if (state != null && state != "") {
+//            String s = "way:" + corpId + ":";
+//            if (state.contains(s)) {
+//                if (welcomeCode != null && welcomeCode != "") {
+//                    String substring = state.substring(state.indexOf(s) + s.length());
+//                    QwContactWay qwContactWay = qwContactWayMapper.selectQwContactWayById(Long.parseLong(substring));
+//                    logger.info("qwContactWay:"+qwContactWay);
+//                    if (qwContactWay != null) {
+//                        isWay = true;
+//                        wayId = qwContactWay;
+//                        if (qwContactWay.getIsWelcome() != null && qwContactWay.getIsWelcome() == 1) {
+//                            Boolean isClose = true;
+//                            if (wayId.getIsSpanWelcome() == 1) {
+//                                ExternalContact externalContact = externalContactResult.getExternal_contact();
+//                                String name = externalContact.getName();
+//                                String closeWelcomeWord = wayId.getCloseWelcomeWord();
+//                                if (closeWelcomeWord != null && closeWelcomeWord.length() > 0) {
+//                                    List<String> strings = JSON.parseArray(closeWelcomeWord, String.class);
+//                                    for (String string : strings) {
+//                                        if (name.contains(string)) {
+//                                            isClose = false;
+//                                            break;
+//                                        }
+//                                    }
+//                                }
+//                            }
+//                            if (qwContactWay.getIsWelcome() == 1 && isClose) {
+//                                isSend = qwContactWayService.sendWelcomeMsg(qwContactWay, corpId, welcomeCode,qwUser,contact.getId());
+//                            }
+//                        }
+//                        if (qwContactWay.getUserType() == 1 && qwContactWay.getIsUserLimit() == 1) {
+//                            QwContactWayUser qwContactWayUser = qwContactWayUserMapper.selectQwContactWayUserByUserIdAndCompanyId(userID, corpId);
+//                            if (qwContactWayUser != null) {
+//                                qwContactWayUser.setDayCount(qwContactWayUser.getDayCount() - 1);
+//                                qwContactWayUserMapper.updateQwContactWayUser(qwContactWayUser);
+//                                if (qwContactWayUser.getDayCount() <= 0) {
+//                                    //超过限制
+//                                    qwContactWayService.updateQwContactWayBYLimit(qwContactWayUser.getWayId());
+//                                }
+//
+//                            }
+//                        }
+//                    }
+//                }
+//            }
+//        }
 
-        if (isSend && welcomeCode != null && welcomeCode != "") {
+        if (welcomeCode != null && welcomeCode != "") {
             if (qwUser != null) {
                 // 查询成员的欢迎语以及欢迎图片
                 QwFriendWelcomeVO qwFriendWelcomeVO = qwFriendWelcomeMapper.selectQwFriendWelcomeByUserIdVO(qwUser.getId(), corpId);

+ 14 - 0
fs-service/src/main/java/com/fs/qw/service/impl/QwTagServiceImpl.java

@@ -200,6 +200,20 @@ public class QwTagServiceImpl implements IQwTagService
         return qwTagMapper.selectQwTagListNameByTagIds(param);
     }
 
+    @Override
+    public Map<String, String> selectTagNameMapByTagIds(List<String> tagIds) {
+        if (tagIds == null || tagIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        List<QwTag> tags = qwTagMapper.selectQwTagListByTagIdsNew(tagIds);
+        if (tags == null || tags.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        return tags.stream()
+                .filter(t -> t.getTagId() != null)
+                .collect(Collectors.toMap(QwTag::getTagId, QwTag::getName, (a, b) -> a));
+    }
+
     @Override
     public List<QwTagVO> getTagListByUserId(ContactTagListParam param) {
         String[] keywords = new String[0];

+ 18 - 0
fs-service/src/main/java/com/fs/qw/vo/ImFsUserRemarkTagsVO.java

@@ -0,0 +1,18 @@
+package com.fs.qw.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * IM 侧企微外部联系人备注与标签(与列表接口解析规则一致)
+ */
+@Data
+public class ImFsUserRemarkTagsVO {
+    /** fs_user.user_id */
+    private Long userId;
+    /** 企微客户备注 */
+    private String remark;
+    /** 标签名称列表 */
+    private List<String> tagNames;
+}

+ 4 - 0
fs-service/src/main/java/com/fs/qw/vo/QwOptionsVO.java

@@ -8,4 +8,8 @@ public class QwOptionsVO {
     String dictLabel;
     String CorpId;
     String corpName;
+    /**
+     * 企微主体「可选ipad」:1=允许不选员工走官方群发;0/null=须选员工(iPad 链路)
+     */
+    private Integer allowOfficial;
 }

+ 26 - 24
fs-service/src/main/java/com/fs/qwApi/service/impl/QwApiServiceImpl.java

@@ -213,7 +213,9 @@ public class QwApiServiceImpl implements QwApiService {
         JSONObject groupList=new JSONObject();
         groupList.put("group_list",msgTemplate.getTagFilterGroupList());
         jsonObject.put("tag_filter",groupList);
-        jsonObject.put("sender",msgTemplate.getSender());
+        if (StringUtils.isNotEmpty(msgTemplate.getSender())) {
+            jsonObject.put("sender", msgTemplate.getSender());
+        }
         jsonObject.put("allow_select",msgTemplate.getAllowSelect());
 
         JSONObject Content=new JSONObject();
@@ -1600,28 +1602,28 @@ public class QwApiServiceImpl implements QwApiService {
     }
 
 
-//    public String sendPost(String url,Object param,String corpId){
-//
-//        QwCompany qwCompany = iQwCompanyService.selectQwCompanyByCorpId(corpId);
-//
-//        String appSecret = qwCompany.getOpenSecret();
-//
-//        HttpClient httpClient = HttpClients.createDefault();
-//        try {
-//            URIBuilder builder = new URIBuilder(url);
-//
-//            builder.setParameter("access_token", getToken(corpId,appSecret));
-//            URI uri = builder.build();
-//            HttpPost httpPost  = new HttpPost(uri);
-//            httpPost.setEntity( new StringEntity(JSON.toJSONString(param),StandardCharsets.UTF_8));
-//            HttpResponse response = httpClient.execute(httpPost);
-//            String reJson = EntityUtils.toString(response.getEntity());
-//            return reJson;
-//        } catch (Exception e) {
-//            e.printStackTrace();
-//        }
-//        return null;
-//    }
+    public String sendPost(String url,Object param,String corpId){
+
+        QwCompany qwCompany = iQwCompanyService.selectQwCompanyByCorpId(corpId);
+
+        String appSecret = qwCompany.getOpenSecret();
+
+        HttpClient httpClient = HttpClients.createDefault();
+        try {
+            URIBuilder builder = new URIBuilder(url);
+
+            builder.setParameter("access_token", getToken(corpId,appSecret));
+            URI uri = builder.build();
+            HttpPost httpPost  = new HttpPost(uri);
+            httpPost.setEntity( new StringEntity(JSON.toJSONString(param),StandardCharsets.UTF_8));
+            HttpResponse response = httpClient.execute(httpPost);
+            String reJson = EntityUtils.toString(response.getEntity());
+            return reJson;
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+        return null;
+    }
 
     public String sendBookPost(String url,QwOpenidByUserParams body,String corpId){
         QwCompany qwCompany = iQwCompanyService.selectQwCompanyByCorpId(corpId);
@@ -1913,7 +1915,7 @@ public class QwApiServiceImpl implements QwApiService {
     @Override
     public QwResult sendWelcomeMsg(SendWelcomeMsgParam param, String corpId) {
 
-        String json = sendPost(QwApiConfig.sendWelcomeMsg,param,corpId, false);
+        String json = sendPost(QwApiConfig.sendWelcomeMsg,param,corpId);
         QwResult qwResult = JSON.parseObject(json, QwResult.class);
         return qwResult;
     }

+ 4 - 1
fs-service/src/main/java/com/fs/sop/mapper/QwSopMapper.java

@@ -254,8 +254,11 @@ public interface QwSopMapper extends BaseMapper<QwSop> {
             "qw_external_contact qec " +
             "\tleft JOIN qw_user qu on qu.id=qec.qw_user_id\n" +
             "\tLEFT JOIN company_user cu on cu.user_id=qu.company_user_id " +
-            "WHERE qec.status=0 and qec.corp_id =#{map.cropId} and qec.qw_user_id  in " +
+            "WHERE qec.status=0 and qec.corp_id =#{map.cropId} " +
+            "<if test='map.userIdsSelectList != null and map.userIdsSelectList.size > 0'>" +
+            " and qec.qw_user_id  in " +
             " <foreach collection='map.userIdsSelectList'  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach> " +
+            "</if> " +
             "<if test ='map.filterType==2 and map.tagsIdsSelectList!=null'> " +
             "  and ( \n" +
             "    <foreach collection='map.tagsIdsSelectList' item='item' index='index' separator=' or '> \n" +

+ 10 - 0
fs-service/src/main/java/com/fs/sop/service/IQwSopService.java

@@ -1,6 +1,8 @@
 package com.fs.sop.service;
 
+import com.fs.common.annotation.DataSource;
 import com.fs.common.core.domain.R;
+import com.fs.common.enums.DataSourceType;
 import com.fs.course.vo.FsCourseWatchLogStatisticsListVO;
 import com.fs.qw.domain.QwSopUpdateStatus;
 import com.fs.qw.param.CourseQuizRedEnvelopeStatsParam;
@@ -51,6 +53,14 @@ public interface IQwSopService
      */
     public int insertQwSop(QwSop qwSop);
 
+    /**
+     * 保存/修改前置校验:须配置 qw_sop.qw_user_ids(使用员工)
+     *
+     * @param qwSop 企微sop
+     * @return null=通过;否则返回错误信息
+     */
+    public String validateBeforeSave(QwSop qwSop);
+
     /**
      * 修改企微sop
      *

+ 24 - 5
fs-service/src/main/java/com/fs/sop/service/impl/QwSopLogsServiceImpl.java

@@ -1987,11 +1987,28 @@ public class QwSopLogsServiceImpl extends ServiceImpl<QwSopLogsMapper, QwSopLogs
     private void handleGroup(List<QwSopLogs> logsGroup, Queue<QwSopLogs> updateQueue) {
 
         try {
-            String firstKey = logsGroup.get(0).getCorpId() + "|" + logsGroup.get(0).getQwUserid();
-            String[] keyParts = firstKey.split("\\|");
-            String corpId = keyParts[0].trim();
-            String qwUserid = keyParts[1].trim();
-
+            QwSopLogs first = logsGroup.get(0);
+            String corpId = first.getCorpId() == null ? "" : first.getCorpId().trim();
+            if (StringUtils.isEmpty(corpId)) {
+                logger.error("corpId 为空,无法创建企微群发");
+                logsGroup.forEach(log -> {
+                    log.setSendStatus(5L);
+                    log.setRemark("corpId 为空");
+                    updateQueue.add(log);
+                });
+                return;
+            }
+            String rawSender = first.getQwUserid();
+            String qwUserid = StringUtils.isEmpty(rawSender) ? null : rawSender.trim();
+            if (StringUtils.isEmpty(qwUserid) || "null".equalsIgnoreCase(qwUserid)) {
+                logger.error("发送员工未配置,corpId:{}", corpId);
+                logsGroup.forEach(log -> {
+                    log.setSendStatus(5L);
+                    log.setRemark("未配置发送员工");
+                    updateQueue.add(log);
+                });
+                return;
+            }
             QwUser qwUser = qwExternalContactService.getQwUserByRedis(corpId, qwUserid);
             if (qwUser == null || qwUser.getIsDel() != 0) {
                 logger.error("员工信息无效-不存在或被删除,corpId:{},userId:{}", corpId, qwUserid);
@@ -2003,6 +2020,8 @@ public class QwSopLogsServiceImpl extends ServiceImpl<QwSopLogsMapper, QwSopLogs
                 return;
             }
 
+            String firstKey = corpId + "|" + qwUserid;
+
             // 提取内容与目标客户列表
             String contentJson = logsGroup.get(0).getContentJson();
             QwSopTempSetting.Content content = JSON.parseObject(contentJson, QwSopTempSetting.Content.class);

+ 18 - 0
fs-service/src/main/java/com/fs/sop/service/impl/QwSopServiceImpl.java

@@ -17,9 +17,11 @@ import com.fs.course.service.IFsCourseWatchLogService;
 import com.fs.course.vo.FsCourseWatchLogStatisticsListVO;
 import com.fs.his.mapper.FsUserMapper;
 import com.fs.his.service.IFsUserService;
+import com.fs.qw.domain.QwCompany;
 import com.fs.qw.domain.QwExternalContact;
 import com.fs.qw.domain.QwSopUpdateStatus;
 import com.fs.qw.domain.QwUser;
+import com.fs.qw.mapper.QwCompanyMapper;
 import com.fs.qw.mapper.QwUserMapper;
 import com.fs.qw.param.*;
 import com.fs.qw.result.QwFilterSopCustomersResult;
@@ -142,6 +144,9 @@ public class QwSopServiceImpl implements IQwSopService
 
     @Autowired
     private FsUserMapper fsUserMapper;
+
+    @Autowired
+    private QwCompanyMapper qwCompanyMapper;
     /**
      * 查询企微sop
      *
@@ -154,6 +159,19 @@ public class QwSopServiceImpl implements IQwSopService
         return qwSopMapper.selectQwSopById(id);
     }
 
+    /**
+     */
+    @Override
+    public String validateBeforeSave(QwSop qwSop) {
+        if (qwSop == null) {
+            return null;
+        }
+        if (StringUtils.isEmpty(qwSop.getQwUserIds())) {
+            return "请选择使用员工";
+        }
+        return null;
+    }
+
 
 
     /**

+ 5 - 0
fs-service/src/main/java/com/fs/sop/vo/SopUserLogsVo.java

@@ -49,4 +49,9 @@ public class SopUserLogsVo  {
      */
     private Integer isSampSend;
 
+    /**
+     * 该 SOP 配置的企微员工集合(qw_sop.qw_user_ids),保存任务时必选,未配置则不会生成待发送记录
+     */
+    private String qwUserIds;
+
 }

+ 1 - 1
fs-service/src/main/resources/mapper/live/LiveGoodsMapper.xml

@@ -170,7 +170,7 @@
 
     <select id="selectProductListByLiveId" parameterType="LiveGoods" resultType="com.fs.live.vo.LiveGoodsVo">
 
-        select lg.goods_id,sp.image as img_url,sp.product_name,sp.price,sp.stock,lg.sales,lg.status,sp.product_id,sp.ot_price,case when lg.is_show = 1 then true else false end as is_show
+        select lg.goods_id,sp.image as img_url,sp.product_name,sp.price,lg.stock,lg.sales,lg.status,sp.product_id,sp.ot_price,case when lg.is_show = 1 then true else false end as is_show
         <if test="companyUserId != null "> ,if(uf.favorite_id is not null, true, false) is_favorite </if>
 
         from live_goods lg

+ 74 - 0
fs-service/src/main/resources/mapper/live/LiveOrderTipConfigMapper.xml

@@ -0,0 +1,74 @@
+<?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.LiveOrderTipConfigMapper">
+
+    <resultMap type="LiveOrderTipConfig" id="LiveOrderTipConfigResult">
+        <result property="configId"           column="config_id"/>
+        <result property="enabled"            column="enabled"/>
+        <result property="rateMinPerMinute"   column="rate_min_per_minute"/>
+        <result property="rateMaxPerMinute"   column="rate_max_per_minute"/>
+        <result property="windowSec"          column="window_sec"/>
+        <result property="fakeEnabled"        column="fake_enabled"/>
+        <result property="fakeMinIntervalSec" column="fake_min_interval_sec"/>
+        <result property="goodsScope"         column="goods_scope"/>
+        <result property="customGoodsIds"     column="custom_goods_ids"/>
+        <result property="nicknameLibrary"    column="nickname_library"/>
+        <result property="nicknameMask"       column="nickname_mask"/>
+        <result property="templateJustNow"    column="template_just_now"/>
+        <result property="templateAgo"        column="template_ago"/>
+        <result property="justNowSeconds"     column="just_now_seconds"/>
+        <result property="remark"             column="remark"/>
+        <result property="updateBy"           column="update_by"/>
+        <result property="updateTime"         column="update_time"/>
+    </resultMap>
+
+    <sql id="selectVo">
+        select config_id, enabled, rate_min_per_minute, rate_max_per_minute, window_sec,
+               fake_enabled, fake_min_interval_sec, goods_scope, custom_goods_ids,
+               nickname_library, nickname_mask, template_just_now, template_ago,
+               just_now_seconds, remark, update_by, update_time
+        from live_order_tip_config
+    </sql>
+
+    <select id="selectByConfigId" resultMap="LiveOrderTipConfigResult">
+        <include refid="selectVo"/>
+        where config_id = #{configId}
+    </select>
+
+    <insert id="insertLiveOrderTipConfig" parameterType="LiveOrderTipConfig">
+        insert into live_order_tip_config (
+            config_id, enabled, rate_min_per_minute, rate_max_per_minute, window_sec,
+            fake_enabled, fake_min_interval_sec, goods_scope, custom_goods_ids,
+            nickname_library, nickname_mask, template_just_now, template_ago,
+            just_now_seconds, remark, update_by, update_time
+        ) values (
+            #{configId}, #{enabled}, #{rateMinPerMinute}, #{rateMaxPerMinute}, #{windowSec},
+            #{fakeEnabled}, #{fakeMinIntervalSec}, #{goodsScope}, #{customGoodsIds},
+            #{nicknameLibrary}, #{nicknameMask}, #{templateJustNow}, #{templateAgo},
+            #{justNowSeconds}, #{remark}, #{updateBy}, #{updateTime}
+        )
+    </insert>
+
+    <update id="updateLiveOrderTipConfig" parameterType="LiveOrderTipConfig">
+        update live_order_tip_config
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="enabled != null">enabled = #{enabled},</if>
+            <if test="rateMinPerMinute != null">rate_min_per_minute = #{rateMinPerMinute},</if>
+            <if test="rateMaxPerMinute != null">rate_max_per_minute = #{rateMaxPerMinute},</if>
+            <if test="windowSec != null">window_sec = #{windowSec},</if>
+            <if test="fakeEnabled != null">fake_enabled = #{fakeEnabled},</if>
+            <if test="fakeMinIntervalSec != null">fake_min_interval_sec = #{fakeMinIntervalSec},</if>
+            <if test="goodsScope != null">goods_scope = #{goodsScope},</if>
+            <if test="customGoodsIds != null">custom_goods_ids = #{customGoodsIds},</if>
+            <if test="nicknameLibrary != null">nickname_library = #{nicknameLibrary},</if>
+            <if test="nicknameMask != null">nickname_mask = #{nicknameMask},</if>
+            <if test="templateJustNow != null">template_just_now = #{templateJustNow},</if>
+            <if test="templateAgo != null">template_ago = #{templateAgo},</if>
+            <if test="justNowSeconds != null">just_now_seconds = #{justNowSeconds},</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>

+ 9 - 2
fs-service/src/main/resources/mapper/qw/QwCompanyMapper.xml

@@ -33,10 +33,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="shareAppId"    column="share_app_id"    />
         <result property="shareAgentId"    column="share_agent_id"    />
         <result property="shareSchema"    column="share_schema"    />
+        <result property="allowOfficial"    column="allow_official"    />
     </resultMap>
 
     <sql id="selectQwCompanyVo">
-        select id, corp_id, corp_name, open_secret, open_corp_id, server_agent_id, server_book_corp_id, server_book_secret, token, encoding_aes_key, provider_secret, realm_name_url, notify_url, chat_toolbar, chat_toolbar_oauth, company_ids, status, create_time, update_time, create_by,is_buy,mini_app_id,company_server_num,share_app_id,share_agent_id,share_schema from qw_company
+        select id, corp_id, corp_name, open_secret, open_corp_id, server_agent_id, server_book_corp_id, server_book_secret, token, encoding_aes_key, provider_secret, realm_name_url, notify_url, chat_toolbar, chat_toolbar_oauth, company_ids, status, create_time, update_time, create_by,is_buy,mini_app_id,company_server_num,share_app_id,share_agent_id,share_schema,allow_official from qw_company
     </sql>
 
     <select id="selectQwCompanyList" parameterType="QwCompany" resultMap="QwCompanyResult">
@@ -64,6 +65,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="shareAppId != null  and shareAppId != ''"> and share_app_id = #{shareAppId}</if>
             <if test="shareAgentId != null  and shareAgentId != ''"> and share_agent_id = #{shareAgentId}</if>
             <if test="shareSchema != null  and shareSchema != ''"> and share_schema = #{shareSchema}</if>
+            <if test="allowOfficial != null"> and allow_official = #{allowOfficial}</if>
         </where>
     </select>
 
@@ -95,6 +97,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             qc.share_app_id,
             qc.share_agent_id,
             qc.share_schema,
+            qc.allow_official,
             sc.`name` AS mini_name,
             qc.qw_api_url
         FROM
@@ -124,6 +127,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="shareAppId != null  and shareAppId != ''"> and qc.share_app_id = #{shareAppId}</if>
             <if test="shareAgentId != null  and shareAgentId != ''"> and qc.share_agent_id = #{shareAgentId}</if>
             <if test="shareSchema != null  and shareSchema != ''"> and qc.share_schema = #{shareSchema}</if>
+            <if test="allowOfficial != null"> and qc.allow_official = #{allowOfficial}</if>
             <if test="miniName != null  and miniName != ''">and sc.`name` like concat('%', #{miniName}, '%') </if>
         </where>
     </select>
@@ -164,11 +168,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="isBuy != null">is_buy,</if>
             <if test="miniAppId != null">mini_app_id,</if>
             <if test="companyServerNum != null">company_server_num,</if>
-            <if test="createUserId != null != null">create_user_id,</if>
+            <if test="createUserId != null">create_user_id,</if>
             <if test="createDeptId != null">create_dept_id,</if>
             <if test="shareAppId != null">share_app_id,</if>
             <if test="shareAgentId != null">share_agent_id,</if>
             <if test="shareSchema != null">share_schema,</if>
+            <if test="allowOfficial != null">allow_official,</if>
          </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="corpId != null">#{corpId},</if>
@@ -198,6 +203,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="shareAppId != null">#{shareAppId},</if>
             <if test="shareAgentId != null">#{shareAgentId},</if>
             <if test="shareSchema != null">#{shareSchema},</if>
+            <if test="allowOfficial != null">#{allowOfficial},</if>
          </trim>
     </insert>
 
@@ -229,6 +235,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="shareAppId != null">share_app_id = #{shareAppId},</if>
             <if test="shareAgentId != null">share_agent_id = #{shareAgentId},</if>
             <if test="shareSchema != null">share_schema = #{shareSchema},</if>
+            <if test="allowOfficial != null">allow_official = #{allowOfficial},</if>
         </trim>
         where id = #{id}
     </update>

+ 2 - 1
fs-service/src/main/resources/mapper/sop/SopUserLogsMapper.xml

@@ -203,7 +203,8 @@
                b.is_register,
                b.chat_id,
                b.filter_mode,
-               b.is_samp_send
+               b.is_samp_send,
+               b.qw_user_ids
         from sop_user_logs a
         inner join qw_sop b on a.sop_id = b.id
         inner join qw_sop_temp c on b.temp_id = c.id

+ 24 - 15
fs-user-app/src/main/java/com/fs/app/controller/AppLoginController.java

@@ -452,10 +452,7 @@ public class AppLoginController extends AppBaseController{
         if (CollectionUtil.isEmpty(user)){
             user = userService.selectFsUserListByPhone(encryptPhoneOldKey(phone));
         }
-        if (CollectionUtil.isEmpty(user)){
-            return R.error("此电话号码未绑定用户");
-        }
-        if (user.size()>1){
+        if (!CollectionUtil.isEmpty(user) && user.size()>1){
             //如果出现了一个手机号多个用户的情况,找出登陆过app的那个用户
             user.removeIf(fsUser -> StringUtils.isEmpty(fsUser.getHistoryApp()));
         }
@@ -469,9 +466,15 @@ public class AppLoginController extends AppBaseController{
                 return R.error("验证码错误");
             }
         }
-        updateExistingUserJpushId(user.get(0), map.get("jpushId"));
-        userNewTaskService.performFirstLoginApp(user.get(0).getUserId());
-        return generateTokenAndReturn(user.get(0));
+        FsUser currentUser;
+        if (CollectionUtil.isEmpty(user)) {
+            currentUser = createNewUserBySmsLogin(phone, map);
+        } else {
+            currentUser = user.get(0);
+            updateExistingUserJpushId(currentUser, map.get("jpushId"));
+        }
+        userNewTaskService.performFirstLoginApp(currentUser.getUserId());
+        return generateTokenAndReturn(currentUser);
     }
 
     @PostMapping("/resetPassword")
@@ -783,14 +786,6 @@ public class AppLoginController extends AppBaseController{
     @PostMapping("/sendCode")
     public R sendCode(@RequestBody Map<String, String> body){
         String phone = body.get("phone");
-        String encryptPhone = encryptPhone(phone);
-        List<FsUser> user = userService.selectFsUserListByPhone(encryptPhone);
-        if(CollectionUtil.isEmpty(user)){
-            user = userService.selectFsUserListByPhone(encryptPhoneOldKey(phone));
-        }
-        if (CollectionUtil.isEmpty(user)){
-            return R.error("此电话号码未绑定用户");
-        }
 
         // 验证码 key(3分钟有效)
         String smsCodeKey = "sms:code:" + phone;
@@ -939,6 +934,20 @@ public class AppLoginController extends AppBaseController{
         return newUser;
     }
 
+    private FsUser createNewUserBySmsLogin(String phone, Map<String, String> map) {
+        FsUser newUser = new FsUser();
+        newUser.setPhone(phone);
+        newUser.setJpushId(map.get("jpushId"));
+        newUser.setSource(map.get("source"));
+        newUser.setLoginDevice(map.get("loginDevice"));
+        newUser.setNickName("app用户" + phone.substring(phone.length() - 4));
+        newUser.setStatus(1);
+        newUser.setAvatar("https://cos.his.cdwjyyh.com/fs/20240926/420728ee06e54575ba82665dedb4756b.png");
+        newUser.setCreateTime(new Date());
+        userService.insertFsUser(newUser);
+        return newUser;
+    }
+
     private FsUser findUserByPhone(String phone) {
         // 先根据加密手机号查询用户
         String jiami = (encryptPhone(phone));

+ 191 - 0
fs-user-app/src/main/java/com/fs/app/controller/ImQwExternalContactController.java

@@ -0,0 +1,191 @@
+package com.fs.app.controller;
+
+import com.fs.app.annotation.Login;
+import com.fs.common.core.domain.R;
+import com.fs.qw.domain.QwExternalContact;
+import com.fs.qw.param.ImFsUserRemarkTagsParam;
+import com.fs.qw.service.IQwExternalContactService;
+import com.fs.qw.service.IQwTagService;
+import com.fs.qw.vo.ImFsUserRemarkTagsVO;
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.collections.CollectionUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * IM:按 fs_user 批量查企微外部联系人备注、标签(
+ */
+@Api("IM-企微客户")
+@Slf4j
+@RestController
+@RequestMapping("/app/im/qwExternalContact")
+public class ImQwExternalContactController extends AppBaseController {
+    private static final Gson GSON = new Gson();
+    private static final Pattern ID_PATTERN = Pattern.compile("^[Uu]?(\\d+)$");
+
+    @Autowired
+    private IQwExternalContactService qwExternalContactService;
+    @Autowired
+    private IQwTagService iQwTagService;
+    @ApiOperation("批量查询企微备注与标签")
+    @Login
+    @PostMapping("/remarkTagsByFsUserIds")
+    public R remarkTagsByFsUserIds(@RequestBody ImFsUserRemarkTagsParam param) {
+        if (param == null || CollectionUtils.isEmpty(param.getUserIds())) {
+            return R.ok().put("data", Collections.emptyList());
+        }
+        List<Long> requested = normalizeFsUserIds(param.getUserIds());
+        if (CollectionUtils.isEmpty(requested)) {
+            return R.ok().put("data", Collections.emptyList());
+        }
+
+        List<QwExternalContact> contacts = qwExternalContactService.selectExternalByFsUserIds(requested);
+        Map<Long, QwExternalContact> bestByFsUser = pickBestExternalContactByFsUser(contacts);
+        Map<String, List<String>> parsedTagIdCache = new HashMap<>();
+        Map<String, String> tagNameMap = buildTagNameMap(bestByFsUser.values(), parsedTagIdCache);
+
+        List<ImFsUserRemarkTagsVO> rows = new ArrayList<>(requested.size());
+        for (Long uid : requested) {
+            ImFsUserRemarkTagsVO row = new ImFsUserRemarkTagsVO();
+            row.setUserId(uid);
+            QwExternalContact c = bestByFsUser.get(uid);
+            if (c != null) {
+                row.setRemark(c.getRemark());
+                row.setTagNames(resolveTagNamesLikeList(c.getTagIds(), parsedTagIdCache, tagNameMap));
+            } else {
+                row.setTagNames(Collections.emptyList());
+            }
+            rows.add(row);
+        }
+        return R.ok().put("data", rows);
+    }
+
+    private static Map<Long, QwExternalContact> pickBestExternalContactByFsUser(List<QwExternalContact> contacts) {
+        Map<Long, QwExternalContact> out = new HashMap<>();
+        if (CollectionUtils.isEmpty(contacts)) {
+            return out;
+        }
+        for (QwExternalContact current : contacts) {
+            if (current == null || current.getFsUserId() == null) {
+                continue;
+            }
+            QwExternalContact best = out.get(current.getFsUserId());
+            if (best == null || isBetter(current, best)) {
+                out.put(current.getFsUserId(), current);
+            }
+        }
+        return out;
+    }
+
+    private static boolean isBetter(QwExternalContact current, QwExternalContact best) {
+        boolean currentActive = Integer.valueOf(0).equals(current.getStatus());
+        boolean bestActive = Integer.valueOf(0).equals(best.getStatus());
+        if (currentActive != bestActive) {
+            return currentActive;
+        }
+        Long currentId = current.getId();
+        Long bestId = best.getId();
+        if (currentId == null) {
+            return false;
+        }
+        if (bestId == null) {
+            return true;
+        }
+        return currentId > bestId;
+    }
+
+    private Map<String, String> buildTagNameMap(Iterable<QwExternalContact> contacts, Map<String, List<String>> parsedTagIdCache) {
+        LinkedHashSet<String> allTagIds = new LinkedHashSet<>();
+        for (QwExternalContact contact : contacts) {
+            allTagIds.addAll(parseTagIds(contact.getTagIds(), parsedTagIdCache));
+        }
+        if (allTagIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        return iQwTagService.selectTagNameMapByTagIds(new ArrayList<>(allTagIds));
+    }
+
+    private List<String> resolveTagNamesLikeList(String tagIds, Map<String, List<String>> parsedTagIdCache, Map<String, String> tagNameMap) {
+        List<String> ids = parseTagIds(tagIds, parsedTagIdCache);
+        if (CollectionUtils.isEmpty(ids)) {
+            return Collections.emptyList();
+        }
+        List<String> names = new ArrayList<>();
+        for (String id : ids) {
+            String name = tagNameMap.get(id);
+            if (name != null) {
+                names.add(name);
+            }
+        }
+        return names;
+    }
+
+    private List<String> parseTagIds(String tagIds, Map<String, List<String>> parsedTagIdCache) {
+        if (tagIds == null || Objects.equals(tagIds, "[]")) {
+            return Collections.emptyList();
+        }
+        if (parsedTagIdCache.containsKey(tagIds)) {
+            return parsedTagIdCache.get(tagIds);
+        }
+        List<String> ids;
+        try {
+            ids = GSON.fromJson(
+                    tagIds,
+                    new TypeToken<List<String>>() {
+                    }.getType());
+        } catch (Exception ex) {
+            log.warn("IM标签解析失败, tagIds={}", tagIds, ex);
+            parsedTagIdCache.put(tagIds, Collections.emptyList());
+            return Collections.emptyList();
+        }
+        if (CollectionUtils.isEmpty(ids)) {
+            parsedTagIdCache.put(tagIds, Collections.emptyList());
+            return Collections.emptyList();
+        }
+        parsedTagIdCache.put(tagIds, ids);
+        return ids;
+    }
+
+    private List<Long> normalizeFsUserIds(List<String> rawUserIds) {
+        List<Long> normalized = new ArrayList<>();
+        for (String raw : rawUserIds) {
+            if (raw == null) {
+                continue;
+            }
+            String trimmed = raw.trim();
+            if (trimmed.isEmpty()) {
+                continue;
+            }
+            Matcher matcher = ID_PATTERN.matcher(trimmed);
+            if (!matcher.matches()) {
+                log.warn("IM userId格式不支持, userId={}", raw);
+                continue;
+            }
+            try {
+                normalized.add(Long.parseLong(matcher.group(1)));
+            } catch (NumberFormatException ex) {
+                log.warn("IM userId转换失败, userId={}", raw, ex);
+            }
+        }
+        return normalized.stream().distinct().collect(Collectors.toList());
+    }
+}