Przeglądaj źródła

直播下单运营

xw 3 dni temu
rodzic
commit
39559409d1

+ 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)));
+    }
+}

+ 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";
+
 }

+ 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

+ 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;

+ 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;
+    }
+
+}

+ 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;
+}

+ 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>