Browse Source

saas同步

lk 4 days ago
parent
commit
85f8e9b5ad
100 changed files with 6646 additions and 0 deletions
  1. 26 0
      adminui/src/api/admin/courseResource.js
  2. 20 0
      adminui/src/api/admin/deviceManufacturer.js
  3. 6 0
      adminui/src/api/admin/deviceStats.js
  4. 17 0
      adminui/src/api/admin/deviceType.js
  5. 8 0
      adminui/src/api/admin/dropShipping.js
  6. 8 0
      adminui/src/api/admin/finance.js
  7. 56 0
      adminui/src/api/admin/inventoryTransfer.js
  8. 26 0
      adminui/src/api/admin/productLibrary.js
  9. 17 0
      adminui/src/api/admin/resourceAuth.js
  10. 17 0
      adminui/src/api/admin/supplier.js
  11. 17 0
      adminui/src/api/admin/tenantPackage.js
  12. 18 0
      agentui/src/api/notice.js
  13. 27 0
      agentui/src/api/purchaseSplit.js
  14. 7 0
      java/fs-common/pom.xml
  15. 13 0
      java/fs-common/src/main/java/com/fs/common/constant/LiveKeysConstant.java
  16. 60 0
      java/fs-common/src/main/java/com/fs/common/live/queue/LiveWsRoomQueueItem.java
  17. 116 0
      java/fs-saas-admin/src/main/java/com/fs/company/controller/WxMomentsTaskController.java
  18. 96 0
      java/fs-saas-admin/src/main/java/com/fs/company/controller/WxPublishMomentsTaskController.java
  19. 72 0
      java/fs-saas-admin/src/main/java/com/fs/live/controller/LiveVoteController.java
  20. 140 0
      java/fs-saas-admin/src/main/java/com/fs/lobster/controller/ExternalRoleTemplateController.java
  21. 453 0
      java/fs-saas-admin/src/main/java/com/fs/lobster/controller/LobsterKnowledgeBaseController.java
  22. 135 0
      java/fs-saas-company/src/main/java/com/fs/company/controller/company/WxMomentsTaskController.java
  23. 129 0
      java/fs-saas-company/src/main/java/com/fs/company/controller/company/WxPublishMomentsTaskController.java
  24. 155 0
      java/fs-saas-company/src/main/java/com/fs/company/controller/external/CompanyExternalRoleController.java
  25. 72 0
      java/fs-saas-company/src/main/java/com/fs/company/controller/live/LiveVoteController.java
  26. 66 0
      java/fs-service/src/main/java/com/fs/company/domain/CompanyExternalRoleTemplate.java
  27. 46 0
      java/fs-service/src/main/java/com/fs/company/domain/CompanyUserVoiceConfig.java
  28. 37 0
      java/fs-service/src/main/java/com/fs/company/domain/CrmManualOutboundUuidMap.java
  29. 47 0
      java/fs-service/src/main/java/com/fs/company/domain/LobsterKnowledgeProbingMeta.java
  30. 11 0
      java/fs-service/src/main/java/com/fs/company/domain/LobsterSalesExternalRole.java
  31. 26 0
      java/fs-service/src/main/java/com/fs/company/mapper/CompanyExternalRoleTemplateMapper.java
  32. 17 0
      java/fs-service/src/main/java/com/fs/company/mapper/CompanyUserVoiceConfigMapper.java
  33. 30 0
      java/fs-service/src/main/java/com/fs/company/mapper/CrmManualOutboundUuidMapMapper.java
  34. 41 0
      java/fs-service/src/main/java/com/fs/company/mapper/LobsterKnowledgeProbingMetaMapper.java
  35. 18 0
      java/fs-service/src/main/java/com/fs/company/mapper/LobsterSalesExternalRoleMapper.java
  36. 25 0
      java/fs-service/src/main/java/com/fs/company/param/CrmManualOutboundUuidMapReportParam.java
  37. 30 0
      java/fs-service/src/main/java/com/fs/company/service/ICrmManualOutboundUuidMapService.java
  38. 45 0
      java/fs-service/src/main/java/com/fs/company/service/external/CompanyExternalRoleTemplateService.java
  39. 40 0
      java/fs-service/src/main/java/com/fs/company/service/external/ExternalRoleConfig.java
  40. 183 0
      java/fs-service/src/main/java/com/fs/company/service/external/impl/CompanyExternalRoleTemplateServiceImpl.java
  41. 192 0
      java/fs-service/src/main/java/com/fs/company/service/impl/CrmManualOutboundUuidMapServiceImpl.java
  42. 262 0
      java/fs-service/src/main/java/com/fs/company/service/workflow/deepdialogue/KnowledgeRetrievalResult.java
  43. 165 0
      java/fs-service/src/main/java/com/fs/company/service/workflow/identity/LobsterSalesSystemRoleCodec.java
  44. 138 0
      java/fs-service/src/main/java/com/fs/company/service/workflow/knowledge/LobsterKnowledgeProbingMetaService.java
  45. 15 0
      java/fs-service/src/main/java/com/fs/company/vo/lobster/LobsterCompanyIdentityConfigRow.java
  46. 8 0
      java/fs-service/src/main/java/com/fs/course/service/IFsCourseQuestionBankService.java
  47. 195 0
      java/fs-service/src/main/java/com/fs/course/service/impl/FsCourseQuestionBankServiceImpl.java
  48. 6 0
      java/fs-service/src/main/java/com/fs/his/domain/FsCourseCouponUser.java
  49. 3 0
      java/fs-service/src/main/java/com/fs/his/domain/FsUser.java
  50. 2 0
      java/fs-service/src/main/java/com/fs/his/service/IFsStorePaymentService.java
  51. 5 0
      java/fs-service/src/main/java/com/fs/his/service/impl/FsStorePaymentServiceImpl.java
  52. 47 0
      java/fs-service/src/main/java/com/fs/live/domain/LiveCompletionAnswerRecord.java
  53. 22 0
      java/fs-service/src/main/java/com/fs/live/domain/LiveCourseQuestionRel.java
  54. 30 0
      java/fs-service/src/main/java/com/fs/live/domain/LiveQuickComment.java
  55. 84 0
      java/fs-service/src/main/java/com/fs/live/domain/LiveRedPacketLog.java
  56. 33 0
      java/fs-service/src/main/java/com/fs/live/domain/LiveRedPasswordJoin.java
  57. 74 0
      java/fs-service/src/main/java/com/fs/live/domain/LiveVote.java
  58. 33 0
      java/fs-service/src/main/java/com/fs/live/domain/LiveVoteRecord.java
  59. 26 0
      java/fs-service/src/main/java/com/fs/live/domain/LiveWatchConfig.java
  60. 6 0
      java/fs-service/src/main/java/com/fs/live/domain/LiveWatchUser.java
  61. 24 0
      java/fs-service/src/main/java/com/fs/live/mapper/LiveCompletionAnswerRecordMapper.java
  62. 27 0
      java/fs-service/src/main/java/com/fs/live/mapper/LiveCourseQuestionRelMapper.java
  63. 34 0
      java/fs-service/src/main/java/com/fs/live/mapper/LiveQuickCommentMapper.java
  64. 70 0
      java/fs-service/src/main/java/com/fs/live/mapper/LiveRedPacketLogMapper.java
  65. 32 0
      java/fs-service/src/main/java/com/fs/live/mapper/LiveRedPasswordJoinMapper.java
  66. 50 0
      java/fs-service/src/main/java/com/fs/live/mapper/LiveVoteMapper.java
  67. 4 0
      java/fs-service/src/main/java/com/fs/live/mapper/LiveWatchUserMapper.java
  68. 27 0
      java/fs-service/src/main/java/com/fs/live/param/LiveCompletionAnswerRecordParam.java
  69. 29 0
      java/fs-service/src/main/java/com/fs/live/param/LiveCompletionCouponAnswerItem.java
  70. 17 0
      java/fs-service/src/main/java/com/fs/live/param/LiveCompletionCouponAnswerParam.java
  71. 34 0
      java/fs-service/src/main/java/com/fs/live/param/LiveRedPacketParam.java
  72. 19 0
      java/fs-service/src/main/java/com/fs/live/param/LiveVoteCastParam.java
  73. 44 0
      java/fs-service/src/main/java/com/fs/live/service/ILiveCompletionAnswerRecordService.java
  74. 21 0
      java/fs-service/src/main/java/com/fs/live/service/ILiveCourseQuestionRelService.java
  75. 30 0
      java/fs-service/src/main/java/com/fs/live/service/ILiveQuickCommentService.java
  76. 67 0
      java/fs-service/src/main/java/com/fs/live/service/ILiveRedPacketLogService.java
  77. 3 0
      java/fs-service/src/main/java/com/fs/live/service/ILiveService.java
  78. 42 0
      java/fs-service/src/main/java/com/fs/live/service/ILiveVoteService.java
  79. 4 0
      java/fs-service/src/main/java/com/fs/live/service/ILiveWatchUserService.java
  80. 82 0
      java/fs-service/src/main/java/com/fs/live/service/impl/LiveCompletionAnswerRecordServiceImpl.java
  81. 98 0
      java/fs-service/src/main/java/com/fs/live/service/impl/LiveCourseQuestionRelServiceImpl.java
  82. 134 0
      java/fs-service/src/main/java/com/fs/live/service/impl/LiveQuickCommentServiceImpl.java
  83. 973 0
      java/fs-service/src/main/java/com/fs/live/service/impl/LiveRedPacketLogServiceImpl.java
  84. 44 0
      java/fs-service/src/main/java/com/fs/live/service/impl/LiveServiceImpl.java
  85. 373 0
      java/fs-service/src/main/java/com/fs/live/service/impl/LiveVoteServiceImpl.java
  86. 103 0
      java/fs-service/src/main/java/com/fs/live/service/impl/LiveWatchUserServiceImpl.java
  87. 143 0
      java/fs-service/src/main/java/com/fs/live/support/LiveTaskIndexSupport.java
  88. 78 0
      java/fs-service/src/main/java/com/fs/live/support/LiveWsRoomPublisher.java
  89. 24 0
      java/fs-service/src/main/java/com/fs/live/vo/AnswerEvaluation.java
  90. 24 0
      java/fs-service/src/main/java/com/fs/live/vo/LiveCompletionAnswerDetailVO.java
  91. 39 0
      java/fs-service/src/main/java/com/fs/live/vo/LiveCompletionAnswerRecordListVO.java
  92. 26 0
      java/fs-service/src/main/java/com/fs/live/vo/LiveCompletionCouponAnswerResult.java
  93. 28 0
      java/fs-service/src/main/java/com/fs/live/vo/LiveWatchUserStatusVO.java
  94. 29 0
      java/fs-service/src/main/java/com/fs/live/vo/RecordTimeRangeVO.java
  95. 26 0
      java/fs-service/src/main/java/com/fs/lobster/domain/LobsterKbCategory.java
  96. 43 0
      java/fs-service/src/main/java/com/fs/lobster/domain/LobsterKbMeta.java
  97. 20 0
      java/fs-service/src/main/java/com/fs/lobster/mapper/KbCategoryMapper.java
  98. 20 0
      java/fs-service/src/main/java/com/fs/lobster/mapper/KbMetaMapper.java
  99. 20 0
      java/fs-service/src/main/java/com/fs/lobster/service/IKbCategoryService.java
  100. 22 0
      java/fs-service/src/main/java/com/fs/lobster/service/IKbMetaService.java

+ 26 - 0
adminui/src/api/admin/courseResource.js

@@ -0,0 +1,26 @@
+import request from '@/utils/request'
+
+// 查询课程资源库列表
+export function listCourseResource(query) {
+  return request({ url: '/admin/courseResource/list', method: 'get', params: query })
+}
+
+// 查询课程资源详情
+export function getCourseResource(id) {
+  return request({ url: '/admin/courseResource/' + id, method: 'get' })
+}
+
+// 新增课程资源
+export function addCourseResource(data) {
+  return request({ url: '/admin/courseResource', method: 'post', data })
+}
+
+// 修改课程资源
+export function updateCourseResource(data) {
+  return request({ url: '/admin/courseResource', method: 'put', data })
+}
+
+// 删除课程资源
+export function delCourseResource(ids) {
+  return request({ url: '/admin/courseResource/' + ids, method: 'delete' })
+}

+ 20 - 0
adminui/src/api/admin/deviceManufacturer.js

@@ -0,0 +1,20 @@
+import request from '@/utils/request'
+
+export function listManufacturer(query) {
+  return request({ url: '/admin/deviceManufacturer/list', method: 'get', params: query })
+}
+export function getManufacturer(id) {
+  return request({ url: '/admin/deviceManufacturer/' + id, method: 'get' })
+}
+export function addManufacturer(data) {
+  return request({ url: '/admin/deviceManufacturer', method: 'post', data })
+}
+export function updateManufacturer(data) {
+  return request({ url: '/admin/deviceManufacturer', method: 'put', data })
+}
+export function delManufacturer(ids) {
+  return request({ url: '/admin/deviceManufacturer/' + ids, method: 'delete' })
+}
+export function allActiveManufacturers() {
+  return request({ url: '/admin/deviceManufacturer/allActive', method: 'get' })
+}

+ 6 - 0
adminui/src/api/admin/deviceStats.js

@@ -0,0 +1,6 @@
+import request from '@/utils/request'
+
+// 获取设备统计概览
+export function getDeviceStats() {
+  return request({ url: '/admin/deviceStats/overview', method: 'get' })
+}

+ 17 - 0
adminui/src/api/admin/deviceType.js

@@ -0,0 +1,17 @@
+import request from '@/utils/request'
+
+export function listDeviceType(query) {
+  return request({ url: '/admin/deviceType/list', method: 'get', params: query })
+}
+export function getDeviceType(id) {
+  return request({ url: '/admin/deviceType/' + id, method: 'get' })
+}
+export function addDeviceType(data) {
+  return request({ url: '/admin/deviceType', method: 'post', data })
+}
+export function updateDeviceType(data) {
+  return request({ url: '/admin/deviceType', method: 'put', data })
+}
+export function delDeviceType(ids) {
+  return request({ url: '/admin/deviceType/' + ids, method: 'delete' })
+}

+ 8 - 0
adminui/src/api/admin/dropShipping.js

@@ -0,0 +1,8 @@
+import request from '@/utils/request'
+
+export function listDropShipping(query) {
+  return request({ url: '/admin/dropShipping/list', method: 'get', params: query })
+}
+export function updateDropShippingStatus(orderId, status) {
+  return request({ url: '/admin/dropShipping/' + orderId + '/status', method: 'put', params: { status } })
+}

+ 8 - 0
adminui/src/api/admin/finance.js

@@ -0,0 +1,8 @@
+import request from '@/utils/request'
+
+export function listLedger(query) {
+  return request({ url: '/admin/finance/ledger/list', method: 'get', params: query })
+}
+export function getLedgerSummary() {
+  return request({ url: '/admin/finance/ledger/summary', method: 'get' })
+}

+ 56 - 0
adminui/src/api/admin/inventoryTransfer.js

@@ -0,0 +1,56 @@
+import request from '@/utils/request'
+
+// 查询库存调拨单列表
+export function listInventoryTransfer(query) {
+  return request({ url: '/supplychain/inventoryTransfer/list', method: 'get', params: query })
+}
+
+// 查询库存调拨单详情
+export function getInventoryTransfer(transferNo) {
+  return request({ url: '/supplychain/inventoryTransfer/detail', method: 'get', params: { transferNo } })
+}
+
+// 新增库存调拨单(草稿)
+export function addInventoryTransfer(data) {
+  return request({ url: '/supplychain/inventoryTransfer', method: 'post', data })
+}
+
+// 修改库存调拨单
+export function updateInventoryTransfer(data) {
+  return request({ url: '/supplychain/inventoryTransfer', method: 'put', data })
+}
+
+// 删除库存调拨单
+export function delInventoryTransfer(transferNo) {
+  return request({ url: '/supplychain/inventoryTransfer/' + transferNo, method: 'delete' })
+}
+
+// 提交库存调拨单
+export function submitInventoryTransfer(transferNo) {
+  return request({ url: '/supplychain/inventoryTransfer/submit', method: 'post', params: { transferNo } })
+}
+
+// 审批库存调拨单
+export function auditInventoryTransfer(data) {
+  return request({ url: '/supplychain/inventoryTransfer/audit', method: 'post', data })
+}
+
+// 发货确认
+export function shipInventoryTransfer(transferNo) {
+  return request({ url: '/supplychain/inventoryTransfer/ship', method: 'post', params: { transferNo } })
+}
+
+// 收货确认
+export function receiveInventoryTransfer(data) {
+  return request({ url: '/supplychain/inventoryTransfer/receive', method: 'post', data })
+}
+
+// 取消库存调拨单
+export function cancelInventoryTransfer(transferNo) {
+  return request({ url: '/supplychain/inventoryTransfer/cancel', method: 'post', params: { transferNo } })
+}
+
+// 查询操作日志
+export function listInventoryTransferLogs(transferNo) {
+  return request({ url: '/supplychain/inventoryTransfer/logs', method: 'get', params: { transferNo } })
+}

+ 26 - 0
adminui/src/api/admin/productLibrary.js

@@ -0,0 +1,26 @@
+import request from '@/utils/request'
+
+// 查询商品库列表
+export function listProductLibrary(query) {
+  return request({ url: '/admin/productLibrary/list', method: 'get', params: query })
+}
+
+// 查询商品详情
+export function getProductLibrary(productId) {
+  return request({ url: '/admin/productLibrary/' + productId, method: 'get' })
+}
+
+// 新增商品
+export function addProductLibrary(data) {
+  return request({ url: '/admin/productLibrary', method: 'post', data })
+}
+
+// 修改商品
+export function updateProductLibrary(data) {
+  return request({ url: '/admin/productLibrary', method: 'put', data })
+}
+
+// 删除商品
+export function delProductLibrary(productIds) {
+  return request({ url: '/admin/productLibrary/' + productIds, method: 'delete' })
+}

+ 17 - 0
adminui/src/api/admin/resourceAuth.js

@@ -0,0 +1,17 @@
+import request from '@/utils/request'
+
+export function listResourceAuth(query) {
+  return request({ url: '/admin/resourceAuth/list', method: 'get', params: query })
+}
+export function addResourceAuth(data) {
+  return request({ url: '/admin/resourceAuth', method: 'post', data })
+}
+export function batchAddResourceAuth(data) {
+  return request({ url: '/admin/resourceAuth/batch', method: 'post', data })
+}
+export function updateResourceAuth(data) {
+  return request({ url: '/admin/resourceAuth', method: 'put', data })
+}
+export function delResourceAuth(ids) {
+  return request({ url: '/admin/resourceAuth/' + ids, method: 'delete' })
+}

+ 17 - 0
adminui/src/api/admin/supplier.js

@@ -0,0 +1,17 @@
+import request from '@/utils/request'
+
+export function listSupplier(query) {
+  return request({ url: '/admin/supplier/list', method: 'get', params: query })
+}
+export function getSupplier(id) {
+  return request({ url: '/admin/supplier/' + id, method: 'get' })
+}
+export function addSupplier(data) {
+  return request({ url: '/admin/supplier', method: 'post', data })
+}
+export function updateSupplier(data) {
+  return request({ url: '/admin/supplier', method: 'put', data })
+}
+export function delSupplier(ids) {
+  return request({ url: '/admin/supplier/' + ids, method: 'delete' })
+}

+ 17 - 0
adminui/src/api/admin/tenantPackage.js

@@ -0,0 +1,17 @@
+import request from '@/utils/request'
+
+export function listTenantPackage(query) {
+  return request({ url: '/admin/tenantPackage/list', method: 'get', params: query })
+}
+export function getTenantPackage(id) {
+  return request({ url: '/admin/tenantPackage/' + id, method: 'get' })
+}
+export function addTenantPackage(data) {
+  return request({ url: '/admin/tenantPackage', method: 'post', data })
+}
+export function updateTenantPackage(data) {
+  return request({ url: '/admin/tenantPackage', method: 'put', data })
+}
+export function delTenantPackage(ids) {
+  return request({ url: '/admin/tenantPackage/' + ids, method: 'delete' })
+}

+ 18 - 0
agentui/src/api/notice.js

@@ -0,0 +1,18 @@
+import request from '@/utils/request'
+
+// 获取公告列表
+export function getNoticeList(params) {
+  return request({
+    url: '/system/notice/list',
+    method: 'get',
+    params
+  })
+}
+
+// 获取公告详情
+export function getNoticeDetail(noticeId) {
+  return request({
+    url: '/system/notice/' + noticeId,
+    method: 'get'
+  })
+}

+ 27 - 0
agentui/src/api/purchaseSplit.js

@@ -0,0 +1,27 @@
+import request from '@/utils/request'
+
+// 进货分成列表
+export function getPurchaseSplitList(params) {
+  return request({
+    url: '/proxy/purchaseSplit/list',
+    method: 'get',
+    params
+  })
+}
+
+// 进货分成汇总
+export function getPurchaseSplitSummary(month) {
+  return request({
+    url: '/proxy/purchaseSplit/summary',
+    method: 'get',
+    params: { month }
+  })
+}
+
+// 进货分成详情
+export function getPurchaseSplitDetail(id) {
+  return request({
+    url: '/proxy/purchaseSplit/' + id,
+    method: 'get'
+  })
+}

+ 7 - 0
java/fs-common/pom.xml

@@ -168,6 +168,13 @@
             <groupId>com.cronutils</groupId>
             <artifactId>cron-utils</artifactId>
         </dependency>
+
+        <!-- Apache HttpClient 4.x(HttpUtils 使用 org.apache.http 包) -->
+        <dependency>
+            <groupId>org.apache.httpcomponents</groupId>
+            <artifactId>httpclient</artifactId>
+            <version>4.5.13</version>
+        </dependency>
     </dependencies>
 
 </project>

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

@@ -41,4 +41,17 @@ public class LiveKeysConstant {
     public static final String LIVE_USER_WATCH_LOG_CACHE = "live:user:watch:log:%s:%s:%s:%s";
 
 
+    public static final String LIVE_LOTTERY_TASK_PREFIX = "live:lottery_task:";
+    public static final String LIVE_RED_TASK_PREFIX = "live:red_task:";
+    public static final String LIVE_VOTE_TASK_PREFIX = "live:vote_task:";
+    public static final String LIVE_LOTTERY_TASK_INDEX = "live:index:lottery_task";
+    public static final String LIVE_RED_TASK_INDEX = "live:index:red_task";
+    public static final String LIVE_AUTO_TASK_INDEX = "live:index:auto_task";
+    public static final String LIVE_VOTE_TASK_INDEX = "live:index:vote_task";
+
+    /** 直播间 WS 旁路广播 Redisson Topic(投票等) */
+    public static final String LIVE_WS_ROOM_CHANNEL = "live:ws:room";
+
+    public static final int TTL_LIVE_DATA = 30;
+
 }

+ 60 - 0
java/fs-common/src/main/java/com/fs/common/live/queue/LiveWsRoomQueueItem.java

@@ -0,0 +1,60 @@
+package com.fs.common.live.queue;
+
+import java.io.Serializable;
+
+/**
+ * 直播间 WS 旁路广播载荷(投票等)。
+ * SaaS 多租户:tenantId 必填,用于组装 roomKey = tenantId:liveId。
+ */
+public class LiveWsRoomQueueItem implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long tenantId;
+    private Long liveId;
+    /** 客户端可见的完整消息体(通常为 R.ok().put("data", ...) JSON) */
+    private String message;
+    private Long enqueueTime;
+
+    public LiveWsRoomQueueItem() {
+    }
+
+    public LiveWsRoomQueueItem(Long tenantId, Long liveId, String message) {
+        this.tenantId = tenantId;
+        this.liveId = liveId;
+        this.message = message;
+        this.enqueueTime = System.currentTimeMillis();
+    }
+
+    public Long getTenantId() {
+        return tenantId;
+    }
+
+    public void setTenantId(Long tenantId) {
+        this.tenantId = tenantId;
+    }
+
+    public Long getLiveId() {
+        return liveId;
+    }
+
+    public void setLiveId(Long liveId) {
+        this.liveId = liveId;
+    }
+
+    public String getMessage() {
+        return message;
+    }
+
+    public void setMessage(String message) {
+        this.message = message;
+    }
+
+    public Long getEnqueueTime() {
+        return enqueueTime;
+    }
+
+    public void setEnqueueTime(Long enqueueTime) {
+        this.enqueueTime = enqueueTime;
+    }
+}

+ 116 - 0
java/fs-saas-admin/src/main/java/com/fs/company/controller/WxMomentsTaskController.java

@@ -0,0 +1,116 @@
+package com.fs.company.controller;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.CompanyWxAccount;
+import com.fs.company.service.ICompanyWxAccountService;
+import com.fs.company.support.AdminCompanyScopeHelper;
+import com.fs.wxcid.domain.WxMomentsTask;
+import com.fs.wxcid.domain.WxMomentsTaskRecord;
+import com.fs.wxcid.service.IWxMomentsTaskService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 刷朋友圈任务(租户总后台)
+ * <p>与 wx-api / 销售端字段一致:wxNo、runCount、点赞/评论概率、话术、滚动条数等;
+ * 列表 companyId 可空;新增须指定销售公司;修改保留原公司</p>
+ */
+@RestController
+@RequestMapping("/company/wxMomentsTask")
+public class WxMomentsTaskController extends BaseController {
+
+    @Autowired
+    private IWxMomentsTaskService wxMomentsTaskService;
+
+    @Autowired
+    private ICompanyWxAccountService companyWxAccountService;
+
+    @PreAuthorize("@ss.hasPermi('company:wxMomentsTask:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(WxMomentsTask query) {
+        startPage();
+        List<WxMomentsTask> list = wxMomentsTaskService.selectWxMomentsTaskList(query);
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('company:wxMomentsTask:query')")
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable Long id) {
+        return AjaxResult.success(wxMomentsTaskService.selectWxMomentsTaskById(id));
+    }
+
+    @PreAuthorize("@ss.hasPermi('company:wxMomentsTask:add')")
+    @Log(title = "刷朋友圈任务", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody WxMomentsTask task) {
+        AdminCompanyScopeHelper.requireCompanyId(task == null ? null : task.getCompanyId());
+        fillAccountWxNo(task);
+        String operator = getUsername();
+        task.setCreateBy(operator);
+        task.setUpdateBy(operator);
+        return toAjax(wxMomentsTaskService.insertWxMomentsTask(task));
+    }
+
+    @PreAuthorize("@ss.hasPermi('company:wxMomentsTask:edit')")
+    @Log(title = "刷朋友圈任务", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody WxMomentsTask task) {
+        if (task == null || task.getId() == null) {
+            return AjaxResult.error("参数错误");
+        }
+        WxMomentsTask existing = wxMomentsTaskService.selectWxMomentsTaskById(task.getId());
+        if (existing == null) {
+            return AjaxResult.error("任务不存在");
+        }
+        // 修改时保留原公司,不允许改挂公司
+        task.setCompanyId(existing.getCompanyId());
+        fillAccountWxNo(task);
+        task.setUpdateBy(getUsername());
+        return toAjax(wxMomentsTaskService.updateWxMomentsTask(task));
+    }
+
+    @PreAuthorize("@ss.hasPermi('company:wxMomentsTask:remove')")
+    @Log(title = "刷朋友圈任务", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids) {
+        return toAjax(wxMomentsTaskService.deleteWxMomentsTaskByIds(ids, getUsername()));
+    }
+
+    @PreAuthorize("@ss.hasPermi('company:wxMomentsTask:list')")
+    @GetMapping("/records")
+    public TableDataInfo records(WxMomentsTaskRecord query) {
+        startPage();
+        List<WxMomentsTaskRecord> list = wxMomentsTaskService.selectRecordList(query);
+        return getDataTable(list);
+    }
+
+    /**
+     * 按个微账号回填 wxNo / companyUserId(与 wx-api 创建时绑定个微一致)
+     */
+    private void fillAccountWxNo(WxMomentsTask task) {
+        if (task == null || task.getAccountId() == null) {
+            return;
+        }
+        CompanyWxAccount account = companyWxAccountService.getById(task.getAccountId());
+        if (account == null) {
+            return;
+        }
+        if (StringUtils.isEmpty(task.getWxNo())) {
+            task.setWxNo(account.getWxNo());
+        }
+        if (task.getCompanyId() == null) {
+            task.setCompanyId(account.getCompanyId());
+        }
+        if (task.getCompanyUserId() == null) {
+            task.setCompanyUserId(account.getCompanyUserId());
+        }
+    }
+}

+ 96 - 0
java/fs-saas-admin/src/main/java/com/fs/company/controller/WxPublishMomentsTaskController.java

@@ -0,0 +1,96 @@
+package com.fs.company.controller;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.CompanyWxAccount;
+import com.fs.company.service.ICompanyWxAccountService;
+import com.fs.company.support.AdminCompanyScopeHelper;
+import com.fs.wxcid.domain.WxPublishMomentsTask;
+import com.fs.wxcid.service.IWxPublishMomentsTaskService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 发布朋友圈任务(租户总后台)
+ * <p>无单条查询接口;修改时若 completed 由 Service 拒绝</p>
+ */
+@RestController
+@RequestMapping("/company/wxPublishMomentsTask")
+public class WxPublishMomentsTaskController extends BaseController {
+
+    @Autowired
+    private IWxPublishMomentsTaskService wxPublishMomentsTaskService;
+
+    @Autowired
+    private ICompanyWxAccountService companyWxAccountService;
+
+    @PreAuthorize("@ss.hasPermi('company:wxPublishMomentsTask:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(WxPublishMomentsTask query) {
+        startPage();
+        List<WxPublishMomentsTask> list = wxPublishMomentsTaskService.selectWxPublishMomentsTaskList(query);
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('company:wxPublishMomentsTask:add')")
+    @Log(title = "发布朋友圈任务", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody WxPublishMomentsTask task) {
+        AdminCompanyScopeHelper.requireCompanyId(task == null ? null : task.getCompanyId());
+        fillAccountWxNo(task);
+        String operator = getUsername();
+        task.setCreateBy(operator);
+        task.setUpdateBy(operator);
+        return toAjax(wxPublishMomentsTaskService.insertWxPublishMomentsTask(task));
+    }
+
+    @PreAuthorize("@ss.hasPermi('company:wxPublishMomentsTask:edit')")
+    @Log(title = "发布朋友圈任务", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody WxPublishMomentsTask task) {
+        if (task == null || task.getId() == null) {
+            return AjaxResult.error("参数错误");
+        }
+        WxPublishMomentsTask existing = wxPublishMomentsTaskService.selectWxPublishMomentsTaskById(task.getId());
+        if (existing == null) {
+            return AjaxResult.error("任务不存在");
+        }
+        task.setCompanyId(existing.getCompanyId());
+        fillAccountWxNo(task);
+        task.setUpdateBy(getUsername());
+        return toAjax(wxPublishMomentsTaskService.updateWxPublishMomentsTask(task));
+    }
+
+    @PreAuthorize("@ss.hasPermi('company:wxPublishMomentsTask:remove')")
+    @Log(title = "发布朋友圈任务", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids) {
+        return toAjax(wxPublishMomentsTaskService.deleteWxPublishMomentsTaskByIds(ids, getUsername()));
+    }
+
+    private void fillAccountWxNo(WxPublishMomentsTask task) {
+        if (task == null || task.getAccountId() == null) {
+            return;
+        }
+        CompanyWxAccount account = companyWxAccountService.getById(task.getAccountId());
+        if (account == null) {
+            return;
+        }
+        if (StringUtils.isEmpty(task.getWxNo())) {
+            task.setWxNo(account.getWxNo());
+        }
+        if (task.getCompanyId() == null) {
+            task.setCompanyId(account.getCompanyId());
+        }
+        if (task.getCompanyUserId() == null) {
+            task.setCompanyUserId(account.getCompanyUserId());
+        }
+    }
+}

+ 72 - 0
java/fs-saas-admin/src/main/java/com/fs/live/controller/LiveVoteController.java

@@ -0,0 +1,72 @@
+package com.fs.live.controller;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.live.domain.LiveVote;
+import com.fs.live.service.ILiveVoteService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 直播投票(管理端)
+ *
+ * @author fs
+ * @date 2026-08-10
+ */
+@RestController
+@RequestMapping("/live/vote")
+public class LiveVoteController extends BaseController {
+
+    @Autowired
+    private ILiveVoteService liveVoteService;
+
+    @GetMapping("/list")
+    public TableDataInfo list(LiveVote liveVote) {
+        startPage();
+        List<LiveVote> list = liveVoteService.selectLiveVoteList(liveVote);
+        return getDataTable(list);
+    }
+
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id) {
+        return AjaxResult.success(liveVoteService.selectLiveVoteById(id));
+    }
+
+    @Log(title = "直播投票", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody LiveVote liveVote) {
+        liveVote.setCreateBy(getUsername());
+        return toAjax(liveVoteService.insertLiveVote(liveVote));
+    }
+
+    @Log(title = "直播投票", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody LiveVote liveVote) {
+        liveVote.setUpdateBy(getUsername());
+        return toAjax(liveVoteService.updateLiveVote(liveVote));
+    }
+
+    @Log(title = "直播投票", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids) {
+        return toAjax(liveVoteService.deleteLiveVoteByIds(ids));
+    }
+
+    @Log(title = "开始直播投票", businessType = BusinessType.UPDATE)
+    @PutMapping("/start/{id}")
+    public R start(@PathVariable("id") Long id) {
+        return liveVoteService.start(id);
+    }
+
+    @Log(title = "结束直播投票", businessType = BusinessType.UPDATE)
+    @PutMapping("/end/{id}")
+    public R end(@PathVariable("id") Long id) {
+        return liveVoteService.end(id);
+    }
+}

+ 140 - 0
java/fs-saas-admin/src/main/java/com/fs/lobster/controller/ExternalRoleTemplateController.java

@@ -0,0 +1,140 @@
+package com.fs.lobster.controller;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.entity.SysRole;
+import com.fs.common.utils.SecurityUtils;
+import com.fs.company.domain.CompanyExternalRoleTemplate;
+import com.fs.company.mapper.CompanyMapper;
+import com.fs.company.service.external.CompanyExternalRoleTemplateService;
+import com.fs.system.service.ISysRoleService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 公司对外角色模板管理(租户管理端)
+ * <p>公司可为不同员工角色类型创建对外角色范本。
+ */
+@RestController
+@RequestMapping("/lobster/external-role-template")
+public class ExternalRoleTemplateController extends BaseController {
+
+    @Autowired
+    private CompanyExternalRoleTemplateService templateService;
+
+    @Autowired(required = false)
+    private ISysRoleService sysRoleService;
+
+    @Autowired(required = false)
+    private CompanyMapper companyMapper;
+
+    /** 查询角色模板列表 */
+    @GetMapping("/list")
+    public AjaxResult list(@RequestParam(required = false) Long companyId) {
+        Long cid = resolveScopedCompanyId(companyId);
+        List<CompanyExternalRoleTemplate> list = templateService.listAllByCompany(cid);
+        return AjaxResult.success(list);
+    }
+
+    /** 查询可选的系统角色列表 */
+    @GetMapping("/sys-roles")
+    public AjaxResult listSysRoles(@RequestParam(required = false) Long companyId) {
+        if (sysRoleService == null) {
+            return AjaxResult.error("角色服务不可用");
+        }
+        // 查询该公司启用的角色列表
+        SysRole query = new SysRole();
+        query.setStatus("0"); // 正常状态
+        List<SysRole> roles = sysRoleService.selectRoleList(query);
+        return AjaxResult.success(roles);
+    }
+
+    /** 查询单个角色模板 */
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable Long id) {
+        return AjaxResult.success(templateService.getById(id));
+    }
+
+    /** 新增角色模板 */
+    @PostMapping
+    public AjaxResult add(@RequestBody CompanyExternalRoleTemplate template) {
+        if (template.getCompanyId() == null) {
+            template.setCompanyId(resolveScopedCompanyId(null));
+        }
+        template.setCreateBy(SecurityUtils.getUsername());
+        // 冗余角色名称
+        if (template.getRoleId() != null && sysRoleService != null) {
+            SysRole role = sysRoleService.selectRoleById(template.getRoleId());
+            if (role != null) {
+                template.setRoleKey(role.getRoleKey());
+                template.setRoleName(role.getRoleName());
+            }
+        }
+        return toAjax(templateService.insert(template));
+    }
+
+    /** 修改角色模板 */
+    @PutMapping
+    public AjaxResult edit(@RequestBody CompanyExternalRoleTemplate template) {
+        template.setUpdateBy(SecurityUtils.getUsername());
+        // 冗余角色名称
+        if (template.getRoleId() != null && sysRoleService != null) {
+            SysRole role = sysRoleService.selectRoleById(template.getRoleId());
+            if (role != null) {
+                template.setRoleKey(role.getRoleKey());
+                template.setRoleName(role.getRoleName());
+            }
+        }
+        return toAjax(templateService.update(template));
+    }
+
+    /** 删除角色模板 */
+    @DeleteMapping("/{id}")
+    public AjaxResult remove(@PathVariable Long id) {
+        return toAjax(templateService.deleteById(id, SecurityUtils.getUsername()));
+    }
+
+    /** 设为默认角色 */
+    @PutMapping("/set-default/{id}")
+    public AjaxResult setDefault(@PathVariable Long id) {
+        CompanyExternalRoleTemplate template = templateService.getById(id);
+        if (template == null) {
+            return AjaxResult.error("模板不存在");
+        }
+        // 先清除同公司的其他默认
+        List<CompanyExternalRoleTemplate> list = templateService.listAllByCompany(template.getCompanyId());
+        for (CompanyExternalRoleTemplate t : list) {
+            if (t.getIsDefault() != null && t.getIsDefault() == 1 && !t.getId().equals(id)) {
+                t.setIsDefault(0);
+                t.setUpdateBy(SecurityUtils.getUsername());
+                templateService.update(t);
+            }
+        }
+        // 设置当前为默认
+        template.setIsDefault(1);
+        template.setUpdateBy(SecurityUtils.getUsername());
+        return toAjax(templateService.update(template));
+    }
+
+    /** 解析当前作用域 companyId:优先传入参数,其次取租户下首家公司 */
+    private Long resolveScopedCompanyId(Long companyId) {
+        if (companyId != null && companyId > 0) {
+            return companyId;
+        }
+        if (companyMapper == null) {
+            return null;
+        }
+        try {
+            List<com.fs.company.domain.Company> companies =
+                    companyMapper.selectCompanyList(new com.fs.company.domain.Company());
+            if (companies != null && !companies.isEmpty() && companies.get(0).getCompanyId() != null) {
+                return companies.get(0).getCompanyId();
+            }
+        } catch (Exception ignored) {
+            // fall through
+        }
+        return null;
+    }
+}

+ 453 - 0
java/fs-saas-admin/src/main/java/com/fs/lobster/controller/LobsterKnowledgeBaseController.java

@@ -0,0 +1,453 @@
+package com.fs.lobster.controller;
+
+import cn.hutool.http.HttpRequest;
+import cn.hutool.http.HttpResponse;
+import cn.hutool.http.Method;
+import cn.hutool.json.JSONArray;
+import cn.hutool.json.JSONUtil;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.lobster.domain.LobsterKbCategory;
+import com.fs.lobster.domain.LobsterKbMeta;
+import com.fs.lobster.service.IKbCategoryService;
+import com.fs.lobster.service.IKbMetaService;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.web.bind.annotation.*;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.*;
+
+@Slf4j
+@RestController
+@RequestMapping("/lobster/kb")
+public class LobsterKnowledgeBaseController extends BaseController {
+
+    @Autowired
+    private IKbMetaService kbMetaService;
+
+    @Autowired
+    private IKbCategoryService kbCategoryService;
+
+    @Value("${ai.api.base-url:http://localhost:9009}")
+    private String aiApiBaseUrl;
+
+    // ==================== 知识库列表 ====================
+
+    /**
+     * 获取知识库列表(主查 Qdrant collection 列表,MySQL 元数据仅做可选补充)
+     */
+    @GetMapping("/list")
+    public AjaxResult list(HttpServletRequest request) {
+        List<Map<String, Object>> result = buildKnowledgeBaseList(request);
+        return AjaxResult.success(result);
+    }
+
+    // ==================== 知识库 CRUD ====================
+
+    /**
+     * 创建知识库(调 Qdrant 创建 collection + 保存元数据到 DB)
+     */
+    @PostMapping("/create")
+    public AjaxResult create(@RequestBody Map<String, Object> body) {
+        String displayName = (String) body.get("displayName");
+        String collectionName = (String) body.get("collectionName");
+        String category = (String) body.get("category");
+        String description = (String) body.get("description");
+
+        if (StringUtils.isBlank(collectionName)) {
+            return AjaxResult.error("collectionName 不能为空");
+        }
+
+        try {
+            // 1. 调用 Qdrant 创建 collection
+            Map<String, Object> qdrantReq = new LinkedHashMap<>();
+            qdrantReq.put("collectionName", collectionName);
+            qdrantReq.put("vectorSize", 1024);
+            exchangeAiApi("/qdrant/collection/create", Method.POST, qdrantReq);
+        } catch (Exception e) {
+            log.error("创建 Qdrant collection 失败: {}", collectionName, e);
+            return AjaxResult.error("创建 Qdrant collection 失败: " + e.getMessage());
+        }
+
+        // 2. 保存元数据到 DB(可选,表不存在时跳过)
+        try {
+            LobsterKbMeta meta = new LobsterKbMeta()
+                    .setCollectionName(collectionName)
+                    .setDisplayName(StringUtils.defaultIfBlank(displayName, collectionName))
+                    .setCategory(category)
+                    .setDescription(description);
+            kbMetaService.save(meta);
+        } catch (Exception e) {
+            log.warn("保存知识库元数据失败(表可能不存在): {}", collectionName, e);
+        }
+
+        return AjaxResult.success();
+    }
+
+    /**
+     * 更新知识库元数据(displayName, category, description)
+     */
+    @PutMapping("/update")
+    public AjaxResult update(@RequestBody Map<String, Object> body) {
+        String collectionName = (String) body.get("collectionName");
+        if (StringUtils.isBlank(collectionName)) {
+            return AjaxResult.error("collectionName 不能为空");
+        }
+
+        try {
+            LobsterKbMeta existing = kbMetaService.getByCollectionName(collectionName);
+            if (existing == null) {
+                return AjaxResult.error("知识库不存在: " + collectionName);
+            }
+
+            if (body.containsKey("displayName")) {
+                existing.setDisplayName((String) body.get("displayName"));
+            }
+            if (body.containsKey("category")) {
+                existing.setCategory((String) body.get("category"));
+            }
+            if (body.containsKey("description")) {
+                existing.setDescription((String) body.get("description"));
+            }
+
+            return toAjax(kbMetaService.update(existing));
+        } catch (Exception e) {
+            log.error("更新知识库元数据失败: {}", collectionName, e);
+            return AjaxResult.error("更新失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 切换启用/停用状态
+     */
+    @PutMapping("/toggle-status")
+    public AjaxResult toggleStatus(@RequestBody Map<String, Object> body) {
+        String collectionName = (String) body.get("collectionName");
+        Integer status = body.get("status") instanceof Number
+                ? ((Number) body.get("status")).intValue() : null;
+
+        if (StringUtils.isBlank(collectionName)) {
+            return AjaxResult.error("collectionName 不能为空");
+        }
+        if (status == null) {
+            return AjaxResult.error("status 不能为空");
+        }
+
+        try {
+            return toAjax(kbMetaService.toggleStatus(collectionName, status));
+        } catch (Exception e) {
+            log.error("切换知识库状态失败: {}", collectionName, e);
+            return AjaxResult.error("操作失败: " + e.getMessage());
+        }
+    }
+
+    // ==================== 分类管理 ====================
+
+    @GetMapping("/category/list")
+    public AjaxResult categoryList() {
+        try {
+            List<LobsterKbCategory> list = kbCategoryService.selectAll();
+            return AjaxResult.success(list);
+        } catch (Exception e) {
+            log.warn("查询分类列表失败(表可能不存在)", e);
+            return AjaxResult.success(Collections.emptyList());
+        }
+    }
+
+    @PostMapping("/category")
+    public AjaxResult addCategory(@RequestBody LobsterKbCategory category) {
+        if (StringUtils.isBlank(category.getName())) {
+            return AjaxResult.error("分类名称不能为空");
+        }
+        try {
+            LobsterKbCategory check = kbCategoryService.selectByName(category.getName());
+            if (check != null) {
+                return AjaxResult.error("分类名称不能重复");
+            }
+            return toAjax(kbCategoryService.insert(category));
+        } catch (Exception e) {
+            log.error("新增分类失败", e);
+            return AjaxResult.error("新增分类失败: " + e.getMessage());
+        }
+    }
+
+    @PutMapping("/category")
+    public AjaxResult editCategory(@RequestBody LobsterKbCategory category) {
+        if (category.getId() == null) {
+            return AjaxResult.error("id 不能为空");
+        }
+        try {
+            if (StringUtils.isNotBlank(category.getName())) {
+                LobsterKbCategory check = kbCategoryService.selectByName(category.getName());
+                if (check != null && !check.getId().equals(category.getId())) {
+                    return AjaxResult.error("分类名称不能重复");
+                }
+            }
+            return toAjax(kbCategoryService.update(category));
+        } catch (Exception e) {
+            log.error("修改分类失败", e);
+            return AjaxResult.error("修改分类失败: " + e.getMessage());
+        }
+    }
+
+    @DeleteMapping("/category/{id}")
+    public AjaxResult removeCategory(@PathVariable Long id) {
+        try {
+            return toAjax(kbCategoryService.deleteById(id));
+        } catch (Exception e) {
+            log.error("删除分类失败", e);
+            return AjaxResult.error("删除分类失败: " + e.getMessage());
+        }
+    }
+
+    // ==================== RAG 流水线配置 ====================
+
+    @Value("${lobster.rag.embeddingModel:}")
+    private String embeddingModel;
+
+    @Value("${lobster.rag.recallMode:hybrid}")
+    private String recallMode;
+
+    @Value("${lobster.rag.rerankModel:}")
+    private String rerankModel;
+
+    @Value("${lobster.rag.chunkSize:512}")
+    private Integer chunkSize;
+
+    @Value("${lobster.rag.chunkOverlap:50}")
+    private Integer chunkOverlap;
+
+    @Value("${lobster.rag.topK:5}")
+    private Integer topK;
+
+    @Value("${lobster.rag.scoreThreshold:0.5}")
+    private Double scoreThreshold;
+
+    /**
+     * 返回 RAG 流水线配置
+     */
+    @GetMapping("/rag-pipeline")
+    public AjaxResult ragPipeline() {
+        Map<String, Object> config = new LinkedHashMap<>();
+        config.put("embeddingModel", embeddingModel);
+        config.put("recallMode", recallMode);
+        config.put("rerankModel", rerankModel);
+        config.put("chunkSize", chunkSize);
+        config.put("chunkOverlap", chunkOverlap);
+        config.put("topK", topK);
+        config.put("scoreThreshold", scoreThreshold);
+        return AjaxResult.success(config);
+    }
+
+    /**
+     * 刷新检索中心配置(从配置文件重新读取)
+     */
+    @PostMapping("/rag-pipeline/refresh")
+    public AjaxResult refreshRagPipeline() {
+        // 配置通过 @Value 注入已实时反映,此处仅做刷新标记
+        Map<String, Object> config = new LinkedHashMap<>();
+        config.put("embeddingModel", embeddingModel);
+        config.put("recallMode", recallMode);
+        config.put("rerankModel", rerankModel);
+        config.put("chunkSize", chunkSize);
+        config.put("chunkOverlap", chunkOverlap);
+        config.put("topK", topK);
+        config.put("scoreThreshold", scoreThreshold);
+        return AjaxResult.success(config);
+    }
+
+    // ==================== 检索配置 ====================
+
+    /**
+     * 获取知识库检索配置
+     */
+    @GetMapping("/retrieval-config")
+    public AjaxResult getRetrievalConfig(@RequestParam("collectionName") String collectionName) {
+        try {
+            LobsterKbMeta meta = kbMetaService.getByCollectionName(collectionName);
+            if (meta == null) {
+                return AjaxResult.success(new LinkedHashMap<>());
+            }
+            if (StringUtils.isBlank(meta.getRetrievalConfig())) {
+                return AjaxResult.success(new LinkedHashMap<>());
+            }
+            return AjaxResult.success(JSONUtil.parseObj(meta.getRetrievalConfig()));
+        } catch (Exception e) {
+            log.warn("获取检索配置失败(表可能不存在): {}", collectionName, e);
+            return AjaxResult.success(new LinkedHashMap<>());
+        }
+    }
+
+    /**
+     * 保存知识库检索配置
+     */
+    @PutMapping("/retrieval-config")
+    public AjaxResult saveRetrievalConfig(@RequestBody Map<String, Object> body) {
+        String collectionName = (String) body.get("collectionName");
+        if (StringUtils.isBlank(collectionName)) {
+            return AjaxResult.error("collectionName 不能为空");
+        }
+
+        try {
+            LobsterKbMeta meta = kbMetaService.getByCollectionName(collectionName);
+            if (meta == null) {
+                return AjaxResult.error("知识库不存在: " + collectionName);
+            }
+            Map<String, Object> config = new LinkedHashMap<>(body);
+            config.remove("collectionName");
+            meta.setRetrievalConfig(JSONUtil.toJsonStr(config));
+            return toAjax(kbMetaService.update(meta));
+        } catch (Exception e) {
+            log.error("保存检索配置失败: {}", collectionName, e);
+            return AjaxResult.error("保存失败: " + e.getMessage());
+        }
+    }
+
+    // ==================== 检索调试 ====================
+
+    /**
+     * 检索调试(代理到 Qdrant 向量搜索)
+     */
+    @PostMapping("/search-debug")
+    public AjaxResult searchDebug(@RequestBody Map<String, Object> body) {
+        String collectionName = (String) body.get("collectionName");
+        String queryText = (String) body.get("queryText");
+        Integer topK = body.get("topK") instanceof Number
+                ? ((Number) body.get("topK")).intValue() : 5;
+
+        if (StringUtils.isBlank(collectionName)) {
+            return AjaxResult.error("collectionName 不能为空");
+        }
+        if (StringUtils.isBlank(queryText)) {
+            return AjaxResult.error("queryText 不能为空");
+        }
+
+        try {
+            Map<String, Object> searchReq = new LinkedHashMap<>();
+            searchReq.put("collectionName", collectionName);
+            searchReq.put("queryText", queryText);
+            searchReq.put("topK", topK);
+            Map<String, Object> resp = exchangeAiApi("/qdrant/collection/search-debug", Method.POST, searchReq);
+            // 解包 AI API 的 AjaxResult.data(避免双重嵌套)
+            Object data = resp.get("data");
+            return AjaxResult.success(data != null ? data : resp);
+        } catch (Exception e) {
+            log.error("检索调试失败: collectionName={}", collectionName, e);
+            return AjaxResult.error("检索调试失败: " + e.getMessage());
+        }
+    }
+
+    // ==================== 私有方法 ====================
+
+    /**
+     * 构建知识库列表:以 Qdrant collection 列表为主,MySQL 元数据仅做可选补充
+     * 只展示当前租户名下的知识库(collection 名以 {tenantCode}_ 开头)
+     */
+    @SuppressWarnings("unchecked")
+    private List<Map<String, Object>> buildKnowledgeBaseList(HttpServletRequest request) {
+        List<Map<String, Object>> result = new ArrayList<>();
+
+        // 获取租户编码
+        String tenantCode = request.getHeader("tenant-code");
+        String tenantPrefix = StringUtils.isNotBlank(tenantCode) ? tenantCode + "_" : null;
+
+        // 1. 获取 Qdrant collection 列表(主数据源)
+        List<Map<String, Object>> qdrantCollections = new ArrayList<>();
+        try {
+            Map<String, Object> resp = exchangeAiApi("/qdrant/collections", Method.GET, null);
+            Object data = resp.get("data");
+            if (data instanceof JSONArray) {
+                qdrantCollections = (List<Map<String, Object>>) (Object) JSONUtil.toList((JSONArray) data, Map.class);
+            }
+        } catch (Exception e) {
+            log.warn("获取 Qdrant collections 失败: {}", e.getMessage());
+            return result;
+        }
+
+        // 2. 尝试获取 MySQL 元数据(表不存在时跳过)
+        Map<String, LobsterKbMeta> metaMap = new LinkedHashMap<>();
+        try {
+            List<LobsterKbMeta> metaList = kbMetaService.listAll();
+            for (LobsterKbMeta meta : metaList) {
+                metaMap.put(meta.getCollectionName(), meta);
+            }
+        } catch (Exception e) {
+            log.warn("获取知识库元数据失败(表可能不存在),将使用默认值: {}", e.getMessage());
+        }
+
+        // 3. 合并数据:Qdrant collection + 元数据,按租户前缀过滤
+        for (Map<String, Object> collection : qdrantCollections) {
+            String name = (String) collection.get("name");
+
+            // 按租户前缀过滤
+            if (tenantPrefix != null) {
+                if (!name.startsWith(tenantPrefix)) {
+                    continue;
+                }
+            }
+
+            LobsterKbMeta meta = metaMap.get(name);
+
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("name", name);
+            // 截取租户前缀后的名称作为默认 displayName
+            String defaultDisplay = (tenantPrefix != null && name.startsWith(tenantPrefix))
+                    ? name.substring(tenantPrefix.length()) : name;
+            item.put("displayName", meta != null ? meta.getDisplayName() : defaultDisplay);
+            item.put("category", meta != null ? meta.getCategory() : "");
+            item.put("description", meta != null ? meta.getDescription() : "");
+            item.put("status", meta != null ? meta.getStatus() : 1);
+
+            // 尝试获取概览统计(从 Qdrant)
+            try {
+                Map<String, Object> overviewResp = exchangeAiApi("/qdrant/collection/overview?collectionName=" + name, Method.GET, null);
+                Object overviewData = overviewResp.get("data");
+                if (overviewData instanceof Map) {
+                    Map<String, Object> ov = (Map<String, Object>) overviewData;
+                    item.put("documentCount", ov.getOrDefault("documentCount", 0));
+                    item.put("chunkCount", ov.getOrDefault("chunkCount", 0));
+                } else {
+                    item.put("documentCount", 0);
+                    item.put("chunkCount", 0);
+                }
+            } catch (Exception e) {
+                log.warn("获取 collection overview 失败: name={}", name, e);
+                item.put("documentCount", 0);
+                item.put("chunkCount", 0);
+            }
+
+            result.add(item);
+        }
+
+        return result;
+    }
+
+    /**
+     * 调用 fs-ai-api 的 HTTP 接口
+     */
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> exchangeAiApi(String path, Method method, Map<String, Object> body) {
+        String url = aiApiBaseUrl + path;
+        HttpRequest request = new HttpRequest(url).method(method).header("Content-Type", "application/json");
+
+        if (body != null) {
+            request.body(JSONUtil.toJsonStr(body));
+        }
+
+        HttpResponse response = request.execute();
+        String respBody = response.body();
+
+        if (response.getStatus() < 200 || response.getStatus() >= 300) {
+            throw new RuntimeException("AI API HTTP " + response.getStatus() + " 调用失败: " + url);
+        }
+
+        try {
+            return JSONUtil.toBean(respBody, Map.class);
+        } catch (Exception e) {
+            return new LinkedHashMap<>();
+        }
+    }
+}

+ 135 - 0
java/fs-saas-company/src/main/java/com/fs/company/controller/company/WxMomentsTaskController.java

@@ -0,0 +1,135 @@
+package com.fs.company.controller.company;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import com.fs.company.domain.CompanyWxAccount;
+import com.fs.company.service.ICompanyWxAccountService;
+import com.fs.wxcid.domain.WxMomentsTask;
+import com.fs.wxcid.domain.WxMomentsTaskRecord;
+import com.fs.wxcid.service.IWxMomentsTaskService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 刷朋友圈任务(销售端)
+ * <p>字段与 wx-api 一致:创建需绑定个微(accountId 回填 wxNo),含 runCount/点赞评论/滚动等</p>
+ */
+@RestController
+@RequestMapping("/company/wxMomentsTask")
+public class WxMomentsTaskController extends BaseController {
+
+    @Autowired
+    private IWxMomentsTaskService wxMomentsTaskService;
+
+    @Autowired
+    private ICompanyWxAccountService companyWxAccountService;
+
+    @Autowired
+    private TokenService tokenService;
+
+    @PreAuthorize("@ss.hasPermi('company:wxMomentsTask:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(WxMomentsTask query) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        if (query.getCompanyId() == null && loginUser.getCompany() != null) {
+            query.setCompanyId(loginUser.getCompany().getCompanyId());
+        }
+        startPage();
+        List<WxMomentsTask> list = wxMomentsTaskService.selectWxMomentsTaskList(query);
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('company:wxMomentsTask:query')")
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable Long id) {
+        return AjaxResult.success(wxMomentsTaskService.selectWxMomentsTaskById(id));
+    }
+
+    @PreAuthorize("@ss.hasPermi('company:wxMomentsTask:add')")
+    @Log(title = "刷朋友圈任务", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody WxMomentsTask task) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        if (loginUser.getCompany() != null) {
+            task.setCompanyId(loginUser.getCompany().getCompanyId());
+        }
+        if (task.getCompanyUserId() == null && loginUser.getUser() != null) {
+            task.setCompanyUserId(loginUser.getUser().getUserId());
+        }
+        if (loginUser.getUser() != null) {
+            String operator = StringUtils.isNotEmpty(loginUser.getUser().getNickName())
+                    ? loginUser.getUser().getNickName() : loginUser.getUsername();
+            task.setCreateBy(operator);
+            task.setUpdateBy(operator);
+        }
+        fillAccountWxNo(task);
+        return toAjax(wxMomentsTaskService.insertWxMomentsTask(task));
+    }
+
+    @PreAuthorize("@ss.hasPermi('company:wxMomentsTask:edit')")
+    @Log(title = "刷朋友圈任务", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody WxMomentsTask task) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        if (loginUser != null && loginUser.getUser() != null) {
+            String operator = StringUtils.isNotEmpty(loginUser.getUser().getNickName())
+                    ? loginUser.getUser().getNickName() : loginUser.getUsername();
+            task.setUpdateBy(operator);
+        }
+        fillAccountWxNo(task);
+        return toAjax(wxMomentsTaskService.updateWxMomentsTask(task));
+    }
+
+    @PreAuthorize("@ss.hasPermi('company:wxMomentsTask:remove')")
+    @Log(title = "刷朋友圈任务", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        String operator = null;
+        if (loginUser != null && loginUser.getUser() != null) {
+            operator = StringUtils.isNotEmpty(loginUser.getUser().getNickName())
+                    ? loginUser.getUser().getNickName() : loginUser.getUsername();
+        }
+        return toAjax(wxMomentsTaskService.deleteWxMomentsTaskByIds(ids, operator));
+    }
+
+    @PreAuthorize("@ss.hasPermi('company:wxMomentsTask:list')")
+    @GetMapping("/records")
+    public TableDataInfo records(WxMomentsTaskRecord query) {
+        startPage();
+        List<WxMomentsTaskRecord> list = wxMomentsTaskService.selectRecordList(query);
+        return getDataTable(list);
+    }
+
+    /**
+     * 按个微账号回填 wxNo / companyUserId(与 wx-api 创建时绑定个微一致)
+     */
+    private void fillAccountWxNo(WxMomentsTask task) {
+        if (task == null || task.getAccountId() == null) {
+            return;
+        }
+        CompanyWxAccount account = companyWxAccountService.getById(task.getAccountId());
+        if (account == null) {
+            return;
+        }
+        if (StringUtils.isEmpty(task.getWxNo())) {
+            task.setWxNo(account.getWxNo());
+        }
+        if (task.getCompanyId() == null) {
+            task.setCompanyId(account.getCompanyId());
+        }
+        if (task.getCompanyUserId() == null) {
+            task.setCompanyUserId(account.getCompanyUserId());
+        }
+    }
+}

+ 129 - 0
java/fs-saas-company/src/main/java/com/fs/company/controller/company/WxPublishMomentsTaskController.java

@@ -0,0 +1,129 @@
+package com.fs.company.controller.company;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.CompanyWxAccount;
+import com.fs.company.service.ICompanyWxAccountService;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import com.fs.wxcid.domain.WxPublishMomentsTask;
+import com.fs.wxcid.service.IWxPublishMomentsTaskService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 发布朋友圈任务(销售端)
+ * <p>无单条查询接口;修改时若 completed 由 Service 拒绝</p>
+ */
+@RestController
+@RequestMapping("/company/wxPublishMomentsTask")
+public class WxPublishMomentsTaskController extends BaseController {
+
+    @Autowired
+    private IWxPublishMomentsTaskService wxPublishMomentsTaskService;
+
+    @Autowired
+    private ICompanyWxAccountService companyWxAccountService;
+
+    @Autowired
+    private TokenService tokenService;
+
+    @PreAuthorize("@ss.hasPermi('company:wxPublishMomentsTask:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(WxPublishMomentsTask query) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        if (query.getCompanyId() == null && loginUser.getCompany() != null) {
+            query.setCompanyId(loginUser.getCompany().getCompanyId());
+        }
+        startPage();
+        List<WxPublishMomentsTask> list = wxPublishMomentsTaskService.selectWxPublishMomentsTaskList(query);
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('company:wxPublishMomentsTask:add')")
+    @Log(title = "发布朋友圈任务", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody WxPublishMomentsTask task) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        if (loginUser.getCompany() != null) {
+            task.setCompanyId(loginUser.getCompany().getCompanyId());
+        }
+        if (task.getCompanyUserId() == null && loginUser.getUser() != null) {
+            task.setCompanyUserId(loginUser.getUser().getUserId());
+        }
+        if (loginUser.getUser() != null) {
+            String operator = StringUtils.isNotEmpty(loginUser.getUser().getNickName())
+                    ? loginUser.getUser().getNickName() : loginUser.getUsername();
+            task.setCreateBy(operator);
+            task.setUpdateBy(operator);
+        }
+        fillAccountWxNo(task);
+        return toAjax(wxPublishMomentsTaskService.insertWxPublishMomentsTask(task));
+    }
+
+    @PreAuthorize("@ss.hasPermi('company:wxPublishMomentsTask:edit')")
+    @Log(title = "发布朋友圈任务", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody WxPublishMomentsTask task) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        if (task == null || task.getId() == null) {
+            return AjaxResult.error("参数错误");
+        }
+        WxPublishMomentsTask existing = wxPublishMomentsTaskService.selectWxPublishMomentsTaskById(task.getId());
+        if (existing == null) {
+            return AjaxResult.error("任务不存在");
+        }
+        if (loginUser.getCompany() != null) {
+            task.setCompanyId(loginUser.getCompany().getCompanyId());
+        } else {
+            task.setCompanyId(existing.getCompanyId());
+        }
+        if (loginUser != null && loginUser.getUser() != null) {
+            String operator = StringUtils.isNotEmpty(loginUser.getUser().getNickName())
+                    ? loginUser.getUser().getNickName() : loginUser.getUsername();
+            task.setUpdateBy(operator);
+        }
+        fillAccountWxNo(task);
+        return toAjax(wxPublishMomentsTaskService.updateWxPublishMomentsTask(task));
+    }
+
+    @PreAuthorize("@ss.hasPermi('company:wxPublishMomentsTask:remove')")
+    @Log(title = "发布朋友圈任务", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        String operator = null;
+        if (loginUser != null && loginUser.getUser() != null) {
+            operator = StringUtils.isNotEmpty(loginUser.getUser().getNickName())
+                    ? loginUser.getUser().getNickName() : loginUser.getUsername();
+        }
+        return toAjax(wxPublishMomentsTaskService.deleteWxPublishMomentsTaskByIds(ids, operator));
+    }
+
+    private void fillAccountWxNo(WxPublishMomentsTask task) {
+        if (task == null || task.getAccountId() == null) {
+            return;
+        }
+        CompanyWxAccount account = companyWxAccountService.getById(task.getAccountId());
+        if (account == null) {
+            return;
+        }
+        if (StringUtils.isEmpty(task.getWxNo())) {
+            task.setWxNo(account.getWxNo());
+        }
+        if (task.getCompanyId() == null) {
+            task.setCompanyId(account.getCompanyId());
+        }
+        if (task.getCompanyUserId() == null) {
+            task.setCompanyUserId(account.getCompanyUserId());
+        }
+    }
+}

+ 155 - 0
java/fs-saas-company/src/main/java/com/fs/company/controller/external/CompanyExternalRoleController.java

@@ -0,0 +1,155 @@
+package com.fs.company.controller.external;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.utils.ServletUtils;
+import com.fs.company.domain.CompanyExternalRoleTemplate;
+import com.fs.company.domain.LobsterSalesExternalRole;
+import com.fs.company.mapper.LobsterSalesExternalRoleMapper;
+import com.fs.company.service.external.CompanyExternalRoleTemplateService;
+import com.fs.company.service.external.ExternalRoleConfig;
+import com.fs.company.service.workflow.identity.IdentityHidingService;
+import com.fs.company.service.workflow.identity.LobsterSalesSystemRoleCodec;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.util.StringUtils;
+import org.springframework.web.bind.annotation.*;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 销售端对外角色配置(租户销售端)
+ * <p>员工根据自身角色类型绑定公司预设的角色模板,可覆盖部分字段。
+ */
+@RestController
+@RequestMapping("/company/external-role")
+public class CompanyExternalRoleController extends BaseController {
+
+    private static final Logger logger = LoggerFactory.getLogger(CompanyExternalRoleController.class);
+
+    @Autowired
+    private CompanyExternalRoleTemplateService templateService;
+
+    @Autowired
+    private LobsterSalesExternalRoleMapper salesRoleMapper;
+
+    @Autowired
+    private TokenService tokenService;
+
+    @Autowired(required = false)
+    private IdentityHidingService identityHidingService;
+
+    /** 查询当前用户的角色配置 */
+    @GetMapping("/my-config")
+    public AjaxResult getMyConfig() {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getCompany().getCompanyId();
+        Long userId = loginUser.getUser().getUserId();
+
+        LobsterSalesExternalRole userRole = salesRoleMapper.selectByCompanyUser(companyId, userId);
+        ExternalRoleConfig resolved = templateService.resolveExternalRole(companyId, userId);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("userRole", userRole);
+        result.put("resolved", resolved);
+        result.put("configured", resolved != null && resolved.isValid());
+        return AjaxResult.success(result);
+    }
+
+    /** 查询公司可选的角色模板列表 */
+    @GetMapping("/templates")
+    public AjaxResult listTemplates() {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getCompany().getCompanyId();
+        List<CompanyExternalRoleTemplate> list = templateService.listEnabledByCompany(companyId);
+        return AjaxResult.success(list);
+    }
+
+    /** 保存/更新当前用户的角色配置 */
+    @PostMapping("/save")
+    public AjaxResult saveMyConfig(@RequestBody Map<String, Object> body) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getCompany().getCompanyId();
+        Long userId = loginUser.getUser().getUserId();
+
+        LobsterSalesExternalRole existing = salesRoleMapper.selectByCompanyUser(companyId, userId);
+
+        LobsterSalesExternalRole record = existing != null ? existing : new LobsterSalesExternalRole();
+        record.setCompanyId(companyId);
+        record.setCompanyUserId(userId);
+
+        if (body.get("templateId") != null) {
+            try {
+                record.setTemplateId(Long.valueOf(body.get("templateId").toString()));
+            } catch (NumberFormatException ignored) {}
+        }
+        if (body.get("roleId") != null) {
+            try {
+                record.setRoleId(Long.valueOf(body.get("roleId").toString()));
+            } catch (NumberFormatException ignored) {}
+        }
+        Object roleKey = body.get("roleKey");
+        if (roleKey != null) record.setRoleKey(roleKey.toString());
+
+        // 覆盖字段(可选)
+        Object displayName = body.get("displayName");
+        if (displayName != null) record.setDisplayName(displayName.toString());
+        Object systemRole = body.get("systemRole");
+        if (systemRole != null) {
+            // 保留已有 personaStyle,仅更新角色话术,统一写入 JSON
+            String encoded = LobsterSalesSystemRoleCodec.mergeEncode(
+                    existing != null ? existing.getSystemRole() : null,
+                    true, systemRole.toString(),
+                    false, null);
+            record.setSystemRole(encoded);
+        }
+        Object companyShortName = body.get("companyShortName");
+        if (companyShortName != null) record.setCompanyShortName(companyShortName.toString());
+        Object mainProduct = body.get("mainProduct");
+        if (mainProduct != null) record.setMainProduct(mainProduct.toString());
+        Object advisorRole = body.get("advisorRole");
+        if (advisorRole != null) record.setAdvisorRole(advisorRole.toString());
+
+        record.setEnabled(1);
+        record.setDelFlag(0);
+
+        int rows;
+        if (existing != null) {
+            rows = salesRoleMapper.updateById(record);
+        } else {
+            rows = salesRoleMapper.insert(record);
+        }
+
+        if (rows > 0) {
+            return AjaxResult.success("保存成功");
+        }
+        return AjaxResult.error("保存失败");
+    }
+
+    /** 查询当前用户的角色配置状态(用于工作流执行前校验) */
+    @GetMapping("/status")
+    public AjaxResult getMyStatus() {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getCompany().getCompanyId();
+        Long userId = loginUser.getUser().getUserId();
+
+        ExternalRoleConfig resolved = templateService.resolveExternalRole(companyId, userId);
+        boolean configured = resolved != null && resolved.isValid();
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("configured", configured);
+        if (configured) {
+            result.put("advisorName", resolved.getAdvisorName());
+            result.put("roleName", resolved.getRoleName());
+            result.put("source", resolved.getSource());
+        } else {
+            result.put("message", "请先在销售端配置对外角色");
+        }
+        return AjaxResult.success(result);
+    }
+}

+ 72 - 0
java/fs-saas-company/src/main/java/com/fs/company/controller/live/LiveVoteController.java

@@ -0,0 +1,72 @@
+package com.fs.company.controller.live;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.live.domain.LiveVote;
+import com.fs.live.service.ILiveVoteService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 直播投票(企业端)
+ *
+ * @author fs
+ * @date 2026-08-10
+ */
+@RestController
+@RequestMapping("/live/vote")
+public class LiveVoteController extends BaseController {
+
+    @Autowired
+    private ILiveVoteService liveVoteService;
+
+    @GetMapping("/list")
+    public TableDataInfo list(LiveVote liveVote) {
+        startPage();
+        List<LiveVote> list = liveVoteService.selectLiveVoteList(liveVote);
+        return getDataTable(list);
+    }
+
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id) {
+        return AjaxResult.success(liveVoteService.selectLiveVoteById(id));
+    }
+
+    @Log(title = "直播投票", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody LiveVote liveVote) {
+        liveVote.setCreateBy(getUsername());
+        return toAjax(liveVoteService.insertLiveVote(liveVote));
+    }
+
+    @Log(title = "直播投票", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody LiveVote liveVote) {
+        liveVote.setUpdateBy(getUsername());
+        return toAjax(liveVoteService.updateLiveVote(liveVote));
+    }
+
+    @Log(title = "直播投票", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids) {
+        return toAjax(liveVoteService.deleteLiveVoteByIds(ids));
+    }
+
+    @Log(title = "开始直播投票", businessType = BusinessType.UPDATE)
+    @PutMapping("/start/{id}")
+    public R start(@PathVariable("id") Long id) {
+        return liveVoteService.start(id);
+    }
+
+    @Log(title = "结束直播投票", businessType = BusinessType.UPDATE)
+    @PutMapping("/end/{id}")
+    public R end(@PathVariable("id") Long id) {
+        return liveVoteService.end(id);
+    }
+}

+ 66 - 0
java/fs-service/src/main/java/com/fs/company/domain/CompanyExternalRoleTemplate.java

@@ -0,0 +1,66 @@
+package com.fs.company.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 公司对外角色模板 — 表: company_external_role_template
+ * <p>每个公司可为不同员工角色类型(sys_role)创建对外角色范本。
+ * <p>销售端员工根据自身角色类型绑定对应模板,工作流执行时以销售端配置为准。
+ */
+@Data
+@TableName("company_external_role_template")
+public class CompanyExternalRoleTemplate {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 公司ID */
+    private Long companyId;
+
+    /** 关联sys_role.role_id */
+    private Long roleId;
+
+    /** 角色权限字符(冗余sys_role.role_key) */
+    private String roleKey;
+
+    /** 角色名称(冗余sys_role.role_name) */
+    private String roleName;
+
+    /** 对外显示名称(如小美) */
+    private String displayName;
+
+    /** 系统角色prompt(角色定位/人设描述) */
+    private String systemRole;
+
+    /** 公司简称/品牌 */
+    private String companyShortName;
+
+    /** 主推产品/服务 */
+    private String mainProduct;
+
+    /** 顾问角色描述(如金牌旅行顾问) */
+    private String advisorRole;
+
+    /** 是否默认角色: 0否 1是 */
+    private Integer isDefault;
+
+    /** 是否启用: 0禁用 1启用 */
+    private Integer enabled;
+
+    /** 排序 */
+    private Integer sortOrder;
+
+    /** 删除标志: 0存在 2删除 */
+    private Integer delFlag;
+
+    private String createBy;
+    private LocalDateTime createTime;
+    private String updateBy;
+    private LocalDateTime updateTime;
+    private String remark;
+}

+ 46 - 0
java/fs-service/src/main/java/com/fs/company/domain/CompanyUserVoiceConfig.java

@@ -0,0 +1,46 @@
+package com.fs.company.domain;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 销售默认音色配置 company_user_voice_config
+ */
+@Data
+public class CompanyUserVoiceConfig {
+
+    private Long id;
+
+    private Long companyId;
+
+    private Long companyUserId;
+
+    /** 音色ID,对应 cc_tts_aliyun.voice_code */
+    private String voiceId;
+
+    /** 试听/参考音频 URL */
+    private String audioUrl;
+
+    /** 音色厂商:1腾讯云 2阿里云 3豆包 */
+    private Integer type;
+
+    /** easycall.cc_tts_aliyun.id */
+    private Integer ttsId;
+
+    /** 状态:1正常 0停用 */
+    private Integer status;
+
+    /** 删除标志:0正常 1删除 */
+    private Integer delFlag;
+
+    private String createBy;
+
+    private Date createTime;
+
+    private String updateBy;
+
+    private Date updateTime;
+
+    private String remark;
+}

+ 37 - 0
java/fs-service/src/main/java/com/fs/company/domain/CrmManualOutboundUuidMap.java

@@ -0,0 +1,37 @@
+package com.fs.company.domain;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 手动外呼 uuid 映射(表 crm_manual_outbound_uuid_map)
+ * <p>status: PENDING / SYNCED / DEAD
+ */
+@Data
+public class CrmManualOutboundUuidMap implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    public static final String STATUS_PENDING = "PENDING";
+    public static final String STATUS_SYNCED = "SYNCED";
+    public static final String STATUS_DEAD = "DEAD";
+
+    private Long id;
+    /** EasyCall 通话 uuid */
+    private String uuid;
+    /** 客户ID,可空 */
+    private Long customerId;
+    private Long companyId;
+    private Long companyUserId;
+    /** SIP 外呼接口 ID(线路扣费锚定) */
+    private Long voiceApiId;
+    /** 3=线路 7=DTU */
+    private Integer callType;
+    /** PENDING / SYNCED / DEAD */
+    private String status;
+    private Integer retryCount;
+    private String lastError;
+    private Date createTime;
+    private Date updateTime;
+}

+ 47 - 0
java/fs-service/src/main/java/com/fs/company/domain/LobsterKnowledgeProbingMeta.java

@@ -0,0 +1,47 @@
+package com.fs.company.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 知识库追问维度元数据(路径A)。
+ * <p>
+ * 人工配置的「针对某条知识库内容需要了解的所有问题维度」。
+ * 当客户问题命中知识库时,系统按此表的维度优先级进行连续追问,
+ * 确保把企业标准化处理流程所需的信息都了解清楚。
+ * </p>
+ */
+@Data
+@TableName("lobster_knowledge_probing_meta")
+public class LobsterKnowledgeProbingMeta {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    /** 租户ID */
+    private Long companyId;
+    /** 关联知识库条目ID(对应 Qdrant point id 或业务知识ID) */
+    private String knowledgeId;
+    /** 知识标题(用于展示与日志) */
+    private String knowledgeTitle;
+    /** 维度 key(与行业槽位 key 对齐,如 customer_need/budget/symptom 等) */
+    private String dimensionKey;
+    /** 维度中文名 */
+    private String dimensionLabel;
+    /** 是否必填:缺少它就无法给出可靠方案/报价/预约/办理时为 1 */
+    private Integer required;
+    /** 内部追问意图(非客户原句,由模型组织问句) */
+    private String probeHint;
+    /** 维度类型:enum/number/string/range */
+    private String dimensionType;
+    /** 枚举选项(JSON 数组,如 ["选项1","选项2"]),仅 type=enum 时使用 */
+    private String dimensionOptions;
+    /** 优先级(越小越优先追问) */
+    private Integer priority;
+    /** 是否启用:1=启用,0=禁用 */
+    private Integer enabled;
+    private LocalDateTime createTime;
+    private LocalDateTime updateTime;
+}

+ 11 - 0
java/fs-service/src/main/java/com/fs/company/domain/LobsterSalesExternalRole.java

@@ -14,8 +14,19 @@ public class LobsterSalesExternalRole {
     private Long id;
     private Long companyId;
     private Long companyUserId;
+    /**
+     * 系统角色设定,JSON:{"systemRole":"角色话术","personaStyle":"warm"};兼容历史纯文本。
+     */
     private String systemRole;
     private String displayName;
+    private Long templateId;
+    private Long roleId;
+    private String roleKey;
+    private String companyShortName;
+    private String mainProduct;
+    private String advisorRole;
+    private Integer enabled;
+    private Integer delFlag;
     private LocalDateTime createTime;
     private LocalDateTime updateTime;
 }

+ 26 - 0
java/fs-service/src/main/java/com/fs/company/mapper/CompanyExternalRoleTemplateMapper.java

@@ -0,0 +1,26 @@
+package com.fs.company.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.company.domain.CompanyExternalRoleTemplate;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+/**
+ * 公司对外角色模板Mapper
+ */
+public interface CompanyExternalRoleTemplateMapper extends BaseMapper<CompanyExternalRoleTemplate> {
+
+    /** 查询公司所有启用的角色模板 */
+    @Select("SELECT * FROM company_external_role_template WHERE company_id = #{companyId} AND enabled = 1 AND del_flag = 0 ORDER BY sort_order, id")
+    List<CompanyExternalRoleTemplate> selectEnabledByCompany(@Param("companyId") Long companyId);
+
+    /** 按公司ID和角色ID查询模板 */
+    @Select("SELECT * FROM company_external_role_template WHERE company_id = #{companyId} AND role_id = #{roleId} AND del_flag = 0 LIMIT 1")
+    CompanyExternalRoleTemplate selectByCompanyAndRole(@Param("companyId") Long companyId, @Param("roleId") Long roleId);
+
+    /** 查询公司默认模板 */
+    @Select("SELECT * FROM company_external_role_template WHERE company_id = #{companyId} AND is_default = 1 AND enabled = 1 AND del_flag = 0 LIMIT 1")
+    CompanyExternalRoleTemplate selectDefaultByCompany(@Param("companyId") Long companyId);
+}

+ 17 - 0
java/fs-service/src/main/java/com/fs/company/mapper/CompanyUserVoiceConfigMapper.java

@@ -0,0 +1,17 @@
+package com.fs.company.mapper;
+
+import com.fs.company.domain.CompanyUserVoiceConfig;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * 销售默认音色配置 Mapper
+ */
+public interface CompanyUserVoiceConfigMapper {
+
+    CompanyUserVoiceConfig selectByCompanyIdAndUserId(@Param("companyId") Long companyId,
+                                                      @Param("companyUserId") Long companyUserId);
+
+    int insert(CompanyUserVoiceConfig config);
+
+    int updateById(CompanyUserVoiceConfig config);
+}

+ 30 - 0
java/fs-service/src/main/java/com/fs/company/mapper/CrmManualOutboundUuidMapMapper.java

@@ -0,0 +1,30 @@
+package com.fs.company.mapper;
+
+import com.fs.company.domain.CrmManualOutboundUuidMap;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 手动外呼 uuid 映射 Mapper(租户库)
+ */
+public interface CrmManualOutboundUuidMapMapper {
+
+    int insert(CrmManualOutboundUuidMap row);
+
+    int updateById(CrmManualOutboundUuidMap row);
+
+    CrmManualOutboundUuidMap selectByUuid(@Param("uuid") String uuid);
+
+    /**
+     * 查询待补偿记录
+     */
+    List<CrmManualOutboundUuidMap> selectPending(@Param("maxRetry") int maxRetry, @Param("limit") int limit);
+
+    /**
+     * 按公司+状态查询 PENDING(看板手动触发)
+     */
+    List<CrmManualOutboundUuidMap> selectPendingByCompany(@Param("companyId") Long companyId,
+                                                         @Param("maxRetry") int maxRetry,
+                                                         @Param("limit") int limit);
+}

+ 41 - 0
java/fs-service/src/main/java/com/fs/company/mapper/LobsterKnowledgeProbingMetaMapper.java

@@ -0,0 +1,41 @@
+package com.fs.company.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.company.domain.LobsterKnowledgeProbingMeta;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+/**
+ * 知识库追问维度元数据 Mapper(路径A)。
+ */
+public interface LobsterKnowledgeProbingMetaMapper extends BaseMapper<LobsterKnowledgeProbingMeta> {
+
+    /**
+     * 按知识库条目ID列表查询启用的追问维度,按 priority 升序。
+     */
+    @Select("<script>"
+            + "SELECT * FROM lobster_knowledge_probing_meta"
+            + " WHERE company_id = #{companyId}"
+            + " AND enabled = 1"
+            + " AND knowledge_id IN "
+            + "<foreach item='kid' collection='knowledgeIds' open='(' separator=',' close=')'>"
+            + "#{kid}"
+            + "</foreach>"
+            + " ORDER BY priority ASC, id ASC"
+            + "</script>")
+    List<LobsterKnowledgeProbingMeta> selectByKnowledgeIds(
+            @Param("companyId") Long companyId,
+            @Param("knowledgeIds") List<String> knowledgeIds);
+
+    /**
+     * 查询某条知识库条目启用的追问维度(单条快捷查询)。
+     */
+    @Select("SELECT * FROM lobster_knowledge_probing_meta"
+            + " WHERE company_id = #{companyId} AND knowledge_id = #{knowledgeId} AND enabled = 1"
+            + " ORDER BY priority ASC, id ASC")
+    List<LobsterKnowledgeProbingMeta> selectByKnowledgeId(
+            @Param("companyId") Long companyId,
+            @Param("knowledgeId") String knowledgeId);
+}

+ 18 - 0
java/fs-service/src/main/java/com/fs/company/mapper/LobsterSalesExternalRoleMapper.java

@@ -2,14 +2,32 @@ package com.fs.company.mapper;
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.fs.company.domain.LobsterSalesExternalRole;
+import com.fs.company.vo.lobster.LobsterCompanyIdentityConfigRow;
 import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
 
+import java.util.List;
+
 @Mapper
 public interface LobsterSalesExternalRoleMapper extends BaseMapper<LobsterSalesExternalRole> {
 
     @Select("SELECT * FROM lobster_sales_external_role WHERE company_id = #{companyId} AND company_user_id = #{companyUserId} LIMIT 1")
     LobsterSalesExternalRole selectByCompanyUser(@Param("companyId") Long companyId,
                                                  @Param("companyUserId") Long companyUserId);
+
+    /** aiV2:按销售 companyUserId 查对外角色(不按 companyId 过滤) */
+    @Select("SELECT * FROM lobster_sales_external_role WHERE company_user_id = #{companyUserId} AND enabled = 1 AND del_flag = 0 LIMIT 1")
+    LobsterSalesExternalRole selectByCompanyUserId(@Param("companyUserId") Long companyUserId);
+
+    @Select("SELECT COUNT(*) FROM lobster_sales_external_role WHERE company_id = #{companyId} AND system_role IS NOT NULL AND system_role != '' AND enabled = 1 AND del_flag = 0")
+    int countByCompanyWithRole(@Param("companyId") Long companyId);
+
+    @Select("SELECT * FROM lobster_sales_external_role WHERE company_id = #{companyId} AND company_user_id > 0 AND enabled = 1 AND del_flag = 0 ORDER BY id ASC LIMIT 1")
+    LobsterSalesExternalRole selectFirstEnabledByCompany(@Param("companyId") Long companyId);
+
+    /**
+     * 销售公司 LEFT JOIN 公司默认角色(company_user_id=0),SQL 见 XML。
+     */
+    List<LobsterCompanyIdentityConfigRow> selectCompanyDefaultIdentityList();
 }

+ 25 - 0
java/fs-service/src/main/java/com/fs/company/param/CrmManualOutboundUuidMapReportParam.java

@@ -0,0 +1,25 @@
+package com.fs.company.param;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 前端上报手动外呼 uuid 映射入参
+ */
+@Data
+public class CrmManualOutboundUuidMapReportParam implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /** EasyCall 通话 uuid(必填) */
+    private String uuid;
+    /** 客户ID,可空 */
+    private Long customerId;
+    private Long companyId;
+    private Long companyUserId;
+    private Long voiceApiId;
+    /**
+     * 拨号快照 callType:03/3=线路,07/7=DTU
+     */
+    private String callType;
+}

+ 30 - 0
java/fs-service/src/main/java/com/fs/company/service/ICrmManualOutboundUuidMapService.java

@@ -0,0 +1,30 @@
+package com.fs.company.service;
+
+import com.fs.company.domain.CrmManualOutboundUuidMap;
+import com.fs.company.param.CrmManualOutboundUuidMapReportParam;
+
+/**
+ * 手动外呼 uuid 映射服务:DB 为主、Redis 为辅(TTL 5 天)
+ */
+public interface ICrmManualOutboundUuidMapService {
+
+    /**
+     * 上报/upsert 映射(customerId 可空)
+     */
+    void report(CrmManualOutboundUuidMapReportParam param);
+
+    /**
+     * 按 uuid 查询(先 Redis 再 DB)
+     */
+    CrmManualOutboundUuidMap getByUuid(String uuid);
+
+    /**
+     * 标记已同步并删 Redis
+     */
+    void markSynced(String uuid);
+
+    /**
+     * 补偿失败:累加 retry,超限标 DEAD
+     */
+    void markRetryOrDead(CrmManualOutboundUuidMap map, String error, int maxRetry);
+}

+ 45 - 0
java/fs-service/src/main/java/com/fs/company/service/external/CompanyExternalRoleTemplateService.java

@@ -0,0 +1,45 @@
+package com.fs.company.service.external;
+
+import com.fs.company.domain.CompanyExternalRoleTemplate;
+
+import java.util.List;
+
+/**
+ * 公司对外角色模板服务
+ */
+public interface CompanyExternalRoleTemplateService {
+
+    /** 查询公司所有启用的角色模板 */
+    List<CompanyExternalRoleTemplate> listEnabledByCompany(Long companyId);
+
+    /** 查询公司所有角色模板(含禁用) */
+    List<CompanyExternalRoleTemplate> listAllByCompany(Long companyId);
+
+    /** 按ID查询 */
+    CompanyExternalRoleTemplate getById(Long id);
+
+    /** 新增 */
+    int insert(CompanyExternalRoleTemplate template);
+
+    /** 更新 */
+    int update(CompanyExternalRoleTemplate template);
+
+    /** 逻辑删除 */
+    int deleteById(Long id, String updateBy);
+
+    /** 按公司ID和角色ID查询 */
+    CompanyExternalRoleTemplate getByCompanyAndRole(Long companyId, Long roleId);
+
+    /** 查询公司默认模板 */
+    CompanyExternalRoleTemplate getDefaultByCompany(Long companyId);
+
+    /**
+     * 解析对外角色配置(工作流执行时调用)
+     * 优先级:销售端配置 > 公司角色模板 > null(调用方处理兜底)
+     *
+     * @param companyId      公司ID
+     * @param companyUserId  销售端用户ID
+     * @return 角色配置,如果未配置返回null
+     */
+    ExternalRoleConfig resolveExternalRole(Long companyId, Long companyUserId);
+}

+ 40 - 0
java/fs-service/src/main/java/com/fs/company/service/external/ExternalRoleConfig.java

@@ -0,0 +1,40 @@
+package com.fs.company.service.external;
+
+import lombok.Data;
+
+/**
+ * 对外角色配置(解析后的最终配置)
+ * <p>用于工作流执行时的角色定位、品牌变量等。
+ */
+@Data
+public class ExternalRoleConfig {
+
+    /** 顾问名称(对外显示名称) */
+    private String advisorName;
+
+    /** 公司简称/品牌 */
+    private String companyShortName;
+
+    /** 主推产品/服务 */
+    private String mainProduct;
+
+    /** 顾问角色描述(如金牌旅行顾问) */
+    private String advisorRole;
+
+    /** 系统角色prompt */
+    private String systemRole;
+
+    /** 角色类型名称(如销售/客服) */
+    private String roleName;
+
+    /** 角色权限字符 */
+    private String roleKey;
+
+    /** 来源: user(销售端) / template(公司模板) / legacy(旧版) */
+    private String source;
+
+    public boolean isValid() {
+        return (advisorName != null && !advisorName.isEmpty())
+                || (systemRole != null && !systemRole.isEmpty());
+    }
+}

+ 183 - 0
java/fs-service/src/main/java/com/fs/company/service/external/impl/CompanyExternalRoleTemplateServiceImpl.java

@@ -0,0 +1,183 @@
+package com.fs.company.service.external.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.fs.company.domain.CompanyExternalRoleTemplate;
+import com.fs.company.domain.LobsterSalesExternalRole;
+import com.fs.company.mapper.CompanyExternalRoleTemplateMapper;
+import com.fs.company.mapper.LobsterSalesExternalRoleMapper;
+import com.fs.company.service.external.CompanyExternalRoleTemplateService;
+import com.fs.company.service.external.ExternalRoleConfig;
+import com.fs.company.service.workflow.identity.LobsterSalesSystemRoleCodec;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.util.List;
+
+/**
+ * 公司对外角色模板服务实现
+ */
+@Service
+public class CompanyExternalRoleTemplateServiceImpl implements CompanyExternalRoleTemplateService {
+
+    private static final Logger logger = LoggerFactory.getLogger(CompanyExternalRoleTemplateServiceImpl.class);
+
+    @Autowired
+    private CompanyExternalRoleTemplateMapper templateMapper;
+
+    @Autowired(required = false)
+    private LobsterSalesExternalRoleMapper salesRoleMapper;
+
+    @Override
+    public List<CompanyExternalRoleTemplate> listEnabledByCompany(Long companyId) {
+        return templateMapper.selectEnabledByCompany(companyId);
+    }
+
+    @Override
+    public List<CompanyExternalRoleTemplate> listAllByCompany(Long companyId) {
+        LambdaQueryWrapper<CompanyExternalRoleTemplate> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(CompanyExternalRoleTemplate::getCompanyId, companyId)
+               .eq(CompanyExternalRoleTemplate::getDelFlag, 0)
+               .orderByAsc(CompanyExternalRoleTemplate::getSortOrder)
+               .orderByAsc(CompanyExternalRoleTemplate::getId);
+        return templateMapper.selectList(wrapper);
+    }
+
+    @Override
+    public CompanyExternalRoleTemplate getById(Long id) {
+        return templateMapper.selectById(id);
+    }
+
+    @Override
+    public int insert(CompanyExternalRoleTemplate template) {
+        if (template.getDelFlag() == null) template.setDelFlag(0);
+        if (template.getEnabled() == null) template.setEnabled(1);
+        if (template.getIsDefault() == null) template.setIsDefault(0);
+        if (template.getSortOrder() == null) template.setSortOrder(0);
+        return templateMapper.insert(template);
+    }
+
+    @Override
+    public int update(CompanyExternalRoleTemplate template) {
+        return templateMapper.updateById(template);
+    }
+
+    @Override
+    public int deleteById(Long id, String updateBy) {
+        CompanyExternalRoleTemplate update = new CompanyExternalRoleTemplate();
+        update.setId(id);
+        update.setDelFlag(2);
+        update.setUpdateBy(updateBy);
+        return templateMapper.updateById(update);
+    }
+
+    @Override
+    public CompanyExternalRoleTemplate getByCompanyAndRole(Long companyId, Long roleId) {
+        return templateMapper.selectByCompanyAndRole(companyId, roleId);
+    }
+
+    @Override
+    public CompanyExternalRoleTemplate getDefaultByCompany(Long companyId) {
+        return templateMapper.selectDefaultByCompany(companyId);
+    }
+
+    /**
+     * 解析对外角色配置(工作流执行时调用)
+     * <p>优先级:销售端配置(覆盖模板字段) > 公司角色模板 > null
+     */
+    @Override
+    public ExternalRoleConfig resolveExternalRole(Long companyId, Long companyUserId) {
+        if (companyId == null) {
+            return null;
+        }
+
+        // 1. 查询销售端用户角色配置
+        LobsterSalesExternalRole userRole = null;
+        if (salesRoleMapper != null && companyUserId != null) {
+            try {
+                userRole = salesRoleMapper.selectByCompanyUser(companyId, companyUserId);
+            } catch (Exception e) {
+                logger.warn("[ExternalRole] 查询销售端角色配置失败: companyId={}, userId={}, err={}",
+                        companyId, companyUserId, e.getMessage());
+            }
+        }
+
+        // 2. 查询公司角色模板
+        CompanyExternalRoleTemplate template = null;
+        if (userRole != null && userRole.getTemplateId() != null) {
+            template = templateMapper.selectById(userRole.getTemplateId());
+            // 检查模板是否有效(未删除)
+            if (template != null && template.getDelFlag() != null && template.getDelFlag() == 2) {
+                template = null;
+            }
+        }
+        if (template == null && userRole != null && userRole.getRoleId() != null) {
+            template = templateMapper.selectByCompanyAndRole(companyId, userRole.getRoleId());
+        }
+
+        // 3. 合并配置(销售端覆盖模板)
+        ExternalRoleConfig config = mergeConfig(userRole, template);
+
+        if (config != null) {
+            logger.info("[ExternalRole] 解析成功: companyId={}, userId={}, source={}, advisorName={}, roleKey={}",
+                    companyId, companyUserId, config.getSource(), config.getAdvisorName(), config.getRoleKey());
+        } else {
+            logger.warn("[ExternalRole] 未找到角色配置: companyId={}, userId={}", companyId, companyUserId);
+        }
+
+        return config;
+    }
+
+    /** 合并销售端配置和公司模板(销售端字段覆盖模板字段) */
+    private ExternalRoleConfig mergeConfig(LobsterSalesExternalRole userRole, CompanyExternalRoleTemplate template) {
+        if (userRole == null && template == null) {
+            return null;
+        }
+
+        ExternalRoleConfig config = new ExternalRoleConfig();
+
+        // 优先从模板填充
+        if (template != null) {
+            config.setAdvisorName(template.getDisplayName());
+            config.setCompanyShortName(template.getCompanyShortName());
+            config.setMainProduct(template.getMainProduct());
+            config.setAdvisorRole(template.getAdvisorRole());
+            config.setSystemRole(template.getSystemRole());
+            config.setRoleName(template.getRoleName());
+            config.setRoleKey(template.getRoleKey());
+            config.setSource("template");
+        }
+
+        // 销售端字段覆盖模板
+        if (userRole != null) {
+            if (StringUtils.hasText(userRole.getDisplayName())) {
+                config.setAdvisorName(userRole.getDisplayName());
+            }
+            if (StringUtils.hasText(userRole.getSystemRole())) {
+                String roleText = LobsterSalesSystemRoleCodec.extractText(userRole.getSystemRole());
+                if (StringUtils.hasText(roleText)) {
+                    config.setSystemRole(roleText);
+                }
+            }
+            if (StringUtils.hasText(userRole.getCompanyShortName())) {
+                config.setCompanyShortName(userRole.getCompanyShortName());
+            }
+            if (StringUtils.hasText(userRole.getMainProduct())) {
+                config.setMainProduct(userRole.getMainProduct());
+            }
+            if (StringUtils.hasText(userRole.getAdvisorRole())) {
+                config.setAdvisorRole(userRole.getAdvisorRole());
+            }
+            if (StringUtils.hasText(userRole.getRoleKey())) {
+                config.setRoleKey(userRole.getRoleKey());
+            }
+            if (template == null) {
+                config.setSource("user");
+            }
+        }
+
+        return config;
+    }
+}

+ 192 - 0
java/fs-service/src/main/java/com/fs/company/service/impl/CrmManualOutboundUuidMapServiceImpl.java

@@ -0,0 +1,192 @@
+package com.fs.company.service.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.company.domain.CrmManualOutboundUuidMap;
+import com.fs.company.mapper.CrmManualOutboundUuidMapMapper;
+import com.fs.company.param.CrmManualOutboundUuidMapReportParam;
+import com.fs.company.service.ICrmManualOutboundUuidMapService;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 手动外呼 uuid 映射:DB 为主、Redis 为辅
+ */
+@Slf4j
+@Service
+public class CrmManualOutboundUuidMapServiceImpl implements ICrmManualOutboundUuidMapService {
+
+    public static final String REDIS_KEY_PREFIX = "crm:manual:outbound:uuid:";
+    public static final int REDIS_TTL_DAYS = 5;
+
+    @Autowired
+    private CrmManualOutboundUuidMapMapper uuidMapMapper;
+    @Autowired
+    private RedisCache redisCache;
+
+    @Override
+    public void report(CrmManualOutboundUuidMapReportParam param) {
+        if (param == null || StringUtils.isBlank(param.getUuid())) {
+            throw new IllegalArgumentException("uuid不能为空");
+        }
+        if (param.getCompanyId() == null) {
+            throw new IllegalArgumentException("companyId不能为空");
+        }
+        Integer callType = resolveCallType(param.getCallType());
+        String uuid = param.getUuid().trim();
+        CrmManualOutboundUuidMap existing = uuidMapMapper.selectByUuid(uuid);
+        if (existing == null) {
+            CrmManualOutboundUuidMap row = new CrmManualOutboundUuidMap();
+            row.setUuid(uuid);
+            row.setCustomerId(param.getCustomerId());
+            row.setCompanyId(param.getCompanyId());
+            row.setCompanyUserId(param.getCompanyUserId());
+            row.setVoiceApiId(param.getVoiceApiId());
+            row.setCallType(callType);
+            row.setStatus(CrmManualOutboundUuidMap.STATUS_PENDING);
+            row.setRetryCount(0);
+            try {
+                uuidMapMapper.insert(row);
+                cachePut(row);
+                log.info("手动外呼uuid映射上报新增 uuid={}, callType={}, customerId={}",
+                        uuid, callType, param.getCustomerId());
+                return;
+            } catch (DuplicateKeyException e) {
+                // 并发双报:另一请求已插入同 uuid,转为 upsert 补全字段
+                existing = uuidMapMapper.selectByUuid(uuid);
+                if (existing == null) {
+                    throw e;
+                }
+                log.info("手动外呼uuid映射并发插入冲突,转为更新 uuid={}", uuid);
+            }
+        }
+        // upsert:补全空字段;已 SYNCED 仍允许补 customerId(不改状态)
+        boolean changed = false;
+        if (param.getCustomerId() != null && existing.getCustomerId() == null) {
+            existing.setCustomerId(param.getCustomerId());
+            changed = true;
+        }
+        if (param.getCompanyUserId() != null && existing.getCompanyUserId() == null) {
+            existing.setCompanyUserId(param.getCompanyUserId());
+            changed = true;
+        }
+        if (param.getVoiceApiId() != null && existing.getVoiceApiId() == null) {
+            existing.setVoiceApiId(param.getVoiceApiId());
+            changed = true;
+        }
+        if (callType != null && (existing.getCallType() == null || Integer.valueOf(3).equals(existing.getCallType()))) {
+            // 允许用更明确的 7 覆盖默认 3
+            if (!callType.equals(existing.getCallType())) {
+                existing.setCallType(callType);
+                changed = true;
+            }
+        }
+        if (CrmManualOutboundUuidMap.STATUS_DEAD.equals(existing.getStatus())
+                || CrmManualOutboundUuidMap.STATUS_SYNCED.equals(existing.getStatus())) {
+            // 重新上报:若仍无 CRM 记录场景由补偿处理;此处仅更新字段
+        } else if (!CrmManualOutboundUuidMap.STATUS_PENDING.equals(existing.getStatus())) {
+            existing.setStatus(CrmManualOutboundUuidMap.STATUS_PENDING);
+            changed = true;
+        }
+        if (changed) {
+            uuidMapMapper.updateById(existing);
+        }
+        cachePut(existing);
+        log.info("手动外呼uuid映射上报更新 uuid={}, changed={}", uuid, changed);
+    }
+
+    @Override
+    public CrmManualOutboundUuidMap getByUuid(String uuid) {
+        if (StringUtils.isBlank(uuid)) {
+            return null;
+        }
+        String key = REDIS_KEY_PREFIX + uuid.trim();
+        try {
+            Object cached = redisCache.getCacheObject(key);
+            if (cached instanceof CrmManualOutboundUuidMap) {
+                return (CrmManualOutboundUuidMap) cached;
+            }
+            if (cached instanceof String && StringUtils.isNotBlank((String) cached)) {
+                return JSON.parseObject((String) cached, CrmManualOutboundUuidMap.class);
+            }
+        } catch (Exception e) {
+            log.warn("读取手动外呼uuid映射Redis失败 uuid={}", uuid, e);
+        }
+        CrmManualOutboundUuidMap db = uuidMapMapper.selectByUuid(uuid.trim());
+        if (db != null) {
+            cachePut(db);
+        }
+        return db;
+    }
+
+    @Override
+    public void markSynced(String uuid) {
+        if (StringUtils.isBlank(uuid)) {
+            return;
+        }
+        CrmManualOutboundUuidMap existing = uuidMapMapper.selectByUuid(uuid.trim());
+        if (existing != null) {
+            existing.setStatus(CrmManualOutboundUuidMap.STATUS_SYNCED);
+            existing.setLastError("");
+            uuidMapMapper.updateById(existing);
+        }
+        try {
+            redisCache.deleteObject(REDIS_KEY_PREFIX + uuid.trim());
+        } catch (Exception e) {
+            log.warn("删除手动外呼uuid映射Redis失败 uuid={}", uuid, e);
+        }
+    }
+
+    @Override
+    public void markRetryOrDead(CrmManualOutboundUuidMap map, String error, int maxRetry) {
+        if (map == null || map.getId() == null) {
+            return;
+        }
+        int retry = map.getRetryCount() == null ? 0 : map.getRetryCount();
+        retry++;
+        map.setRetryCount(retry);
+        map.setLastError(error != null && error.length() > 500 ? error.substring(0, 500) : error);
+        if (retry >= maxRetry) {
+            map.setStatus(CrmManualOutboundUuidMap.STATUS_DEAD);
+        } else {
+            map.setStatus(CrmManualOutboundUuidMap.STATUS_PENDING);
+        }
+        uuidMapMapper.updateById(map);
+        cachePut(map);
+    }
+
+    private void cachePut(CrmManualOutboundUuidMap row) {
+        if (row == null || StringUtils.isBlank(row.getUuid())) {
+            return;
+        }
+        try {
+            redisCache.setCacheObject(REDIS_KEY_PREFIX + row.getUuid(), row,
+                    REDIS_TTL_DAYS, TimeUnit.DAYS);
+        } catch (Exception e) {
+            log.warn("写入手动外呼uuid映射Redis失败 uuid={}", row.getUuid(), e);
+        }
+    }
+
+    private Integer resolveCallType(String callType) {
+        if (StringUtils.isBlank(callType)) {
+            return 3;
+        }
+        String t = callType.trim();
+        if ("07".equals(t) || "7".equals(t)) {
+            return 7;
+        }
+        if ("03".equals(t) || "3".equals(t)) {
+            return 3;
+        }
+        try {
+            return Integer.valueOf(t);
+        } catch (NumberFormatException e) {
+            return 3;
+        }
+    }
+}

+ 262 - 0
java/fs-service/src/main/java/com/fs/company/service/workflow/deepdialogue/KnowledgeRetrievalResult.java

@@ -0,0 +1,262 @@
+package com.fs.company.service.workflow.deepdialogue;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 知识库检索结构化结果 — 同时承载「用于回答的纯文本」和「用于追问决策的维度元数据」。
+ * <p>
+ * 由分析报告《知识库驱动的连续追问可行性分析》提出,用于将知识库从「被动回答素材」
+ * 升级为「主动引导对话的结构化知识源」。
+ * </p>
+ * <p>
+ * 两种维度来源:
+ * <ul>
+ *   <li>路径A(精准):从 lobster_knowledge_probing_meta 表读取人工配置的 probingDimensions</li>
+ *   <li>路径B(灵活):LLM 阅读 kbContext 文本后动态推导追问维度(由 TopicProfileCollectionPlanner 完成)</li>
+ * </ul>
+ * </p>
+ */
+public class KnowledgeRetrievalResult {
+
+    /** 原有纯文本片段(用于注入回答 prompt,保持向下兼容) */
+    private String rawContext;
+
+    /** 命中的 KB 条目及元数据(路径A) */
+    private List<KnowledgeHit> hits;
+
+    /** 是否有可用于追问的维度元数据(路径A) */
+    private boolean hasProbingDimensions;
+
+    /** 命中的 KB 条目数量 */
+    private int hitCount;
+
+    public KnowledgeRetrievalResult() {
+        this.hits = new ArrayList<>();
+    }
+
+    public static KnowledgeRetrievalResult empty() {
+        KnowledgeRetrievalResult r = new KnowledgeRetrievalResult();
+        r.rawContext = "";
+        r.hits = Collections.emptyList();
+        r.hasProbingDimensions = false;
+        r.hitCount = 0;
+        return r;
+    }
+
+    /** 从 Map 列表(LobsterKnowledgeSearchService 返回格式)构建结构化结果 */
+    @SuppressWarnings("unchecked")
+    public static KnowledgeRetrievalResult fromSearchResults(List<Map<String, Object>> searchResults) {
+        KnowledgeRetrievalResult result = new KnowledgeRetrievalResult();
+        if (searchResults == null || searchResults.isEmpty()) {
+            return result.empty();
+        }
+        result.hitCount = searchResults.size();
+        StringBuilder sb = new StringBuilder();
+        List<KnowledgeHit> hitList = new ArrayList<>();
+        int idx = 1;
+        for (Map<String, Object> item : searchResults) {
+            Object q = item.get("q");
+            Object a = item.get("a");
+            Object id = item.get("id");
+            Object payload = item.get("payload");
+
+            StringBuilder kbText = new StringBuilder();
+            if (q != null && !q.toString().trim().isEmpty()) {
+                kbText.append("Q: ").append(q).append("\n");
+            }
+            if (a != null && !a.toString().trim().isEmpty()) {
+                kbText.append("A: ").append(a);
+            }
+            if (kbText.length() > 0) {
+                sb.append(idx++).append(". ").append(kbText).append("\n");
+            }
+
+            KnowledgeHit hit = new KnowledgeHit();
+            hit.knowledgeId = id != null ? id.toString() : null;
+            hit.title = q != null ? q.toString() : "";
+            hit.content = a != null ? a.toString() : "";
+
+            // 路径A:从 payload 解析 probing_dimensions(Qdrant payload 中携带)
+            if (payload instanceof Map) {
+                Map<String, Object> payloadMap = (Map<String, Object>) payload;
+                Object probingDims = payloadMap.get("probing_dimensions");
+                if (probingDims instanceof List) {
+                    hit.probingDimensions = parseProbingDimensions((List<Object>) probingDims);
+                }
+                Object dimOrder = payloadMap.get("dimension_priority");
+                if (dimOrder instanceof String && !((String) dimOrder).isBlank()) {
+                    hit.dimensionOrder = parseDimensionOrder((String) dimOrder);
+                }
+            }
+
+            if (hit.probingDimensions != null && !hit.probingDimensions.isEmpty()) {
+                result.hasProbingDimensions = true;
+            }
+            hitList.add(hit);
+        }
+        result.rawContext = sb.length() > 0 ? sb.toString() : "";
+        result.hits = hitList;
+        return result;
+    }
+
+    @SuppressWarnings("unchecked")
+    private static List<ProbingDimension> parseProbingDimensions(List<Object> rawList) {
+        if (rawList == null || rawList.isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<ProbingDimension> dims = new ArrayList<>();
+        for (Object item : rawList) {
+            if (!(item instanceof Map)) continue;
+            Map<String, Object> m = (Map<String, Object>) item;
+            ProbingDimension dim = new ProbingDimension();
+            dim.key = asString(m.get("key"));
+            dim.label = asString(m.get("label"));
+            dim.required = Boolean.TRUE.equals(m.get("required"));
+            dim.probeHint = asString(m.get("probe_hint"));
+            if (dim.probeHint == null || dim.probeHint.isEmpty()) {
+                dim.probeHint = asString(m.get("probeHint"));
+            }
+            dim.type = asString(m.get("type"));
+            Object options = m.get("options");
+            if (options instanceof List) {
+                dim.options = new ArrayList<>();
+                for (Object o : (List<?>) options) {
+                    dim.options.add(o != null ? o.toString() : "");
+                }
+            }
+            if (dim.key != null && !dim.key.isEmpty()) {
+                dims.add(dim);
+            }
+        }
+        return dims;
+    }
+
+    private static List<String> parseDimensionOrder(String orderStr) {
+        if (orderStr == null || orderStr.isBlank()) {
+            return Collections.emptyList();
+        }
+        List<String> result = new ArrayList<>();
+        for (String s : orderStr.split(",")) {
+            String trimmed = s.trim();
+            if (!trimmed.isEmpty()) {
+                result.add(trimmed);
+            }
+        }
+        return result;
+    }
+
+    private static String asString(Object o) {
+        return o != null ? o.toString() : null;
+    }
+
+    /**
+     * 合并所有命中 KB 的 required 维度,按 dimensionOrder 优先级排序。
+     * <p>用于 ConversationProbePlanner 优先级0 选择下一个追问维度。</p>
+     */
+    public List<ProbingDimension> mergeAndSortRequiredDimensions() {
+        if (hits == null || hits.isEmpty()) {
+            return Collections.emptyList();
+        }
+        // 收集所有 dimensionOrder(以第一个有配置的 hit 为准)
+        List<String> collectedOrder = null;
+        for (KnowledgeHit hit : hits) {
+            if (hit.dimensionOrder != null && !hit.dimensionOrder.isEmpty()) {
+                collectedOrder = hit.dimensionOrder;
+                break;
+            }
+        }
+        final List<String> order = collectedOrder;
+        Map<String, ProbingDimension> merged = new LinkedHashMap<>();
+        for (KnowledgeHit hit : hits) {
+            if (hit.probingDimensions == null) continue;
+            for (ProbingDimension dim : hit.probingDimensions) {
+                if (!dim.required) continue;
+                // 同 key 以先出现的为准(保留优先级)
+                merged.putIfAbsent(dim.key, dim);
+            }
+        }
+        List<ProbingDimension> result = new ArrayList<>(merged.values());
+        // 按 order 排序
+        if (order != null && !order.isEmpty()) {
+            final List<String> ord = new ArrayList<>(order);
+            result.sort(java.util.Comparator.comparingInt(dim -> {
+                int idx = ord.indexOf(dim.key);
+                return idx < 0 ? Integer.MAX_VALUE : idx;
+            }));
+        }
+        return result;
+    }
+
+    // ============ Getters / Setters ============
+
+    public String getRawContext() { return rawContext; }
+    public void setRawContext(String rawContext) { this.rawContext = rawContext; }
+
+    public List<KnowledgeHit> getHits() { return hits; }
+    public void setHits(List<KnowledgeHit> hits) { this.hits = hits; }
+
+    public boolean isHasProbingDimensions() { return hasProbingDimensions; }
+    public void setHasProbingDimensions(boolean hasProbingDimensions) {
+        this.hasProbingDimensions = hasProbingDimensions;
+    }
+
+    public int getHitCount() { return hitCount; }
+    public void setHitCount(int hitCount) { this.hitCount = hitCount; }
+
+    public boolean isEmpty() {
+        return (rawContext == null || rawContext.isEmpty()) && (hits == null || hits.isEmpty());
+    }
+
+    /** 命中的 KB 条目及元数据 */
+    public static class KnowledgeHit {
+        private String knowledgeId;
+        private String title;
+        private String content;
+        /** 该 KB 需要了解的维度(路径A元数据,可为空表示走路径B LLM推导) */
+        private List<ProbingDimension> probingDimensions;
+        /** 追问优先级顺序(key 列表) */
+        private List<String> dimensionOrder;
+
+        public String getKnowledgeId() { return knowledgeId; }
+        public void setKnowledgeId(String knowledgeId) { this.knowledgeId = knowledgeId; }
+        public String getTitle() { return title; }
+        public void setTitle(String title) { this.title = title; }
+        public String getContent() { return content; }
+        public void setContent(String content) { this.content = content; }
+        public List<ProbingDimension> getProbingDimensions() { return probingDimensions; }
+        public void setProbingDimensions(List<ProbingDimension> probingDimensions) {
+            this.probingDimensions = probingDimensions;
+        }
+        public List<String> getDimensionOrder() { return dimensionOrder; }
+        public void setDimensionOrder(List<String> dimensionOrder) {
+            this.dimensionOrder = dimensionOrder;
+        }
+    }
+
+    /** 追问维度定义 */
+    public static class ProbingDimension {
+        private String key;
+        private String label;
+        private boolean required;
+        private String probeHint;
+        private String type; // enum/number/string/range
+        private List<String> options;
+
+        public String getKey() { return key; }
+        public void setKey(String key) { this.key = key; }
+        public String getLabel() { return label; }
+        public void setLabel(String label) { this.label = label; }
+        public boolean isRequired() { return required; }
+        public void setRequired(boolean required) { this.required = required; }
+        public String getProbeHint() { return probeHint; }
+        public void setProbeHint(String probeHint) { this.probeHint = probeHint; }
+        public String getType() { return type; }
+        public void setType(String type) { this.type = type; }
+        public List<String> getOptions() { return options; }
+        public void setOptions(List<String> options) { this.options = options; }
+    }
+}

+ 165 - 0
java/fs-service/src/main/java/com/fs/company/service/workflow/identity/LobsterSalesSystemRoleCodec.java

@@ -0,0 +1,165 @@
+package com.fs.company.service.workflow.identity;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import org.springframework.util.StringUtils;
+
+/**
+ * 销售对外角色 {@code lobster_sales_external_role.system_role} 编解码。
+ * <p>存储格式:{@code {"systemRole":"角色话术","personaStyle":"warm"}}
+ * <br>兼容历史纯文本:整段视为角色话术,无人设风格。
+ */
+public final class LobsterSalesSystemRoleCodec {
+
+    public static final String KEY_SYSTEM_ROLE = "systemRole";
+    public static final String KEY_PERSONA_STYLE = "personaStyle";
+
+    private static final String[] ROLE_ALIASES = {KEY_SYSTEM_ROLE, "text", "role"};
+    private static final String[] STYLE_ALIASES = {KEY_PERSONA_STYLE, "persona_style"};
+
+    public static final Parsed EMPTY = new Parsed("", null);
+
+    private LobsterSalesSystemRoleCodec() {
+    }
+
+    /** 解析结果:角色话术 + 人设风格(可空) */
+    public static final class Parsed {
+        private final String systemRole;
+        private final String personaStyle;
+
+        public Parsed(String systemRole, String personaStyle) {
+            this.systemRole = systemRole != null ? systemRole : "";
+            this.personaStyle = StringUtils.hasText(personaStyle)
+                    ? LobsterPersonaStyleHints.normalizeStyle(personaStyle)
+                    : null;
+        }
+
+        public String getSystemRole() {
+            return systemRole;
+        }
+
+        public String getPersonaStyle() {
+            return personaStyle;
+        }
+
+        public boolean hasSystemRole() {
+            return StringUtils.hasText(systemRole);
+        }
+
+        public boolean hasPersonaStyle() {
+            return StringUtils.hasText(personaStyle);
+        }
+
+        public boolean isEmpty() {
+            return !hasSystemRole() && !hasPersonaStyle();
+        }
+
+        public Parsed withSystemRole(String role) {
+            return new Parsed(role, personaStyle);
+        }
+
+        public Parsed withPersonaStyle(String style) {
+            return new Parsed(systemRole, style);
+        }
+
+        /** 编码为 JSON;两者皆空时返回空串 */
+        public String encode() {
+            if (isEmpty()) {
+                return "";
+            }
+            JSONObject obj = new JSONObject(true);
+            obj.put(KEY_SYSTEM_ROLE, systemRole);
+            if (hasPersonaStyle()) {
+                obj.put(KEY_PERSONA_STYLE, personaStyle);
+            }
+            return obj.toJSONString();
+        }
+    }
+
+    public static Parsed parse(String raw) {
+        if (!StringUtils.hasText(raw)) {
+            return EMPTY;
+        }
+        String trimmed = raw.trim();
+        if (!trimmed.startsWith("{")) {
+            return new Parsed(trimmed, null);
+        }
+        try {
+            JSONObject obj = JSON.parseObject(trimmed);
+            if (obj == null || !looksLikePayload(obj)) {
+                return new Parsed(trimmed, null);
+            }
+            return new Parsed(readAliased(obj, ROLE_ALIASES, true), readAliased(obj, STYLE_ALIASES, false));
+        } catch (Exception ignored) {
+            return new Parsed(trimmed, null);
+        }
+    }
+
+    public static String extractText(String raw) {
+        return parse(raw).getSystemRole();
+    }
+
+    public static String extractPersonaStyle(String raw) {
+        return parse(raw).getPersonaStyle();
+    }
+
+    public static String encode(String systemRoleText, String personaStyle) {
+        return new Parsed(systemRoleText, personaStyle).encode();
+    }
+
+    /**
+     * 在已有列值上合并补丁:{@code patch*} 为 false 时保留原值。
+     */
+    public static Parsed merge(String existingRaw, boolean patchRole, String systemRoleText,
+                               boolean patchStyle, String personaStyle) {
+        Parsed next = parse(existingRaw);
+        if (patchRole) {
+            next = next.withSystemRole(systemRoleText != null ? systemRoleText.trim() : "");
+        }
+        if (patchStyle) {
+            next = next.withPersonaStyle(StringUtils.hasText(personaStyle) ? personaStyle : null);
+        }
+        return next;
+    }
+
+    /** @see #merge(String, boolean, String, boolean, String) */
+    public static String mergeEncode(String existingRaw, boolean patchRole, String systemRoleText,
+                                     boolean patchStyle, String personaStyle) {
+        return merge(existingRaw, patchRole, systemRoleText, patchStyle, personaStyle).encode();
+    }
+
+    private static boolean looksLikePayload(JSONObject obj) {
+        for (String key : ROLE_ALIASES) {
+            if (obj.containsKey(key)) {
+                return true;
+            }
+        }
+        for (String key : STYLE_ALIASES) {
+            if (obj.containsKey(key)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * @param allowBlank true 时允许返回空串(角色话术);
+     *                   false 时空值视为未配置(人设)
+     */
+    private static String readAliased(JSONObject obj, String[] keys, boolean allowBlank) {
+        for (String key : keys) {
+            if (!obj.containsKey(key)) {
+                continue;
+            }
+            Object v = obj.get(key);
+            if (v == null) {
+                return allowBlank ? "" : null;
+            }
+            String text = v.toString().trim();
+            if (StringUtils.hasText(text) || allowBlank) {
+                return text;
+            }
+        }
+        return allowBlank ? "" : null;
+    }
+}

+ 138 - 0
java/fs-service/src/main/java/com/fs/company/service/workflow/knowledge/LobsterKnowledgeProbingMetaService.java

@@ -0,0 +1,138 @@
+package com.fs.company.service.workflow.knowledge;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.company.domain.LobsterKnowledgeProbingMeta;
+import com.fs.company.mapper.LobsterKnowledgeProbingMetaMapper;
+import com.fs.company.service.workflow.deepdialogue.KnowledgeRetrievalResult;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 知识库追问维度元数据查询服务(路径A)。
+ * <p>
+ * 从 {@code lobster_knowledge_probing_meta} 表读取人工配置的追问维度,
+ * 将其转换为 {@link KnowledgeRetrievalResult.ProbingDimension} 列表,
+ * 供 {@code ConversationProbePlanner.planKbDrivenProbe} 按优先级消费。
+ * </p>
+ * <p>
+ * 与路径B 的区别:路径A 维度是人工配置的标准化流程,精确可控;
+ * 路径B 由 LLM 阅读 KB 内容后动态推导,灵活但需要 LLM 调用开销。
+ * </p>
+ */
+@Service
+public class LobsterKnowledgeProbingMetaService {
+
+    private static final Logger log = LoggerFactory.getLogger(LobsterKnowledgeProbingMetaService.class);
+
+    @Autowired(required = false)
+    private LobsterKnowledgeProbingMetaMapper probingMetaMapper;
+
+    /**
+     * 根据知识库命中条目ID列表,查询并组装追问维度元数据。
+     * <p>
+     * 用于增强 {@link KnowledgeRetrievalResult}:将路径A 的元数据维度挂载到对应的 hit 上。
+     * </p>
+     *
+     * @param companyId    租户ID
+     * @param knowledgeIds 命中的知识库条目ID列表
+     * @return 按 knowledgeId 分组的维度列表;无配置时返回空 Map
+     */
+    public Map<String, List<KnowledgeRetrievalResult.ProbingDimension>> loadDimensionsByKnowledgeIds(
+            Long companyId, List<String> knowledgeIds) {
+        if (probingMetaMapper == null || companyId == null
+                || knowledgeIds == null || knowledgeIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        try {
+            List<LobsterKnowledgeProbingMeta> metas =
+                    probingMetaMapper.selectByKnowledgeIds(companyId, knowledgeIds);
+            if (metas == null || metas.isEmpty()) {
+                return Collections.emptyMap();
+            }
+            Map<String, List<KnowledgeRetrievalResult.ProbingDimension>> result = new LinkedHashMap<>();
+            for (LobsterKnowledgeProbingMeta meta : metas) {
+                if (meta.getKnowledgeId() == null || meta.getDimensionKey() == null) {
+                    continue;
+                }
+                KnowledgeRetrievalResult.ProbingDimension dim = toProbingDimension(meta);
+                result.computeIfAbsent(meta.getKnowledgeId(), k -> new ArrayList<>()).add(dim);
+            }
+            log.debug("[KB-ProbingMeta] loaded {} dimensions for {} knowledge ids | companyId={}",
+                    result.values().stream().mapToInt(List::size).sum(),
+                    result.size(), companyId);
+            return result;
+        } catch (Exception e) {
+            log.warn("[KB-ProbingMeta] load failed: {}", e.getMessage());
+            return Collections.emptyMap();
+        }
+    }
+
+    /**
+     * 将元数据维度增强到 {@link KnowledgeRetrievalResult} 的 hits 上。
+     * <p>
+     * 在 {@code LobsterKnowledgeSearchService} 完成 KB 检索后调用此方法,
+     * 为每个 hit 补充路径A 的追问维度元数据。
+     * </p>
+     *
+     * @param companyId    租户ID
+     * @param kbResult     KB 检索结构化结果(会被原地修改)
+     */
+    public void enrichKnowledgeRetrievalResult(Long companyId, KnowledgeRetrievalResult kbResult) {
+        if (kbResult == null || kbResult.getHits() == null || kbResult.getHits().isEmpty()) {
+            return;
+        }
+        List<String> knowledgeIds = new ArrayList<>();
+        for (KnowledgeRetrievalResult.KnowledgeHit hit : kbResult.getHits()) {
+            if (hit.getKnowledgeId() != null && !hit.getKnowledgeId().isBlank()) {
+                knowledgeIds.add(hit.getKnowledgeId());
+            }
+        }
+        if (knowledgeIds.isEmpty()) {
+            return;
+        }
+        Map<String, List<KnowledgeRetrievalResult.ProbingDimension>> dimMap =
+                loadDimensionsByKnowledgeIds(companyId, knowledgeIds);
+        if (dimMap.isEmpty()) {
+            return;
+        }
+        boolean anyEnriched = false;
+        for (KnowledgeRetrievalResult.KnowledgeHit hit : kbResult.getHits()) {
+            List<KnowledgeRetrievalResult.ProbingDimension> dims =
+                    dimMap.get(hit.getKnowledgeId());
+            if (dims != null && !dims.isEmpty()) {
+                hit.setProbingDimensions(dims);
+                anyEnriched = true;
+            }
+        }
+        if (anyEnriched) {
+            kbResult.setHasProbingDimensions(true);
+        }
+    }
+
+    private static KnowledgeRetrievalResult.ProbingDimension toProbingDimension(
+            LobsterKnowledgeProbingMeta meta) {
+        KnowledgeRetrievalResult.ProbingDimension dim = new KnowledgeRetrievalResult.ProbingDimension();
+        dim.setKey(meta.getDimensionKey());
+        dim.setLabel(meta.getDimensionLabel());
+        dim.setRequired(meta.getRequired() != null && meta.getRequired() == 1);
+        dim.setProbeHint(meta.getProbeHint());
+        dim.setType(meta.getDimensionType());
+        if (meta.getDimensionOptions() != null && !meta.getDimensionOptions().isBlank()) {
+            try {
+                List<String> options = JSON.parseArray(meta.getDimensionOptions(), String.class);
+                dim.setOptions(options);
+            } catch (Exception e) {
+                log.debug("[KB-ProbingMeta] parse options failed: {}", e.getMessage());
+            }
+        }
+        return dim;
+    }
+}

+ 15 - 0
java/fs-service/src/main/java/com/fs/company/vo/lobster/LobsterCompanyIdentityConfigRow.java

@@ -0,0 +1,15 @@
+package com.fs.company.vo.lobster;
+
+import lombok.Data;
+
+/**
+ * Join row: company LEFT JOIN company-default role (raw system_role JSON).
+ */
+@Data
+public class LobsterCompanyIdentityConfigRow {
+
+    private Long companyId;
+    private String companyName;
+    /** Raw lobster_sales_external_role.system_role */
+    private String systemRoleRaw;
+}

+ 8 - 0
java/fs-service/src/main/java/com/fs/course/service/IFsCourseQuestionBankService.java

@@ -5,6 +5,9 @@ import com.fs.course.domain.FsCourseQuestionBank;
 import com.fs.course.dto.FsCourseQuestionBankImportDTO;
 import com.fs.course.dto.ImportResultDTO;
 import com.fs.course.param.FsCourseQuestionAnswerUParam;
+import com.fs.live.domain.LiveWatchConfig;
+import com.fs.live.param.LiveCompletionCouponAnswerParam;
+import com.fs.live.vo.LiveCompletionCouponAnswerResult;
 
 import javax.validation.constraints.Size;
 import java.util.List;
@@ -93,4 +96,9 @@ public interface IFsCourseQuestionBankService
     R courseAnswerByFsUser(FsCourseQuestionAnswerUParam param);
 
     R courseAnswerIsOpen(FsCourseQuestionAnswerUParam param, boolean isH5User);
+
+    /**
+     * 提交今日问题作答(仅记录答题结果,不发券)
+     */
+    LiveCompletionCouponAnswerResult submitAnswers(LiveCompletionCouponAnswerParam param, Long userId, LiveWatchConfig config);
 }

+ 195 - 0
java/fs-service/src/main/java/com/fs/course/service/impl/FsCourseQuestionBankServiceImpl.java

@@ -6,6 +6,7 @@ import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.fs.common.core.domain.R;
 import com.fs.common.exception.ServiceException;
+import com.fs.common.exception.base.BaseException;
 import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.course.config.CourseConfig;
@@ -18,6 +19,13 @@ import com.fs.course.service.IFsCourseQuestionBankService;
 import com.fs.his.domain.FsUser;
 import com.fs.his.mapper.FsUserMapper;
 import com.fs.his.service.IFsStorePaymentService;
+import com.fs.live.domain.LiveWatchConfig;
+import com.fs.live.param.LiveCompletionCouponAnswerItem;
+import com.fs.live.param.LiveCompletionCouponAnswerParam;
+import com.fs.live.service.ILiveCompletionAnswerRecordService;
+import com.fs.live.vo.AnswerEvaluation;
+import com.fs.live.vo.LiveCompletionAnswerDetailVO;
+import com.fs.live.vo.LiveCompletionCouponAnswerResult;
 import com.fs.system.service.ISysConfigService;
 import lombok.Getter;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -59,6 +67,9 @@ public class FsCourseQuestionBankServiceImpl implements IFsCourseQuestionBankSer
     @Autowired
     private FsUserCourseCategoryMapper courseCategoryMapper;
 
+    @Autowired
+    private ILiveCompletionAnswerRecordService liveCompletionAnswerRecordService;
+
     /**
      * 查询题库
      *
@@ -843,4 +854,188 @@ public class FsCourseQuestionBankServiceImpl implements IFsCourseQuestionBankSer
         String cleanString = inputString.replaceAll("[\\[\\]\"\\s]", "");
         return cleanString.split(",");
     }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public LiveCompletionCouponAnswerResult submitAnswers(LiveCompletionCouponAnswerParam param, Long userId, LiveWatchConfig config) {
+        if (param == null || param.getLiveId() == null) {
+            throw new BaseException("参数错误");
+        }
+        if (param.getAnswers() == null || param.getAnswers().isEmpty()) {
+            throw new BaseException("请完成今日问题");
+        }
+        if (StringUtils.isEmpty(config.getFinishQuestionIds())) {
+            throw new BaseException("未设置题目");
+        }
+
+        int i = liveCompletionAnswerRecordService.countLiveCompletionAnswerRecordByLiveUserId(param.getLiveId(), userId);
+        if (i >= config.getAnswerAttempts()) {
+            LiveCompletionCouponAnswerResult result = new LiveCompletionCouponAnswerResult();
+            result.setAllCorrect(false);
+            result.setRecordId(null);
+            result.setAnswerNumOver(true);
+            result.setCorrectNum(0);
+            return result;
+        }
+
+        List<Long> configuredQuestionIds = Arrays.stream(config.getFinishQuestionIds().split(",")).map(Long::valueOf).collect(Collectors.toList());
+        List<LiveCompletionCouponAnswerItem> normalizedAnswers = normalizeUserAnswers(param.getAnswers());
+        AnswerEvaluation evaluation = evaluateAnswersWithDetails(normalizedAnswers, configuredQuestionIds);
+        Long recordId = liveCompletionAnswerRecordService.saveAnswerRecord(
+                param.getLiveId(), userId, evaluation.isAllCorrect(), normalizedAnswers, evaluation.getDetails());
+
+        LiveCompletionCouponAnswerResult result = new LiveCompletionCouponAnswerResult();
+        result.setAllCorrect(evaluation.isAllCorrect());
+        result.setRecordId(recordId);
+        result.setAnswerNumOver(false);
+        result.setCorrectNum(Math.max(0, config.getAnswerAttempts() - (i + 1)));
+        return result;
+    }
+
+    private List<LiveCompletionCouponAnswerItem> normalizeUserAnswers(List<LiveCompletionCouponAnswerItem> rawAnswers) {
+        if (rawAnswers == null || rawAnswers.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        boolean flatOptionFormat = rawAnswers.stream().anyMatch(a -> a.getIsAnswer() != null)
+                || rawAnswers.stream()
+                .filter(a -> a.getQuestionId() != null)
+                .collect(Collectors.groupingBy(LiveCompletionCouponAnswerItem::getQuestionId))
+                .values().stream().anyMatch(list -> list.size() > 1);
+        if (!flatOptionFormat) {
+            return rawAnswers;
+        }
+
+        Map<Long, List<LiveCompletionCouponAnswerItem>> grouped = rawAnswers.stream()
+                .filter(a -> a.getQuestionId() != null)
+                .collect(Collectors.groupingBy(LiveCompletionCouponAnswerItem::getQuestionId));
+
+        List<Long> questionIds = new ArrayList<>(grouped.keySet());
+        Map<Long, FsCourseQuestionBank> questionMap = selectFsCourseQuestionBankByIds(questionIds)
+                .stream()
+                .collect(Collectors.toMap(FsCourseQuestionBank::getId, q -> q, (a, b) -> a));
+
+        List<LiveCompletionCouponAnswerItem> normalized = new ArrayList<>();
+        for (Map.Entry<Long, List<LiveCompletionCouponAnswerItem>> entry : grouped.entrySet()) {
+            Long questionId = entry.getKey();
+            List<LiveCompletionCouponAnswerItem> options = entry.getValue();
+
+            List<LiveCompletionCouponAnswerItem> selected = options.stream()
+                    .filter(a -> a.getIsAnswer() != null && a.getIsAnswer() == 1)
+                    .collect(Collectors.toList());
+
+            LiveCompletionCouponAnswerItem item = new LiveCompletionCouponAnswerItem();
+            item.setQuestionId(questionId);
+
+            if (selected.isEmpty()) {
+                if (options.size() == 1) {
+                    item.setAnswer(resolveAnswerText(options.get(0)));
+                    normalized.add(item);
+                }
+                continue;
+            }
+
+            FsCourseQuestionBank question = questionMap.get(questionId);
+            boolean multiChoice = question != null && question.getType() != null && question.getType() == 2L;
+
+            if (multiChoice) {
+                List<String> names = selected.stream()
+                        .map(this::resolveAnswerText)
+                        .filter(StringUtils::isNotEmpty)
+                        .collect(Collectors.toList());
+                item.setAnswer(JSON.toJSONString(names));
+            } else {
+                item.setAnswer(resolveAnswerText(selected.get(0)));
+            }
+            normalized.add(item);
+        }
+        return normalized;
+    }
+
+    private String resolveAnswerText(LiveCompletionCouponAnswerItem item) {
+        if (item == null) {
+            return null;
+        }
+        if (StringUtils.isNotEmpty(item.getAnswerName())) {
+            return item.getAnswerName().trim();
+        }
+        return StringUtils.isEmpty(item.getAnswer()) ? null : item.getAnswer().trim();
+    }
+
+    private AnswerEvaluation evaluateAnswersWithDetails(List<LiveCompletionCouponAnswerItem> userAnswers,
+                                                        List<Long> configuredQuestionIds) {
+        Set<Long> submittedIds = userAnswers.stream()
+                .map(LiveCompletionCouponAnswerItem::getQuestionId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        if (submittedIds.size() != configuredQuestionIds.size()
+                || !submittedIds.containsAll(configuredQuestionIds)) {
+            throw new BaseException("请完成全部今日问题");
+        }
+
+        Map<Long, FsCourseQuestionBank> correctAnswersMap =
+                selectFsCourseQuestionBankByIds(new ArrayList<>(submittedIds))
+                .stream()
+                .collect(Collectors.toMap(FsCourseQuestionBank::getId, q -> q));
+
+        boolean allCorrect = true;
+        List<LiveCompletionAnswerDetailVO> details = new ArrayList<>();
+        for (LiveCompletionCouponAnswerItem userAnswer : userAnswers) {
+            FsCourseQuestionBank correctAnswer = correctAnswersMap.get(userAnswer.getQuestionId());
+            if (correctAnswer == null || correctAnswer.getStatus() == null || correctAnswer.getStatus() == 0) {
+                throw new BaseException("题目不存在或已停用");
+            }
+            boolean questionCorrect = isAnswerCorrect(userAnswer.getAnswer(), correctAnswer);
+            if (!questionCorrect) {
+                allCorrect = false;
+            }
+
+            LiveCompletionAnswerDetailVO detail = new LiveCompletionAnswerDetailVO();
+            detail.setQuestionId(userAnswer.getQuestionId());
+            detail.setTitle(correctAnswer.getTitle());
+            detail.setType(correctAnswer.getType());
+            detail.setUserAnswer(userAnswer.getAnswer());
+            detail.setCorrectAnswer(correctAnswer.getAnswer());
+            detail.setIsRight(questionCorrect ? 1 : 0);
+            details.add(detail);
+        }
+        return new AnswerEvaluation(allCorrect, details);
+    }
+
+    private boolean isAnswerCorrect(String userAnswerValue, FsCourseQuestionBank correctAnswer) {
+        if (correctAnswer.getType() == null || correctAnswer.getType() == 1L) {
+            return Objects.equals(normalizeSingleAnswer(userAnswerValue), normalizeSingleAnswer(correctAnswer.getAnswer()));
+        }
+        String[] userAnswers = parseAnswerArray(userAnswerValue);
+        String[] correctAnswers = parseAnswerArray(correctAnswer.getAnswer());
+        Arrays.sort(userAnswers);
+        Arrays.sort(correctAnswers);
+        return Arrays.equals(userAnswers, correctAnswers);
+    }
+
+    private String normalizeSingleAnswer(String answer) {
+        if (StringUtils.isEmpty(answer)) {
+            return answer;
+        }
+        String trimmed = answer.trim();
+        if (trimmed.startsWith("[")) {
+            List<String> list = JSON.parseArray(trimmed, String.class);
+            if (list != null && !list.isEmpty()) {
+                return list.get(0);
+            }
+        }
+        return trimmed;
+    }
+
+    private String[] parseAnswerArray(String answer) {
+        if (StringUtils.isEmpty(answer)) {
+            return new String[0];
+        }
+        String trimmed = answer.trim();
+        if (trimmed.startsWith("[")) {
+            List<String> list = JSON.parseArray(trimmed, String.class);
+            return list == null ? new String[0] : list.toArray(new String[0]);
+        }
+        return trimmed.split(",");
+    }
 }

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

@@ -55,4 +55,10 @@ public class FsCourseCouponUser {
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     @TableField(fill = FieldFill.INSERT_UPDATE)
     private Date updateTime;
+
+    /** 发放类型 */
+    private Integer sendType;
+
+    /** 直播间ID */
+    private Long liveId;
 }

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

@@ -193,6 +193,9 @@ public class FsUser extends BaseEntity
     /** app登录后不为null(表示是否下载app) */
     private String historyApp;
 
+    /** App openId */
+    private String appOpenId;
+
     public void setNickName(String nickname)
     {
         if(StringUtils.isNotEmpty(nickname)){

+ 2 - 0
java/fs-service/src/main/java/com/fs/his/service/IFsStorePaymentService.java

@@ -135,4 +135,6 @@ public interface IFsStorePaymentService
     String v3TransferNotify(String notifyData, HttpServletRequest request);
 
     String v3TransferNotifyWithCompanyId(Long companyId, String notifyData, HttpServletRequest request);
+
+    R sendAppLiveRedPacket(WxSendRedPacketParam packetParam);
 }

+ 5 - 0
java/fs-service/src/main/java/com/fs/his/service/impl/FsStorePaymentServiceImpl.java

@@ -596,6 +596,11 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
         return service.sendRedPacketLimit(param);
     }
 
+    @Override
+    public R sendAppLiveRedPacket(WxSendRedPacketParam packetParam) {
+        return sendRedPacket(packetParam);
+    }
+
 
     @Override
     public R sendRedPacketLimit(WxSendRedPacketParam param){

+ 47 - 0
java/fs-service/src/main/java/com/fs/live/domain/LiveCompletionAnswerRecord.java

@@ -0,0 +1,47 @@
+package com.fs.live.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 直播完课答题记录对象 live_completion_answer_record
+ */
+@Data
+@TableName("live_completion_answer_record")
+public class LiveCompletionAnswerRecord implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    @Excel(name = "直播ID")
+    private Long liveId;
+
+    @Excel(name = "用户ID")
+    private Long userId;
+
+    @Excel(name = "用户名称")
+    private String userName;
+
+    /** 是否全部正确 0否 1是 */
+    @Excel(name = "是否全部正确", readConverterExp = "0=否,1=是")
+    private Integer isRight;
+
+    /** 用户作答原始 JSON */
+    private String answerJson;
+
+    /** 题目及作答明细 JSON */
+    private String questionJson;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @Excel(name = "答题时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+}

+ 22 - 0
java/fs-service/src/main/java/com/fs/live/domain/LiveCourseQuestionRel.java

@@ -0,0 +1,22 @@
+package com.fs.live.domain;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 直播间与课程题库试题关联
+ */
+@Data
+public class LiveCourseQuestionRel {
+
+    private Long relId;
+
+    private Long liveId;
+
+    private Long questionBankId;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+}

+ 30 - 0
java/fs-service/src/main/java/com/fs/live/domain/LiveQuickComment.java

@@ -0,0 +1,30 @@
+package com.fs.live.domain;
+
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 直播一键评论内容库对象 live_quick_comment
+ *
+ * @author fs
+ * @date 2026-08-10
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class LiveQuickComment extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 主键 */
+    private Long id;
+
+    /** 快捷评论文案 */
+    private String content;
+
+    /** 排序,越小越靠前 */
+    private Integer sortOrder;
+
+    /** 删除标记 0正常 1删除 */
+    private Integer delFlag;
+}

+ 84 - 0
java/fs-service/src/main/java/com/fs/live/domain/LiveRedPacketLog.java

@@ -0,0 +1,84 @@
+package com.fs.live.domain;
+
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.EqualsAndHashCode;
+
+import java.math.BigDecimal;
+
+/**
+ * 直播红包 记录对象 live_red_packet_log
+ *
+ * @author fs
+ * @date 2026-06-08
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class LiveRedPacketLog extends BaseEntity{
+
+    /** 日志id */
+    private Long logId;
+
+    /** 批次单号 */
+    @Excel(name = "批次单号")
+    private String outBatchNo;
+
+    /** 直播间id */
+    @Excel(name = "直播间id")
+    private Long liveId;
+
+    /** 用户id */
+    @Excel(name = "用户id")
+    private Long userId;
+
+    /** 公司员工id */
+    @Excel(name = "公司员工id")
+    private Long companyUserId;
+
+    /** 公司id */
+    @Excel(name = "公司id")
+    private Long companyId;
+
+    /** 转载金额 */
+    @Excel(name = "转载金额")
+    private BigDecimal amount;
+
+    /** 状态 0 发送中  1  已发送  2余额不足待发送 */
+    @Excel(name = "状态 0 发送中  1  已发送  2余额不足待发送")
+    private Long status;
+
+    /** 观看记录id live_watch_log */
+    @Excel(name = "观看记录id live_watch_log")
+    private Long watchLogId;
+
+    /** 微信返回结果 */
+    @Excel(name = "微信返回结果")
+    private String result;
+
+    /** 微信批次ID */
+    @Excel(name = "微信批次ID")
+    private String batchId;
+
+    /** $column.columnComment */
+    @Excel(name = "微信批次ID")
+    private String appId;
+
+    /** 商户ID */
+    @Excel(name = "商户ID")
+    private String mchId;
+
+
+    /** 直播方式 1 app 2浏览器(可能有) */
+    @Excel(name = "直播方式 1 app 2浏览器(可能有)")
+    private Long wathcType;
+
+    /**
+    * 备注
+    */
+    @Excel(name = "备注")
+    private String remark;
+
+
+}

+ 33 - 0
java/fs-service/src/main/java/com/fs/live/domain/LiveRedPasswordJoin.java

@@ -0,0 +1,33 @@
+package com.fs.live.domain;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 口令福袋参与记录 live_red_password_join
+ *
+ * @author fs
+ * @date 2026-08-10
+ */
+@Data
+public class LiveRedPasswordJoin {
+
+    /** 主键 */
+    private Long id;
+
+    /** 红包/福袋ID */
+    private Long redId;
+
+    /** 直播间ID */
+    private Long liveId;
+
+    /** 用户ID */
+    private Long userId;
+
+    /** 是否中奖 0否 1是 */
+    private Integer isWin;
+
+    /** 参与时间 */
+    private Date createTime;
+}

+ 74 - 0
java/fs-service/src/main/java/com/fs/live/domain/LiveVote.java

@@ -0,0 +1,74 @@
+package com.fs.live.domain;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.util.Date;
+
+/**
+ * 直播投票对象 live_vote
+ * <p>
+ * 注意:表字段 end_time 映射为 {@link #voteEndTime},避免与 BaseEntity.endTime(String 查询区间) 冲突。
+ *
+ * @author fs
+ * @date 2026-08-10
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class LiveVote extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 主键 */
+    private Long id;
+
+    /** 直播间ID */
+    private Long liveId;
+
+    /** 投票描述 */
+    private String description;
+
+    /** 选项A */
+    private String optionA;
+
+    /** 选项B */
+    private String optionB;
+
+    /** 可投票时间(分钟) */
+    private Integer duration;
+
+    /** 结束后下架隐藏时间(分钟) */
+    private Integer hideDuration;
+
+    /** 0未开始 1进行中 2已结束 */
+    private Integer status;
+
+    /** A票数 */
+    private Integer voteACount;
+
+    /** B票数 */
+    private Integer voteBCount;
+
+    /** 获胜选项 A/B,平局为空 */
+    private String winner;
+
+    /** 开始时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date startTime;
+
+    /** 结束时间(表字段 end_time) */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date voteEndTime;
+
+    /** 删除标记 0正常 1删除 */
+    private Integer delFlag;
+
+    /** 结束后隐藏截止时间 = voteEndTime + hideDuration(非表字段) */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date hideAt;
+
+    /** 当前用户已选选项 A/B(非表字段) */
+    private String myChoice;
+}

+ 33 - 0
java/fs-service/src/main/java/com/fs/live/domain/LiveVoteRecord.java

@@ -0,0 +1,33 @@
+package com.fs.live.domain;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 直播投票记录对象 live_vote_record
+ *
+ * @author fs
+ * @date 2026-08-10
+ */
+@Data
+public class LiveVoteRecord {
+
+    /** 主键 */
+    private Long id;
+
+    /** 投票ID */
+    private Long voteId;
+
+    /** 直播间ID */
+    private Long liveId;
+
+    /** 用户ID */
+    private Long userId;
+
+    /** 选项 A/B */
+    private String optionChoice;
+
+    /** 投票时间 */
+    private Date createTime;
+}

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

@@ -38,6 +38,32 @@ public class LiveWatchConfig extends BaseEntity{
     @Excel(name = "实施动作 1现金红包 2积分红包")
     private Long action;
 
+    /**
+     * 完课要求百分比 观看时长占直播总时长的比例
+     */
+    private Long completionRate;
+
+    /**
+     * 题目id 集合
+     */
+    private String finishQuestionIds;
+
+    /**
+     * 可答题次数
+     */
+    private int answerAttempts;
+
+    /**
+     * 录播奖励配置
+     */
+    private String recordTimeRangeStr;
+
+    /** 是否余额扣减发红包 */
+    private String isRedPackageBalanceDeduction;
+
+    /** 录播红包是否开启 */
+    private Boolean recordRedPacketEnabled;
+
     /** 领取提示语 */
     @Excel(name = "领取提示语")
     private String receivePrompt;

+ 6 - 0
java/fs-service/src/main/java/com/fs/live/domain/LiveWatchUser.java

@@ -66,4 +66,10 @@ public class LiveWatchUser extends BaseEntity {
     private Integer pageNum;
     private Integer pageSize;
 
+    /** 发放类型 */
+    private Integer sendType;
+
+    /** 奖励类型 */
+    private Integer rewardType;
+
 }

+ 24 - 0
java/fs-service/src/main/java/com/fs/live/mapper/LiveCompletionAnswerRecordMapper.java

@@ -0,0 +1,24 @@
+package com.fs.live.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.live.domain.LiveCompletionAnswerRecord;
+import com.fs.live.param.LiveCompletionAnswerRecordParam;
+import com.fs.live.vo.LiveCompletionAnswerRecordListVO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 直播完课答题记录Mapper
+ */
+@Mapper
+public interface LiveCompletionAnswerRecordMapper extends BaseMapper<LiveCompletionAnswerRecord> {
+
+    List<LiveCompletionAnswerRecordListVO> selectLiveCompletionAnswerRecordList(LiveCompletionAnswerRecordParam param);
+
+    LiveCompletionAnswerRecordListVO selectLiveCompletionAnswerRecordById(Long id);
+
+    LiveCompletionAnswerRecordListVO selectLiveCompletionAnswerRecordByLiveUserId(@Param("liveId")  Long liveId,@Param("userId") Long userId);
+    int countLiveCompletionAnswerRecordByLiveUserId(@Param("liveId")  Long liveId,@Param("userId") Long userId);
+}

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

@@ -0,0 +1,27 @@
+package com.fs.live.mapper;
+
+import com.fs.live.vo.LiveQuestionLiveVO;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 直播间-课程题库关联
+ */
+public interface LiveCourseQuestionRelMapper {
+
+    List<LiveQuestionLiveVO> selectLinkedByLiveId(@Param("liveId") Long liveId);
+
+    List<LiveQuestionLiveVO> selectOptionQuestionBank(@Param("liveId") Long liveId,
+                                                      @Param("title") String title,
+                                                      @Param("type") Integer type);
+
+    int insertIgnore(@Param("liveId") Long liveId, @Param("questionBankId") Long questionBankId);
+
+    int deleteByRelIds(@Param("liveId") Long liveId, @Param("relIds") Long[] relIds);
+
+    /**
+     * 校验关联是否属于该直播间,并解析课程题库主键
+     */
+    Long selectQuestionBankIdByLiveAndRel(@Param("liveId") Long liveId, @Param("relId") Long relId);
+}

+ 34 - 0
java/fs-service/src/main/java/com/fs/live/mapper/LiveQuickCommentMapper.java

@@ -0,0 +1,34 @@
+package com.fs.live.mapper;
+
+import java.util.List;
+
+import com.fs.live.domain.LiveQuickComment;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * 直播一键评论内容库Mapper接口
+ *
+ * @author fs
+ * @date 2026-08-10
+ */
+public interface LiveQuickCommentMapper {
+
+    LiveQuickComment selectLiveQuickCommentById(Long id);
+
+    List<LiveQuickComment> selectLiveQuickCommentList(LiveQuickComment liveQuickComment);
+
+    /**
+     * App端只读列表:del_flag=0,按 sort_order 升序,最多 limit 条
+     */
+    List<LiveQuickComment> selectAppList(@Param("limit") int limit);
+
+    int insertLiveQuickComment(LiveQuickComment liveQuickComment);
+
+    int updateLiveQuickComment(LiveQuickComment liveQuickComment);
+
+    int deleteLiveQuickCommentById(Long id);
+
+    int deleteLiveQuickCommentByIds(Long[] ids);
+
+    int batchUpdateSort(@Param("list") List<LiveQuickComment> list);
+}

+ 70 - 0
java/fs-service/src/main/java/com/fs/live/mapper/LiveRedPacketLogMapper.java

@@ -0,0 +1,70 @@
+package com.fs.live.mapper;
+
+import java.util.List;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.live.domain.LiveRedPacketLog;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+/**
+ * 直播红包 记录Mapper接口
+ *
+ * @author fs
+ * @date 2026-06-08
+ */
+public interface LiveRedPacketLogMapper extends BaseMapper<LiveRedPacketLog>{
+    /**
+     * 查询直播红包 记录
+     *
+     * @param logId 直播红包 记录主键
+     * @return 直播红包 记录
+     */
+    LiveRedPacketLog selectLiveRedPacketLogByLogId(Long logId);
+
+
+    /**
+     * 查询直播红包 记录列表
+     *
+     * @param liveRedPacketLog 直播红包 记录
+     * @return 直播红包 记录集合
+     */
+    List<LiveRedPacketLog> selectLiveRedPacketLogList(LiveRedPacketLog liveRedPacketLog);
+
+    /**
+     * 新增直播红包 记录
+     *
+     * @param liveRedPacketLog 直播红包 记录
+     * @return 结果
+     */
+    int insertLiveRedPacketLog(LiveRedPacketLog liveRedPacketLog);
+
+    /**
+     * 修改直播红包 记录
+     *
+     * @param liveRedPacketLog 直播红包 记录
+     * @return 结果
+     */
+    int updateLiveRedPacketLog(LiveRedPacketLog liveRedPacketLog);
+
+    /**
+     * 删除直播红包 记录
+     *
+     * @param logId 直播红包 记录主键
+     * @return 结果
+     */
+    int deleteLiveRedPacketLogByLogId(Long logId);
+
+    /**
+     * 批量删除直播红包 记录
+     *
+     * @param logIds 需要删除的数据主键集合
+     * @return 结果
+     */
+    int deleteLiveRedPacketLogByLogIds(Long[] logIds);
+
+    @Select("select * from live_red_packet_log where live_id = #{liveId} and user_id = #{userId} order by log_id desc limit 1")
+    LiveRedPacketLog selectLiveRedPacketLogByTemporary(@Param("liveId") Long liveId, @Param("userId")Long userId);
+
+    @Select("select * from live_red_packet_log where out_batch_no = #{outBatchNo}")
+    LiveRedPacketLog selectLiveRedPacketLogByBatchNo(@Param("outBatchNo") String outBatchNo);
+}

+ 32 - 0
java/fs-service/src/main/java/com/fs/live/mapper/LiveRedPasswordJoinMapper.java

@@ -0,0 +1,32 @@
+package com.fs.live.mapper;
+
+import com.fs.live.domain.LiveRedPasswordJoin;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 口令福袋参与记录 Mapper
+ *
+ * @author fs
+ * @date 2026-08-10
+ */
+@Mapper
+public interface LiveRedPasswordJoinMapper {
+
+    /**
+     * 插入参与记录(忽略重复 uk_red_user)
+     */
+    int insertIgnore(LiveRedPasswordJoin join);
+
+    LiveRedPasswordJoin selectByRedAndUser(@Param("redId") Long redId, @Param("userId") Long userId);
+
+    List<LiveRedPasswordJoin> selectByRedId(@Param("redId") Long redId);
+
+    List<LiveRedPasswordJoin> selectWinnersByRedId(@Param("redId") Long redId);
+
+    int markWinners(@Param("redId") Long redId, @Param("userIds") List<Long> userIds);
+
+    int countByRedId(@Param("redId") Long redId);
+}

+ 50 - 0
java/fs-service/src/main/java/com/fs/live/mapper/LiveVoteMapper.java

@@ -0,0 +1,50 @@
+package com.fs.live.mapper;
+
+import com.fs.live.domain.LiveVote;
+import com.fs.live.domain.LiveVoteRecord;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+import java.util.Set;
+
+/**
+ * 直播投票 Mapper
+ *
+ * @author fs
+ * @date 2026-08-10
+ */
+public interface LiveVoteMapper {
+
+    LiveVote selectLiveVoteById(Long id);
+
+    List<LiveVote> selectLiveVoteList(LiveVote liveVote);
+
+    int insertLiveVote(LiveVote liveVote);
+
+    int updateLiveVote(LiveVote liveVote);
+
+    int deleteLiveVoteById(Long id);
+
+    int deleteLiveVoteByIds(Long[] ids);
+
+    /** 同一直播间进行中投票数量 */
+    int countActiveByLiveId(@Param("liveId") Long liveId, @Param("excludeId") Long excludeId);
+
+    /** App:进行中,或已结束但仍在隐藏期内 */
+    LiveVote selectCurrentVisible(@Param("liveId") Long liveId);
+
+    int incrVoteACount(@Param("id") Long id);
+
+    int incrVoteBCount(@Param("id") Long id);
+
+    /** 结束投票:显式写入 winner(可为 null 表示平局) */
+    int endLiveVote(@Param("id") Long id,
+                    @Param("winner") String winner,
+                    @Param("voteEndTime") java.util.Date voteEndTime);
+
+    List<LiveVote> selectByIds(@Param("ids") Set<String> ids);
+
+    int insertLiveVoteRecord(LiveVoteRecord record);
+
+    LiveVoteRecord selectRecordByVoteAndUser(@Param("voteId") Long voteId, @Param("userId") Long userId);
+}

+ 4 - 0
java/fs-service/src/main/java/com/fs/live/mapper/LiveWatchUserMapper.java

@@ -166,4 +166,8 @@ public interface LiveWatchUserMapper {
      * @return 插入的记录数
      */
     int batchInsertLiveWatchUser(@Param("list") List<LiveWatchUser> liveWatchUsers);
+
+    LiveWatchUser getWatchLiveByLiveFlag(@Param("userId") Long userId, @Param("liveId") Long liveId);
+
+    LiveWatchUser getWatchLiveByReplayFlag(@Param("userId") Long userId, @Param("liveId") Long liveId);
 }

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

@@ -0,0 +1,27 @@
+package com.fs.live.param;
+
+import lombok.Data;
+
+/**
+ * 直播完课答题记录查询参数
+ */
+@Data
+public class LiveCompletionAnswerRecordParam {
+
+    private Integer pageNum;
+
+    private Integer pageSize;
+
+    private Long liveId;
+
+    private Long userId;
+
+    private String userName;
+
+    /** 是否全部正确 0否 1是 */
+    private Integer isRight;
+
+    private String beginTime;
+
+    private String endTime;
+}

+ 29 - 0
java/fs-service/src/main/java/com/fs/live/param/LiveCompletionCouponAnswerItem.java

@@ -0,0 +1,29 @@
+package com.fs.live.param;
+
+import lombok.Data;
+
+/**
+ * 完课优惠券单题作答
+ */
+@Data
+public class LiveCompletionCouponAnswerItem {
+
+    /** 题目 ID */
+    private Long questionId;
+
+    /**
+     * 用户答案
+     * 单选:选项文案,如「一斤半」
+     * 多选:JSON 数组字符串,如 ["A","B"]
+     */
+    private String answer;
+
+    /** 选项下标(App 扁平提交时使用) */
+    private Integer answerIndex;
+
+    /** 选项文案(App 扁平提交时使用,优先于 answer) */
+    private String answerName;
+
+    /** 是否为用户选中项:1 是 0 否(App 扁平提交时使用) */
+    private Integer isAnswer;
+}

+ 17 - 0
java/fs-service/src/main/java/com/fs/live/param/LiveCompletionCouponAnswerParam.java

@@ -0,0 +1,17 @@
+package com.fs.live.param;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 直播完课优惠券答题参数
+ */
+@Data
+public class LiveCompletionCouponAnswerParam {
+
+    private Long liveId;
+
+    /** 用户作答列表 */
+    private List<LiveCompletionCouponAnswerItem> answers;
+}

+ 34 - 0
java/fs-service/src/main/java/com/fs/live/param/LiveRedPacketParam.java

@@ -0,0 +1,34 @@
+package com.fs.live.param;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+
+@Data
+public class LiveRedPacketParam {
+
+    private Long userId;
+
+    @NotNull(message = "直播间Id不能为空")
+    private Long liveId;//直播间Id
+
+    @NotNull(message = "客服参数不能为空")
+    private Long companyUserId;
+
+    @NotNull(message = "经销商参数不能为空")
+    private Long companyId;
+
+    private Integer source=1;//来源 1 app 2 浏览器
+
+    private Integer sendType;
+
+    @NotBlank(message = "小程序参数不能为空")
+    private String appId; //前端传来的小程序的appid
+
+    private Integer rewardType; //奖励类型 1红包 2积分
+
+    private String code;
+
+    private Long watchLogId;
+}

+ 19 - 0
java/fs-service/src/main/java/com/fs/live/param/LiveVoteCastParam.java

@@ -0,0 +1,19 @@
+package com.fs.live.param;
+
+import lombok.Data;
+
+/**
+ * App 端投票入参
+ */
+@Data
+public class LiveVoteCastParam {
+
+    /** 投票ID */
+    private Long voteId;
+
+    /** 选项 A/B */
+    private String optionChoice;
+
+    /** 用户ID(由登录态填充) */
+    private Long userId;
+}

+ 44 - 0
java/fs-service/src/main/java/com/fs/live/service/ILiveCompletionAnswerRecordService.java

@@ -0,0 +1,44 @@
+package com.fs.live.service;
+
+
+import com.fs.live.param.LiveCompletionAnswerRecordParam;
+import com.fs.live.param.LiveCompletionCouponAnswerItem;
+import com.fs.live.vo.LiveCompletionAnswerDetailVO;
+import com.fs.live.vo.LiveCompletionAnswerRecordListVO;
+
+import java.util.List;
+
+/**
+ * 直播完课答题记录Service
+ */
+public interface ILiveCompletionAnswerRecordService {
+
+    /**
+     * 查询完课答题记录列表
+     */
+    List<LiveCompletionAnswerRecordListVO> selectLiveCompletionAnswerRecordList(LiveCompletionAnswerRecordParam param);
+
+    /**
+     * 查询完课答题记录详情
+     */
+    LiveCompletionAnswerRecordListVO selectLiveCompletionAnswerRecordById(Long id);
+
+    /**
+    * 获取答题记录里 答对的 记录 有没有
+    */
+    LiveCompletionAnswerRecordListVO selectLiveCompletionAnswerRecordByLiveUserId(Long liveId,Long userId);
+
+    /**
+     * 获取答题记录次数
+     */
+    int countLiveCompletionAnswerRecordByLiveUserId(Long liveId,Long  userId);
+
+    /**
+     * 保存完课答题记录(/app/live/completion/coupon/answer 提交时调用)
+     *
+     * @return 记录主键ID
+     */
+    Long saveAnswerRecord(Long liveId, Long userId, boolean allCorrect,
+                          List<LiveCompletionCouponAnswerItem> answers,
+                          List<LiveCompletionAnswerDetailVO> questionDetails);
+}

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

@@ -0,0 +1,21 @@
+package com.fs.live.service;
+
+import com.fs.live.vo.LiveQuestionLiveVO;
+
+import java.util.List;
+
+/**
+ * 直播间与课程题库关联
+ */
+public interface ILiveCourseQuestionRelService {
+
+    void checkLiveCompany(Long liveId, Long companyId);
+
+    List<LiveQuestionLiveVO> selectLinkedByLiveId(Long liveId, Long companyId);
+
+    List<LiveQuestionLiveVO> selectOptionQuestionBank(Long liveId, Long companyId, String title, Integer type);
+
+    int batchAdd(Long liveId, Long companyId, String questionIdsCsv);
+
+    int deleteByRelIds(Long liveId, Long companyId, String relIdsCsv);
+}

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

@@ -0,0 +1,30 @@
+package com.fs.live.service;
+
+import java.util.List;
+
+import com.fs.live.domain.LiveQuickComment;
+
+/**
+ * 直播一键评论内容库Service接口
+ *
+ * @author fs
+ * @date 2026-08-10
+ */
+public interface ILiveQuickCommentService {
+
+    LiveQuickComment selectLiveQuickCommentById(Long id);
+
+    List<LiveQuickComment> selectLiveQuickCommentList(LiveQuickComment liveQuickComment);
+
+    List<LiveQuickComment> selectAppList();
+
+    int insertLiveQuickComment(LiveQuickComment liveQuickComment);
+
+    int updateLiveQuickComment(LiveQuickComment liveQuickComment);
+
+    int deleteLiveQuickCommentByIds(Long[] ids);
+
+    int deleteLiveQuickCommentById(Long id);
+
+    int saveSort(List<LiveQuickComment> list);
+}

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

@@ -0,0 +1,67 @@
+package com.fs.live.service;
+
+import java.util.List;
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.fs.common.core.domain.R;
+import com.fs.live.domain.LiveRedPacketLog;
+import com.fs.live.param.LiveRedPacketParam;
+
+/**
+ * 直播红包 记录Service接口
+ *
+ * @author fs
+ * @date 2026-06-08
+ */
+public interface ILiveRedPacketLogService extends IService<LiveRedPacketLog>{
+    /**
+     * 查询直播红包 记录
+     *
+     * @param logId 直播红包 记录主键
+     * @return 直播红包 记录
+     */
+    LiveRedPacketLog selectLiveRedPacketLogByLogId(Long logId);
+
+    /**
+     * 查询直播红包 记录列表
+     *
+     * @param liveRedPacketLog 直播红包 记录
+     * @return 直播红包 记录集合
+     */
+    List<LiveRedPacketLog> selectLiveRedPacketLogList(LiveRedPacketLog liveRedPacketLog);
+
+    /**
+     * 新增直播红包 记录
+     *
+     * @param liveRedPacketLog 直播红包 记录
+     * @return 结果
+     */
+    int insertLiveRedPacketLog(LiveRedPacketLog liveRedPacketLog);
+
+    /**
+     * 修改直播红包 记录
+     *
+     * @param liveRedPacketLog 直播红包 记录
+     * @return 结果
+     */
+    int updateLiveRedPacketLog(LiveRedPacketLog liveRedPacketLog);
+
+    /**
+     * 批量删除直播红包 记录
+     *
+     * @param logIds 需要删除的直播红包 记录主键集合
+     * @return 结果
+     */
+    int deleteLiveRedPacketLogByLogIds(Long[] logIds);
+
+    /**
+     * 删除直播红包 记录信息
+     *
+     * @param logId 直播红包 记录主键
+     * @return 结果
+     */
+    int deleteLiveRedPacketLogByLogId(Long logId);
+
+    R sendLiveReward(LiveRedPacketParam param);
+
+    R syncLiveRedPacket(String outBatchNo, String batchId);
+}

+ 3 - 0
java/fs-service/src/main/java/com/fs/live/service/ILiveService.java

@@ -4,6 +4,7 @@ package com.fs.live.service;
 import com.fs.common.core.page.PageRequest;
 import com.fs.company.vo.CompanyVO;
 import com.fs.live.param.LiveNotifyParam;
+import com.fs.live.param.LiveRedPacketParam;
 import com.fs.live.vo.LiveVo;
 import com.fs.common.core.domain.R;
 import com.fs.live.domain.Live;
@@ -217,4 +218,6 @@ public interface ILiveService
      * @return
      */
     List<CompanyVO> getCompanyDropList();
+
+    R getQuestions(LiveRedPacketParam param);
 }

+ 42 - 0
java/fs-service/src/main/java/com/fs/live/service/ILiveVoteService.java

@@ -0,0 +1,42 @@
+package com.fs.live.service;
+
+import com.fs.common.core.domain.R;
+import com.fs.live.domain.LiveVote;
+import com.fs.live.param.LiveVoteCastParam;
+
+import java.util.List;
+import java.util.Set;
+
+/**
+ * 直播投票 Service
+ *
+ * @author fs
+ * @date 2026-08-10
+ */
+public interface ILiveVoteService {
+
+    LiveVote selectLiveVoteById(Long id);
+
+    List<LiveVote> selectLiveVoteList(LiveVote liveVote);
+
+    int insertLiveVote(LiveVote liveVote);
+
+    int updateLiveVote(LiveVote liveVote);
+
+    int deleteLiveVoteByIds(Long[] ids);
+
+    /** 开始投票 */
+    R start(Long id);
+
+    /** 结束投票(手动) */
+    R end(Long id);
+
+    /** 到期批量结束(秒级任务) */
+    List<LiveVote> finishDueByIds(Set<String> ids);
+
+    /** App:当前进行中或未隐藏的投票 */
+    LiveVote getCurrent(Long liveId, Long userId);
+
+    /** App:投票 */
+    R cast(LiveVoteCastParam param);
+}

+ 4 - 0
java/fs-service/src/main/java/com/fs/live/service/ILiveWatchUserService.java

@@ -179,4 +179,8 @@ public interface ILiveWatchUserService {
     void clearLiveFlagCache(Long liveId);
 
     List<LiveWatchUser> selectAllWatchUser(LiveWatchUser queryUser);
+
+    R getLiveStatusByUserID(Long liveId, Long userId);
+
+    R sendLiveRewardClone(long liveId, long userId);
 }

+ 82 - 0
java/fs-service/src/main/java/com/fs/live/service/impl/LiveCompletionAnswerRecordServiceImpl.java

@@ -0,0 +1,82 @@
+package com.fs.live.service.impl;
+
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.his.domain.FsUser;
+import com.fs.his.mapper.FsUserMapper;
+import com.fs.live.domain.LiveCompletionAnswerRecord;
+import com.fs.live.mapper.LiveCompletionAnswerRecordMapper;
+import com.fs.live.param.LiveCompletionAnswerRecordParam;
+import com.fs.live.param.LiveCompletionCouponAnswerItem;
+import com.fs.live.service.ILiveCompletionAnswerRecordService;
+import com.fs.live.vo.LiveCompletionAnswerDetailVO;
+import com.fs.live.vo.LiveCompletionAnswerRecordListVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * 直播完课答题记录Service业务层
+ */
+@Service
+public class LiveCompletionAnswerRecordServiceImpl implements ILiveCompletionAnswerRecordService {
+
+    @Autowired
+    private LiveCompletionAnswerRecordMapper liveCompletionAnswerRecordMapper;
+
+    @Autowired
+    private FsUserMapper fsUserMapper;
+
+    @Override
+    public List<LiveCompletionAnswerRecordListVO> selectLiveCompletionAnswerRecordList(LiveCompletionAnswerRecordParam param) {
+        return liveCompletionAnswerRecordMapper.selectLiveCompletionAnswerRecordList(param);
+    }
+
+    @Override
+    public LiveCompletionAnswerRecordListVO selectLiveCompletionAnswerRecordById(Long id) {
+        return liveCompletionAnswerRecordMapper.selectLiveCompletionAnswerRecordById(id);
+    }
+
+    @Override
+    public LiveCompletionAnswerRecordListVO selectLiveCompletionAnswerRecordByLiveUserId(Long liveId, Long userId) {
+        return liveCompletionAnswerRecordMapper.selectLiveCompletionAnswerRecordByLiveUserId(liveId,userId);
+    }
+
+    @Override
+    public int countLiveCompletionAnswerRecordByLiveUserId(Long liveId, Long userId) {
+        return liveCompletionAnswerRecordMapper.countLiveCompletionAnswerRecordByLiveUserId(liveId,userId);
+    }
+
+    @Override
+    public Long saveAnswerRecord(Long liveId, Long userId, boolean allCorrect,
+                                 List<LiveCompletionCouponAnswerItem> answers,
+                                 List<LiveCompletionAnswerDetailVO> questionDetails) {
+        LiveCompletionAnswerRecord record = new LiveCompletionAnswerRecord();
+        record.setLiveId(liveId);
+        record.setUserId(userId);
+        record.setUserName(resolveUserName(userId));
+        record.setIsRight(allCorrect ? 1 : 0);
+        record.setAnswerJson(answers == null ? null : JSON.toJSONString(answers));
+        record.setQuestionJson(questionDetails == null ? null : JSON.toJSONString(questionDetails));
+        record.setCreateTime(DateUtils.getNowDate());
+        liveCompletionAnswerRecordMapper.insert(record);
+        return record.getId();
+    }
+
+    private String resolveUserName(Long userId) {
+        if (userId == null) {
+            return null;
+        }
+        FsUser user = fsUserMapper.selectFsUserByUserId(userId);
+        if (user == null) {
+            return null;
+        }
+        if (StringUtils.isNotEmpty(user.getNickName())) {
+            return user.getNickName();
+        }
+        return user.getPhone();
+    }
+}

+ 98 - 0
java/fs-service/src/main/java/com/fs/live/service/impl/LiveCourseQuestionRelServiceImpl.java

@@ -0,0 +1,98 @@
+package com.fs.live.service.impl;
+
+import com.fs.common.exception.base.BaseException;
+import com.fs.common.utils.StringUtils;
+import com.fs.live.domain.Live;
+import com.fs.live.mapper.LiveCourseQuestionRelMapper;
+import com.fs.live.mapper.LiveMapper;
+import com.fs.live.service.ILiveCourseQuestionRelService;
+import com.fs.live.vo.LiveQuestionLiveVO;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+public class LiveCourseQuestionRelServiceImpl implements ILiveCourseQuestionRelService {
+
+    private final LiveCourseQuestionRelMapper liveCourseQuestionRelMapper;
+    private final LiveMapper liveMapper;
+
+    @Override
+    public void checkLiveCompany(Long liveId, Long companyId) {
+        if (liveId == null) {
+            throw new BaseException("invalid params");
+        }
+        // platform live may have null companyId: only check live exists
+        if (companyId == null) {
+            Live live = liveMapper.selectLiveByLiveId(liveId);
+            if (live == null) {
+                throw new BaseException("live not found");
+            }
+            return;
+        }
+        Live live = liveMapper.selectLiveByLiveIdAndCompanyId(liveId, companyId);
+        if (live == null) {
+            throw new BaseException("live not found or no permission");
+        }
+    }
+
+    @Override
+    public List<LiveQuestionLiveVO> selectLinkedByLiveId(Long liveId, Long companyId) {
+        checkLiveCompany(liveId, companyId);
+        return liveCourseQuestionRelMapper.selectLinkedByLiveId(liveId);
+    }
+
+    @Override
+    public List<LiveQuestionLiveVO> selectOptionQuestionBank(Long liveId, Long companyId, String title, Integer type) {
+        checkLiveCompany(liveId, companyId);
+        return liveCourseQuestionRelMapper.selectOptionQuestionBank(liveId, title, type);
+    }
+
+    @Override
+    public int batchAdd(Long liveId, Long companyId, String questionIdsCsv) {
+        checkLiveCompany(liveId, companyId);
+        if (StringUtils.isEmpty(questionIdsCsv)) {
+            return 0;
+        }
+        String[] parts = questionIdsCsv.split(",");
+        int n = 0;
+        for (String p : parts) {
+            if (StringUtils.isEmpty(p)) {
+                continue;
+            }
+            try {
+                Long qid = Long.parseLong(p.trim());
+                n += liveCourseQuestionRelMapper.insertIgnore(liveId, qid);
+            } catch (NumberFormatException ignored) {
+                // skip invalid
+            }
+        }
+        return n;
+    }
+
+    @Override
+    public int deleteByRelIds(Long liveId, Long companyId, String relIdsCsv) {
+        checkLiveCompany(liveId, companyId);
+        if (StringUtils.isEmpty(relIdsCsv)) {
+            return 0;
+        }
+        List<Long> ids = new ArrayList<>();
+        for (String p : relIdsCsv.split(",")) {
+            if (StringUtils.isEmpty(p)) {
+                continue;
+            }
+            try {
+                ids.add(Long.parseLong(p.trim()));
+            } catch (NumberFormatException ignored) {
+                // skip
+            }
+        }
+        if (ids.isEmpty()) {
+            return 0;
+        }
+        return liveCourseQuestionRelMapper.deleteByRelIds(liveId, ids.toArray(new Long[0]));
+    }
+}

+ 134 - 0
java/fs-service/src/main/java/com/fs/live/service/impl/LiveQuickCommentServiceImpl.java

@@ -0,0 +1,134 @@
+package com.fs.live.service.impl;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.live.domain.LiveQuickComment;
+import com.fs.live.mapper.LiveQuickCommentMapper;
+import com.fs.live.service.ILiveQuickCommentService;
+import com.fs.live.service.ILiveSensitiveWordsService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 直播一键评论内容库Service业务层处理
+ *
+ * @author fs
+ * @date 2026-08-10
+ */
+@Slf4j
+@Service
+public class LiveQuickCommentServiceImpl implements ILiveQuickCommentService {
+
+    private static final int APP_LIST_LIMIT = 10;
+    private static final int CONTENT_MAX_LEN = 30;
+
+    @Autowired
+    private LiveQuickCommentMapper liveQuickCommentMapper;
+
+    @Autowired
+    private ILiveSensitiveWordsService liveSensitiveWordsService;
+
+    @Override
+    public LiveQuickComment selectLiveQuickCommentById(Long id) {
+        return liveQuickCommentMapper.selectLiveQuickCommentById(id);
+    }
+
+    @Override
+    public List<LiveQuickComment> selectLiveQuickCommentList(LiveQuickComment liveQuickComment) {
+        return liveQuickCommentMapper.selectLiveQuickCommentList(liveQuickComment);
+    }
+
+    @Override
+    public List<LiveQuickComment> selectAppList() {
+        return liveQuickCommentMapper.selectAppList(APP_LIST_LIMIT);
+    }
+
+    @Override
+    public int insertLiveQuickComment(LiveQuickComment liveQuickComment) {
+        validateContent(liveQuickComment.getContent());
+        if (liveQuickComment.getSortOrder() == null) {
+            liveQuickComment.setSortOrder(0);
+        }
+        liveQuickComment.setDelFlag(0);
+        liveQuickComment.setCreateTime(DateUtils.getNowDate());
+        return liveQuickCommentMapper.insertLiveQuickComment(liveQuickComment);
+    }
+
+    @Override
+    public int updateLiveQuickComment(LiveQuickComment liveQuickComment) {
+        if (liveQuickComment.getContent() != null) {
+            validateContent(liveQuickComment.getContent());
+        }
+        liveQuickComment.setUpdateTime(DateUtils.getNowDate());
+        return liveQuickCommentMapper.updateLiveQuickComment(liveQuickComment);
+    }
+
+    @Override
+    public int deleteLiveQuickCommentByIds(Long[] ids) {
+        return liveQuickCommentMapper.deleteLiveQuickCommentByIds(ids);
+    }
+
+    @Override
+    public int deleteLiveQuickCommentById(Long id) {
+        return liveQuickCommentMapper.deleteLiveQuickCommentById(id);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public int saveSort(List<LiveQuickComment> list) {
+        if (list == null || list.isEmpty()) {
+            return 0;
+        }
+        List<LiveQuickComment> validList = list.stream()
+                .filter(item -> item != null && item.getId() != null && item.getSortOrder() != null)
+                .collect(Collectors.toList());
+        if (validList.isEmpty()) {
+            return 0;
+        }
+        return liveQuickCommentMapper.batchUpdateSort(validList);
+    }
+
+    private void validateContent(String content) {
+        if (StringUtils.isEmpty(content)) {
+            throw new ServiceException("评论内容不能为空");
+        }
+        String trimmed = content.trim();
+        if (trimmed.isEmpty()) {
+            throw new ServiceException("评论内容不能为空");
+        }
+        if (trimmed.length() > CONTENT_MAX_LEN) {
+            throw new ServiceException("评论内容不能超过" + CONTENT_MAX_LEN + "字");
+        }
+        if (containsSensitiveWord(trimmed)) {
+            throw new ServiceException("评论内容包含敏感词,无法添加");
+        }
+    }
+
+    /**
+     * 后台新增/修改时校验。词库在租户库 live_sensitive_words,观众端只读列表不走这里。
+     * 租户未建表时跳过,避免把异常扩散到启动或读接口。
+     */
+    private boolean containsSensitiveWord(String text) {
+        try {
+            List<String> words = liveSensitiveWordsService.selectAllWords();
+            if (words == null || words.isEmpty()) {
+                return false;
+            }
+            for (String word : words) {
+                if (StringUtils.isNotEmpty(word) && text.contains(word.trim())) {
+                    return true;
+                }
+            }
+            return false;
+        } catch (Exception e) {
+            log.warn("一键评论敏感词校验跳过(词库不可用): {}", e.getMessage());
+            return false;
+        }
+    }
+}

+ 973 - 0
java/fs-service/src/main/java/com/fs/live/service/impl/LiveRedPacketLogServiceImpl.java

@@ -0,0 +1,973 @@
+package com.fs.live.service.impl;
+
+import java.math.BigDecimal;
+import java.text.SimpleDateFormat;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+import com.alibaba.fastjson.JSON;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.fs.common.constant.FsConstants;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.Company;
+import com.fs.company.domain.CompanyRedPacketBalanceLogs;
+import com.fs.company.mapper.CompanyMapper;
+import com.fs.company.mapper.CompanyRedPacketBalanceLogsMapper;
+import com.fs.company.service.ICompanyService;
+import com.fs.course.domain.*;
+import com.fs.course.mapper.BalanceRollbackErrorMapper;
+import com.fs.his.domain.*;
+import com.fs.his.mapper.FsCourseCouponMapper;
+import com.fs.his.mapper.FsCourseCouponUserMapper;
+import com.fs.his.mapper.FsUserIntegralLogsMapper;
+import com.fs.his.mapper.FsUserMapper;
+import com.fs.his.param.WxSendRedPacketParam;
+import com.fs.his.service.IFsStorePaymentService;
+import com.fs.live.domain.*;
+import com.fs.live.mapper.LiveRedPacketLogMapper;
+import com.fs.live.mapper.LiveWatchUserMapper;
+import com.fs.live.param.LiveRedPacketParam;
+import com.fs.live.service.*;
+import com.fs.live.vo.LiveCompletionAnswerRecordListVO;
+import com.fs.live.vo.RecordTimeRangeVO;
+import com.fs.voice.utils.StringUtil;
+import com.github.binarywang.wxpay.bean.transfer.TransferBillsResult;
+import lombok.extern.slf4j.Slf4j;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+/**
+ * 直播红包 记录Service业务层处理
+ *
+ * @author fs
+ * @date 2026-06-08
+ */
+@Slf4j
+@Service
+public class LiveRedPacketLogServiceImpl extends ServiceImpl<LiveRedPacketLogMapper, LiveRedPacketLog> implements ILiveRedPacketLogService {
+
+    private static final Logger logger = LoggerFactory.getLogger(LiveRedPacketLogServiceImpl.class);
+
+    @Autowired
+    private RedissonClient redissonClient;
+
+    @Autowired
+    private FsUserMapper fsUserMapper;
+
+    @Autowired
+    private LiveWatchUserMapper watchUserMapper;
+
+    @Autowired
+    @Lazy
+    private ILiveService iLiveService;
+
+    @Autowired
+    private LiveRedPacketLogMapper redPacketLogMapper;
+
+    @Autowired
+    RedisCache redisCache;
+
+    @Autowired
+    private IFsStorePaymentService paymentService;
+
+    @Autowired
+    private BalanceRollbackErrorMapper balanceRollbackErrorMapper;
+
+    @Autowired
+    ICompanyService companyService;
+
+    @Autowired
+    private CompanyMapper companyMapper;
+
+    @Autowired
+    private FsUserIntegralLogsMapper fsUserIntegralLogsMapper;
+
+    @Autowired
+    private CompanyRedPacketBalanceLogsMapper companyRedPacketBalanceLogsMapper;
+
+    @Autowired
+    @Lazy
+    private ILiveWatchUserService liveWatchUserService;
+
+    @Autowired
+    private FsCourseCouponMapper fsCourseCouponMapper;
+
+    @Autowired
+    private FsCourseCouponUserMapper courseCouponUserMapper;
+
+    @Autowired
+    private ILiveCompletionAnswerRecordService liveCompletionAnswerRecordService;
+
+    /**
+     * 查询直播红包 记录
+     *
+     * @param logId 直播红包 记录主键
+     * @return 直播红包 记录
+     */
+    @Override
+    public LiveRedPacketLog selectLiveRedPacketLogByLogId(Long logId)
+    {
+        return baseMapper.selectLiveRedPacketLogByLogId(logId);
+    }
+
+    /**
+     * 查询直播红包 记录列表
+     *
+     * @param liveRedPacketLog 直播红包 记录
+     * @return 直播红包 记录
+     */
+    @Override
+    public List<LiveRedPacketLog> selectLiveRedPacketLogList(LiveRedPacketLog liveRedPacketLog)
+    {
+        return baseMapper.selectLiveRedPacketLogList(liveRedPacketLog);
+    }
+
+    /**
+     * 新增直播红包 记录
+     *
+     * @param liveRedPacketLog 直播红包 记录
+     * @return 结果
+     */
+    @Override
+    public int insertLiveRedPacketLog(LiveRedPacketLog liveRedPacketLog)
+    {
+        liveRedPacketLog.setCreateTime(DateUtils.getNowDate());
+        return baseMapper.insertLiveRedPacketLog(liveRedPacketLog);
+    }
+
+    /**
+     * 修改直播红包 记录
+     *
+     * @param liveRedPacketLog 直播红包 记录
+     * @return 结果
+     */
+    @Override
+    public int updateLiveRedPacketLog(LiveRedPacketLog liveRedPacketLog)
+    {
+        liveRedPacketLog.setUpdateTime(DateUtils.getNowDate());
+        return baseMapper.updateLiveRedPacketLog(liveRedPacketLog);
+    }
+
+    /**
+     * 批量删除直播红包 记录
+     *
+     * @param logIds 需要删除的直播红包 记录主键
+     * @return 结果
+     */
+    @Override
+    public int deleteLiveRedPacketLogByLogIds(Long[] logIds)
+    {
+        return baseMapper.deleteLiveRedPacketLogByLogIds(logIds);
+    }
+
+    /**
+     * 删除直播红包 记录信息
+     *
+     * @param logId 直播红包 记录主键
+     * @return 结果
+     */
+    @Override
+    public int deleteLiveRedPacketLogByLogId(Long logId)
+    {
+        return baseMapper.deleteLiveRedPacketLogByLogId(logId);
+    }
+
+    @Override
+    @Transactional
+    public R sendLiveReward(LiveRedPacketParam param) {
+
+        // 生成锁的key,基于用户ID和直播ID确保同一用户同一直播的请求被锁定
+        String lockKey = "liveReward_lock:user:" + param.getUserId() + ":live:" + param.getLiveId();
+        RLock lock = redissonClient.getLock(lockKey);
+
+        try {
+            // 尝试获取锁,等待时间5秒,锁过期时间120秒
+            boolean isLocked = lock.tryLock(5, 120, TimeUnit.SECONDS);
+            if (!isLocked) {
+                logger.warn("直播获取锁失败,用户ID:{},直播ID:{}", param.getUserId(), param.getLiveId());
+                return R.error("操作频繁,请2分钟后再试!");
+            }
+
+            // 从缓存里面更新一下时长
+            R cloneResult  = liveWatchUserService.sendLiveRewardClone(param.getLiveId(), param.getUserId());
+
+            if (cloneResult.get("code") == null || !Integer.valueOf(200).equals(cloneResult.get("code"))){
+                return cloneResult.get("msg") != null ? cloneResult : R.error("直播观看校验时长失败");
+            }
+
+            //保证锁的释放 在事物完成之后
+            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                @Override
+                public void afterCompletion(int status) {
+                    try {
+                        if (lock.isHeldByCurrentThread()) {
+                            lock.unlock();
+                            logger.info("直播事务完成后释放锁,用户ID:{},直播ID:{}", param.getUserId(), param.getLiveId());
+                        }
+                    } catch (IllegalMonitorStateException e) {
+                        logger.warn("直播释放锁异常(可能已过期),用户ID:{},直播ID:{}", param.getUserId(), param.getLiveId());
+                    }
+                }
+            });
+
+
+            logger.info("直播成功获取锁,开始处理奖励发放,用户ID:{},直播ID:{}", param.getUserId(), param.getLiveId());
+
+            // 获取用户信息
+            FsUser user = fsUserMapper.selectFsUserByUserId(param.getUserId());
+
+            log.info("直播查询会员信息:{}", user);
+            if (user.getStatus() == 0) {
+                return R.error("会员被停用,无权限,请联系客服!");
+            }
+
+            Live live = iLiveService.selectLiveByLiveId(param.getLiveId());
+            LiveWatchConfig config = JSON.parseObject(live.getConfigJson(), LiveWatchConfig.class);
+            log.info("直播配置:{}", config);
+            // 先查是否开启了启动 直播完课奖励
+            if (config.getEnabled() && 3 == config.getParticipateCondition()) {
+
+                //直播/录播
+                LiveWatchUser watchUser=null;
+                //录播
+                LiveWatchUser replayUser=null;
+
+                // 根据直播类型判断是否已发放奖励 先查直播
+                watchUser = watchUserMapper.getWatchLiveByLiveFlag(param.getUserId(), param.getLiveId());
+                log.info("直播看课记录:{}", watchUser);
+                if (watchUser == null) {
+                    // 直播没有记录 查录播
+                    watchUser = watchUserMapper.getWatchLiveByReplayFlag(param.getUserId(), param.getLiveId());
+
+                    if (watchUser==null){
+                        return R.error("您没有观看过该课程,无奖励发放!");
+                    }
+
+                    if (watchUser.getSendType() != null){
+                        return R.error("奖励已发放!请勿重复领取");
+                    }
+                }
+                //直播不空 那要存一下录播
+                else {
+                    // 直播没有记录 查录播
+                    replayUser = watchUserMapper.getWatchLiveByReplayFlag(param.getUserId(), param.getLiveId());
+
+                }
+
+                if (watchUser.getSendType() != null || (replayUser != null && replayUser.getSendType() != null)){
+                    return R.error("奖励已发放!请勿重复领取");
+                }
+
+                // 查是否有领取记录
+                LiveRedPacketLog liveRedPacketLog = redPacketLogMapper.selectLiveRedPacketLogByTemporary(param.getLiveId(), param.getUserId());
+
+                log.info("直播课程红包:{}", liveRedPacketLog);
+                if (liveRedPacketLog != null && liveRedPacketLog.getStatus() == 1) {
+                    //已领确定领取
+                    watchUser.setRewardType(1);
+                    watchUser.setSendType(1);
+                    watchUserMapper.updateLiveWatchUser(watchUser);
+                    return R.error("已领取该直播课程奖励,不可重复领取!");
+                }
+                if (liveRedPacketLog != null && liveRedPacketLog.getStatus() == 0) {
+                    //已跳转但是没点确定领取
+                    watchUser.setRewardType(1);
+                    watchUser.setSendType(1);
+                    watchUserMapper.updateLiveWatchUser(watchUser);
+
+                    if (!StringUtil.strIsNullOrEmpty(liveRedPacketLog.getResult())) {
+                        R r = JSON.parseObject(liveRedPacketLog.getResult(), R.class);
+                        return r;
+                    } else {
+                        return R.error("操作频繁,请稍后再试!");
+                    }
+                }
+                if (liveRedPacketLog != null && liveRedPacketLog.getStatus() == 2) {
+                    return R.error("请联系客服补发");
+                }
+
+                //已经有领取记录了 但是 奖励状态没变更 给它改了
+                if (liveRedPacketLog!=null && (watchUser.getRewardType() != null || (replayUser != null && replayUser.getRewardType() != null))) {
+                    //已跳转但是没点确定领取
+                    watchUser.setRewardType(98);
+                    watchUser.setSendType(1);
+                    watchUserMapper.updateLiveWatchUser(watchUser);
+
+                    return R.error("奖励已发放!请勿重复领取");
+                }
+
+                //判断直播数据 是否满足 完课/如果有录播数据 再判断录播是否满足完课
+                // 在线时长
+                long onlineSeconds = watchUser.getOnlineSeconds() != null ? watchUser.getOnlineSeconds() : 0L;
+                Long duration = live.getDuration();
+                Long completionRate = config.getCompletionRate();
+
+                if (duration == null || duration <= 0 || completionRate == null || completionRate <= 0) {
+                    return R.error("直播完课配置异常,无法发放奖励");
+                }
+
+                //优先 发直播红包
+                if (watchUser.getLiveFlag()==1){
+
+                    if (onlineSeconds * 100 < duration * completionRate) {
+                        //直播时长不满足,看录播 如果录播的时长看课时长 大于了 设定的百分比则 可以领取积分
+                        if (replayUser != null && replayUser.getOnlineSeconds() != null
+                                && replayUser.getOnlineSeconds() * 100 >= duration * completionRate) {
+
+                            // 如果是 回放完课 发积分 //新增 时间范围 范围内可以领取红包,范围外领取积分或者不领
+                            if (config.getRecordRedPacketEnabled()!=null
+                                    && config.getRecordRedPacketEnabled()
+                                    && !StringUtil.strIsNullOrEmpty(config.getRecordTimeRangeStr())){
+
+                                List<RecordTimeRangeVO> ranges = parseRecordRewards(config.getRecordTimeRangeStr());
+                                RecordTimeRangeVO matched = matchCurrentTime(ranges);
+
+                                if (matched != null) {
+                                    return dispatchRecordRewards(matched, user, param, watchUser, config);
+                                }else {
+                                    return R.error("当前时间段,不满足领取奖励条件");
+                                }
+                            }else {
+                                // 更新直播观看记录的奖励类型,99=未设置奖励
+                                watchUser.setRewardType(99);
+                                watchUser.setSendType(1);
+
+                                watchUserMapper.updateLiveWatchUser(watchUser);
+
+                                log.error("未设置回放奖励里:{},{}",live.getLiveId(),config);
+                                return R.ok("未设置回放奖励");
+                            }
+
+
+                        }else {
+                            return R.error("观看时长未达到完课要求,请看继续观看回放,之后再领取");
+                        }
+
+                    }
+
+                    //没设置题目直接发  设置了题目 判断答得对不  对也发 不对不发
+                    if (!StringUtil.strIsNullOrEmpty(config.getFinishQuestionIds())){
+                        LiveCompletionAnswerRecordListVO answerRecordListVO = liveCompletionAnswerRecordService.selectLiveCompletionAnswerRecordByLiveUserId(param.getLiveId(), param.getUserId());
+                        //没有答对的 直接红包OK 答对了走下面的 红包
+                        if (answerRecordListVO==null){
+
+                            // 97 直播题目没答对且次数耗尽
+                            watchUser.setRewardType(97);
+                            watchUser.setSendType(1);
+
+                            watchUserMapper.updateLiveWatchUser(watchUser);
+                            return R.ok("答题成功");
+                        }
+                    }
+
+
+                    WxSendRedPacketParam packetParam = new WxSendRedPacketParam();
+
+                    if (StringUtil.strIsNullOrEmpty(user.getAppOpenId())){
+                        return R.error("请重新登录app");
+                    }
+
+                    // 用app的 appOpenId
+                    String openId = user.getAppOpenId();
+                    packetParam.setOpenId(openId);
+                    BeanUtils.copyProperties(param, packetParam);
+
+                   return sendAppLiveRedPacketAuto(packetParam, watchUser, config, param);
+                }
+                // 其次 是 录播 再发 回放积分
+                else if (watchUser.getReplayFlag()==1){
+
+                    if (onlineSeconds * 100 < duration * completionRate) {
+                        return R.error("观看时长未达到完课要求,请继续观看");
+                    }
+
+                    //新增 时间范围 范围内可以领取红包,范围外领取积分或者不领
+                    // 如果是 回放完课 发积分 //新增 时间范围 范围内可以领取红包,范围外领取积分或者不领
+                    if (config.getRecordRedPacketEnabled()!=null
+                            && config.getRecordRedPacketEnabled()
+                            && !StringUtil.strIsNullOrEmpty(config.getRecordTimeRangeStr())){
+
+                        List<RecordTimeRangeVO> ranges = parseRecordRewards(config.getRecordTimeRangeStr());
+                        RecordTimeRangeVO matched = matchCurrentTime(ranges);
+
+                        if (matched != null) {
+                            return dispatchRecordRewards(matched, user, param, watchUser, config);
+                        }else {
+                            return R.error("当前时间段,不满足领取奖励条件");
+                        }
+                    }else {
+                        // 更新直播观看记录的奖励类型,99=未设置奖励
+                        watchUser.setRewardType(99);
+                        watchUser.setSendType(1);
+
+                        watchUserMapper.updateLiveWatchUser(watchUser);
+                        log.error("未设置回放奖励内:{},{}",live.getLiveId(),config);
+                        return R.ok("未设置回放奖励");
+                    }
+                }else {
+                    return R.error("未知 直播观看类型 ");
+                }
+            }else {
+                return R.error("当前直播 并未启动 直播完课奖励 ");
+            }
+
+
+
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            logger.error("直播获取锁被中断,用户ID:{},直播ID:{}", param.getUserId(), param.getLiveId(), e);
+            return R.error("系统繁忙,请重试!");
+        }
+    }
+
+    @Override
+    public R syncLiveRedPacket(String outBatchNo, String batchId) {
+        LiveRedPacketLog log = redPacketLogMapper.selectLiveRedPacketLogByBatchNo(outBatchNo);
+        if (log != null) {
+            log.setStatus(1L);
+            log.setUpdateTime(new Date());
+            log.setBatchId(batchId);
+            redPacketLogMapper.updateLiveRedPacketLog(log);
+
+            // 更新扣减状态
+            CompanyRedPacketBalanceLogs redLogs = new CompanyRedPacketBalanceLogs();
+            redLogs.setRedPacketId(log.getLogId());
+            redLogs.setStatus(1L);
+            companyRedPacketBalanceLogsMapper.updateCompanyRedPacketBalanceLogsByRedPacketId(redLogs);
+
+            return R.ok();
+        }
+        return R.error("批次不存在");
+    }
+
+    public static List<RecordTimeRangeVO> parseRecordRewards(String recordTimeRangeStr) {
+        List<RecordTimeRangeVO> result = new ArrayList<>();
+        if (recordTimeRangeStr == null || recordTimeRangeStr.isEmpty()) {
+            return result;
+        }
+        String[] records = recordTimeRangeStr.split(",");
+        for (String record : records) {
+            if (record.isEmpty()) continue;
+            String[] parts = record.split("-", 6);
+            if (parts.length < 3) continue;
+
+            RecordTimeRangeVO range = new RecordTimeRangeVO();
+            range.setStartTime(parts[0]);
+            range.setEndTime(parts[1]);
+
+            // rewardTypes: 用|分隔的多个类型,如 "1|2|3"
+            if (parts[2] != null && !parts[2].isEmpty()) {
+                range.setRewardTypes(Arrays.asList(parts[2].split("\\|")));
+            }
+
+            // 红包金额
+            if (parts.length > 3 && !parts[3].isEmpty()) {
+                range.setRedPacketAmount(BigDecimal.valueOf(Double.parseDouble(parts[3])));
+            }
+            // 积分
+            if (parts.length > 4 && !parts[4].isEmpty()) {
+                range.setScoreAmount(Long.valueOf(parts[4]));
+            }
+            // 核销卷ID列表(~分隔)
+            if (parts.length > 5 && !parts[5].isEmpty()) {
+                range.setCouponIds(Arrays.stream(parts[5].split("~"))
+                        .filter(s -> !s.isEmpty())
+                        .map(Long::valueOf)
+                        .collect(Collectors.toList()));
+            }
+
+            result.add(range);
+        }
+        return result;
+    }
+
+    /**
+     * 根据匹配到的时间段,组合发放奖励(红包/积分/核销卷)
+     */
+    private R dispatchRecordRewards(RecordTimeRangeVO matched, FsUser user, LiveRedPacketParam param,
+                                     LiveWatchUser watchUser, LiveWatchConfig config) {
+        boolean hasReward = false;
+        R lastResult = R.ok();
+        // 组合 rewardType: 1=红包, 2=积分, 4=核销卷, 组合值相加
+        int combinedRewardType = 0;
+
+        // 红包
+        if (matched.getRewardTypes() != null && matched.getRewardTypes().contains("1")) {
+
+
+            //没设置题目直接发  设置了题目 判断答得对不  对也发 不对不发
+            if (!StringUtil.strIsNullOrEmpty(config.getFinishQuestionIds())){
+                LiveCompletionAnswerRecordListVO answerRecordListVO = liveCompletionAnswerRecordService.selectLiveCompletionAnswerRecordByLiveUserId(param.getLiveId(), param.getUserId());
+                //没有答对的 直接红包OK 答对了走下面的 红包
+                if (answerRecordListVO==null){
+                    hasReward = true;
+                    combinedRewardType |= 1;
+                }else {
+                    WxSendRedPacketParam packetParam = new WxSendRedPacketParam();
+                    if (StringUtil.strIsNullOrEmpty(user.getAppOpenId())) {
+                        return R.error("请重新登录app");
+                    }
+                    String openId = user.getAppOpenId();
+                    packetParam.setOpenId(openId);
+                    BeanUtils.copyProperties(param, packetParam);
+                    config.setRedPacketAmount(matched.getRedPacketAmount());
+                    lastResult = sendAppLiveRedPacketAuto(packetParam, watchUser, config, param);
+
+                    hasReward = true;
+                    combinedRewardType |= 1;
+                }
+
+            }else {
+                WxSendRedPacketParam packetParam = new WxSendRedPacketParam();
+                if (StringUtil.strIsNullOrEmpty(user.getAppOpenId())) {
+                    return R.error("请重新登录app");
+                }
+                String openId = user.getAppOpenId();
+                packetParam.setOpenId(openId);
+                BeanUtils.copyProperties(param, packetParam);
+                config.setRedPacketAmount(matched.getRedPacketAmount());
+                lastResult = sendAppLiveRedPacketAuto(packetParam, watchUser, config, param);
+
+                hasReward = true;
+                combinedRewardType |= 1;
+            }
+
+        }
+        // 积分
+        if (matched.getRewardTypes() != null && matched.getRewardTypes().contains("2")) {
+            config.setScoreAmount(matched.getScoreAmount());
+            sendLiveIntegralReward(param, user, watchUser, config);
+            hasReward = true;
+            combinedRewardType |= 2;
+        }
+        // 课程优惠卷(核销卷)
+        if (matched.getRewardTypes() != null && matched.getRewardTypes().contains("3")) {
+            if (matched.getCouponIds() != null && !matched.getCouponIds().isEmpty()) {
+                List<String> failMsgs = new ArrayList<>();
+                for (Long couponId : matched.getCouponIds()) {
+                    try {
+                        R couponResult = sendIssueCourseCoupon(param.getLiveId(), user.getUserId(), couponId);
+                        if (couponResult.get("msg") != null) {
+                            failMsgs.add("核销卷" + couponId + ":" + couponResult.get("msg"));
+                        }
+                    } catch (Exception e) {
+                        logger.error("发放核销卷异常,couponId:{}", couponId, e);
+                        failMsgs.add("核销卷" + couponId + ":发放异常");
+                    }
+                }
+                if (!failMsgs.isEmpty()) {
+                    watchUser.setRemark(String.join("; ", failMsgs));
+                }
+            }
+            hasReward = true;
+            combinedRewardType |= 4;
+        }
+
+        if (!hasReward) {
+            return R.error("当前时间段,不满足领取奖励条件");
+        }
+
+        // 覆盖为组合后的 rewardType,标记实际发放了哪些奖励
+        watchUser.setRewardType(combinedRewardType);
+        watchUser.setSendType(1);
+        watchUserMapper.updateLiveWatchUser(watchUser);
+        return lastResult;
+    }
+
+    private static int toMinutes(String timeStr) {
+        String[] parts = timeStr.split(":");
+        return Integer.parseInt(parts[0]) * 60 + Integer.parseInt(parts[1]);
+    }
+
+    /**
+     * 当前时间是否在某个时间段内
+     */
+    public static RecordTimeRangeVO matchCurrentTime(List<RecordTimeRangeVO> ranges) {
+        int nowMinutes = toMinutes(
+                new SimpleDateFormat("HH:mm").format(new Date())
+        );
+        for (RecordTimeRangeVO range : ranges) {
+            int start = toMinutes(range.getStartTime());
+            int end = toMinutes(range.getEndTime());
+            if (nowMinutes >= start && nowMinutes < end) {
+                return range;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 发放直播-课程优惠卷卷
+     */
+    public R sendIssueCourseCoupon(Long liveId, Long userId, Long couponId) {
+
+        //优惠券
+        FsCourseCoupon fsCourseCoupon = fsCourseCouponMapper.selectFsCourseCouponById(couponId);
+
+        if (fsCourseCoupon == null) {
+            return R.ok("不存在");
+        }
+
+        if (fsCourseCoupon.getStatus() != 1) {
+            return R.ok("已停用");
+        }
+
+        if (fsCourseCoupon.getRemainNumber() <= 0) {
+            return R.ok("剩余数量不足");
+        }
+
+        //用户领取优惠券总数
+        Integer count = courseCouponUserMapper.selectCountByUserIdAndCouponId(userId, couponId);
+        if (count >= fsCourseCoupon.getLimitCount()) {
+            return R.ok("领取达到上限");
+        }
+
+        //发放优惠券
+        FsCourseCouponUser couponUser = new FsCourseCouponUser();
+        couponUser.setCouponId(couponId);
+        couponUser.setUserId(userId);
+        couponUser.setStartTime(new Date());
+        couponUser.setLimitTime(fsCourseCoupon.getLimitTime());
+        couponUser.setCreateTime(new Date());
+        couponUser.setLiveId(liveId);
+        couponUser.setSendType(2);
+        int i = courseCouponUserMapper.insertFsCourseCouponUser(couponUser);
+
+        //扣库存
+        if (i > 0) {
+            FsCourseCoupon coupon = new FsCourseCoupon();
+            coupon.setId(couponId);
+            coupon.setRemainNumber(fsCourseCoupon.getRemainNumber() - 1);
+            fsCourseCouponMapper.updateFsCourseCoupon(coupon);
+        }
+
+        return R.ok();
+    }
+
+    /**
+     * 直播 看课的的
+     */
+    private R sendAppLiveRedPacketAuto(WxSendRedPacketParam packetParam,LiveWatchUser watchUser,LiveWatchConfig config,LiveRedPacketParam param) {
+
+
+        // 确定红包金额
+        BigDecimal amount = config.getRedPacketAmount();
+
+        packetParam.setAmount(amount);
+
+        if (amount.compareTo(BigDecimal.ZERO) > 0) {
+
+            // 预设值异常对象
+
+            BalanceRollbackError balanceRollbackError = new BalanceRollbackError();
+            balanceRollbackError.setCompanyId(packetParam.getCompanyId());
+            balanceRollbackError.setUserId(watchUser.getUserId());
+            balanceRollbackError.setLogId(watchUser.getId());
+            balanceRollbackError.setVideoId(watchUser.getLiveId());
+            balanceRollbackError.setStatus(0);
+            balanceRollbackError.setMoney(amount);
+
+            // 打开红包扣减功能
+            if ("1".equals(config.getIsRedPackageBalanceDeduction())) {
+
+                if (packetParam.getCompanyId() == null) {
+                    logger.error("直播发送红包参数错误,公司不能为空,异常请求参数{}", packetParam);
+                    return R.error("发送红包失败,请联系管理员,公司不能为空");
+                }
+                String companyMoneyKey = FsConstants.COMPANY_MONEY_KEY + packetParam.getCompanyId();
+
+                // 第一次加锁:预扣减余额
+                RLock lock1 = redissonClient.getLock(FsConstants.COMPANY_MONEY_LOCK + packetParam.getCompanyId());
+                boolean lockAcquired = false;
+                BigDecimal newMoney;
+                try {
+                    if (lock1.tryLock(3, 10, TimeUnit.SECONDS)) {
+                        lockAcquired = true;
+                        BigDecimal originalMoney;
+                        // 获取当前余额
+                        String moneyStr = redisCache.getCacheObject(companyMoneyKey);
+                        if (StringUtils.isNotEmpty(moneyStr)) {
+                            originalMoney = new BigDecimal(moneyStr);
+                        } else {
+                            // 缓存没有值,重启系统恢复redis数据 保证数据正确性
+                            logger.error("直播发送红包获取redis余额缓存异常,异常请求参数{}", packetParam);
+                            return R.error("系统异常,余额缓存异常,请稍后重试");
+                        }
+
+                        if (originalMoney.compareTo(BigDecimal.ZERO) < 0) {
+                            logger.error("服务商余额不足,异常请求参数{}", packetParam);
+                            return R.error("服务商余额不足,请联系群主服务器充值!");
+                        }
+
+                        // 预扣减金额
+                        newMoney = originalMoney.subtract(amount);
+                        redisCache.setCacheObject(companyMoneyKey, newMoney.toString());
+                    } else {
+                        logger.error("获取redis锁失败,异常请求参数{}", packetParam);
+                        return R.error("系统繁忙,请稍后重试");
+                    }
+                } catch (Exception e) {
+                    logger.error("预扣减余额失败: 异常请求参数{},异常信息{}", packetParam, e.getMessage(), e);
+                    return R.error("系统异常,请稍后重试");
+                } finally {
+                    // 只有在成功获取锁的情况下才释放锁
+                    if (lockAcquired && lock1.isHeldByCurrentThread()) {
+                        try {
+                            lock1.unlock();
+                        } catch (IllegalMonitorStateException e) {
+                            logger.warn("尝试释放非当前线程持有的锁: companyId={}", packetParam.getCompanyId());
+                        }
+                    }
+                }
+
+
+                // 调用第三方接口(锁外操作)
+                R sendRedPacket;
+                try {
+                    sendRedPacket = paymentService.sendAppLiveRedPacket(packetParam);
+                } catch (Exception e) {
+                    logger.error("红包发送异常: 异常请求参数{}", packetParam, e);
+                    // 异常时回滚余额
+
+                    rollbackBalance(balanceRollbackError);
+                    return R.error("奖励发送失败,请联系客服");
+                }
+
+                // 红包发送成功处理
+                if (sendRedPacket.get("code").equals(200)) {
+
+                    // 更新观看记录的奖励类型
+                    watchUser.setRewardType(1);
+                    watchUser.setSendType(1);
+                    watchUserMapper.updateLiveWatchUser(watchUser);
+
+
+                    LiveRedPacketLog liveRedPacketLog = new LiveRedPacketLog();
+                    TransferBillsResult transferBillsResult;
+                    if (sendRedPacket.get("isNew").equals(1)) {
+                        transferBillsResult = (TransferBillsResult) sendRedPacket.get("data");
+                        liveRedPacketLog.setResult(JSON.toJSONString(sendRedPacket));
+                        liveRedPacketLog.setOutBatchNo(transferBillsResult.getOutBillNo());
+                        liveRedPacketLog.setBatchId(transferBillsResult.getTransferBillNo());
+                    } else {
+                        liveRedPacketLog.setOutBatchNo(sendRedPacket.get("orderCode").toString());
+                        liveRedPacketLog.setBatchId(sendRedPacket.get("batchId").toString());
+                    }
+
+                    liveRedPacketLog.setCompanyId(param.getCompanyId());
+                    liveRedPacketLog.setUserId(watchUser.getUserId());
+                    liveRedPacketLog.setLiveId(watchUser.getLiveId());
+                    liveRedPacketLog.setStatus(0L);
+                    liveRedPacketLog.setCompanyUserId(param.getCompanyUserId());
+                    liveRedPacketLog.setCreateTime(new Date());
+                    liveRedPacketLog.setAmount(amount);
+                    liveRedPacketLog.setWatchLogId(watchUser.getId());
+                    liveRedPacketLog.setAppId(packetParam.getAppId());
+                    liveRedPacketLog.setWathcType(1L);
+
+                    redPacketLogMapper.insertLiveRedPacketLog(liveRedPacketLog);
+
+                    // 异步登记余额扣减日志
+                    BigDecimal money = amount.multiply(BigDecimal.valueOf(-1));
+                    companyService.asyncRecordBalanceLog(packetParam.getCompanyId(), money, 15, newMoney, "发放直播红包", liveRedPacketLog.getLogId());
+
+                    return sendRedPacket;
+
+
+                } else {
+                    // 发送失败,回滚余额
+                    rollbackBalance(balanceRollbackError);
+                    return R.error("奖励发送失败,请联系客服");
+                }
+
+                // ===================== 本次修改目的为了实时扣减公司余额=====================
+            } else {
+                Company company = companyMapper.selectCompanyById(packetParam.getCompanyId());
+                BigDecimal money = company.getMoney();
+                if (money.compareTo(BigDecimal.ZERO) <= 0) {
+                    return R.error("服务商余额不足,请联系群主服务器充值!");
+                }
+
+                try{
+                    // 发送红包
+                    R sendRedPacket = paymentService.sendAppLiveRedPacket(packetParam);
+                    if (sendRedPacket.get("code").equals(200)) {
+
+                        // 更新观看记录的奖励类型
+                        watchUser.setRewardType(1);
+                        watchUser.setSendType(1);
+                        watchUserMapper.updateLiveWatchUser(watchUser);
+
+
+                        LiveRedPacketLog liveRedPacketLog = new LiveRedPacketLog();
+                        TransferBillsResult transferBillsResult;
+                        if (sendRedPacket.get("isNew").equals(1)) {
+                            transferBillsResult = (TransferBillsResult) sendRedPacket.get("data");
+                            liveRedPacketLog.setResult(JSON.toJSONString(sendRedPacket));
+                            liveRedPacketLog.setOutBatchNo(transferBillsResult.getOutBillNo());
+                            liveRedPacketLog.setBatchId(transferBillsResult.getTransferBillNo());
+                        } else {
+                            liveRedPacketLog.setOutBatchNo(sendRedPacket.get("orderCode").toString());
+                            liveRedPacketLog.setBatchId(sendRedPacket.get("batchId").toString());
+                        }
+                        // 添加红包记录
+                        liveRedPacketLog.setCompanyId(param.getCompanyId());
+                        liveRedPacketLog.setUserId(watchUser.getUserId());
+                        liveRedPacketLog.setLiveId(watchUser.getLiveId());
+                        liveRedPacketLog.setStatus(0L);
+                        liveRedPacketLog.setCompanyUserId(param.getCompanyUserId());
+                        liveRedPacketLog.setCreateTime(new Date());
+                        liveRedPacketLog.setAmount(amount);
+                        liveRedPacketLog.setWatchLogId(watchUser.getId());
+                        liveRedPacketLog.setAppId( packetParam.getAppId());
+
+                        redPacketLogMapper.insertLiveRedPacketLog(liveRedPacketLog);
+
+                        return sendRedPacket;
+                    } else {
+                        return R.error("奖励发送失败,请联系客服");
+                    }
+                }catch (Exception e){
+                    return R.error(e.getMessage());
+                }
+
+            }
+        } else {
+
+            // 更新观看记录的奖励类型
+            watchUser.setRewardType(1);
+            watchUser.setSendType(1);
+            watchUserMapper.updateLiveWatchUser(watchUser);
+
+
+            LiveRedPacketLog liveRedPacketLog = new LiveRedPacketLog();
+            // 添加红包记录
+            liveRedPacketLog.setCompanyId(param.getCompanyId());
+            liveRedPacketLog.setUserId(watchUser.getUserId());
+            liveRedPacketLog.setLiveId(watchUser.getLiveId());
+            liveRedPacketLog.setStatus(1L);
+            liveRedPacketLog.setRemark("设置的直播红包金额为0");
+            liveRedPacketLog.setCompanyUserId(param.getCompanyUserId());
+            liveRedPacketLog.setCreateTime(new Date());
+            liveRedPacketLog.setAmount(BigDecimal.ZERO);
+            liveRedPacketLog.setWatchLogId(watchUser.getId());
+            liveRedPacketLog.setAppId( packetParam.getAppId());
+            redPacketLogMapper.insertLiveRedPacketLog(liveRedPacketLog);
+
+            return R.ok("答题成功!");
+        }
+    }
+
+    /**
+     * @Description: 回滚redis缓存中的余额 异常登记回滚异常表,定时重新回滚
+     * @Param:
+     * @Return:
+     * @Author
+     * @Date 2025/10/22 10:37
+     */
+    private void rollbackBalance(BalanceRollbackError balanceRollbackError) {
+        String companyMoneyKey = FsConstants.COMPANY_MONEY_KEY + balanceRollbackError.getCompanyId();
+        RLock lock2 = redissonClient.getLock(FsConstants.COMPANY_MONEY_LOCK + balanceRollbackError.getCompanyId());
+        boolean lockAcquired = false;
+        boolean backError = true;
+        try {
+            if (lock2.tryLock(3, 10, TimeUnit.SECONDS)) {
+                lockAcquired = true;
+                // 获取当前余额
+                String currentMoneyStr = redisCache.getCacheObject(companyMoneyKey);
+                if (StringUtils.isEmpty(currentMoneyStr)) {
+                    throw new RuntimeException("回滚余额异常");
+                }
+
+                // 回滚金额(加回之前扣减的金额)
+                BigDecimal rollbackMoney = new BigDecimal(currentMoneyStr).add(balanceRollbackError.getMoney());
+                redisCache.setCacheObject(companyMoneyKey, rollbackMoney.toString());
+                backError = false;
+                logger.info("余额回滚成功: companyId={}, amount={}", balanceRollbackError.getCompanyId(), balanceRollbackError.getMoney());
+
+            } else {
+                logger.warn("回滚余额时获取锁失败: companyId={}", balanceRollbackError.getCompanyId());
+                // 登记回滚余额异常表
+                balanceRollbackErrorMapper.insert(balanceRollbackError);
+            }
+        } catch (Exception e) {
+            logger.error("回滚余额时发生异常: companyId={}", balanceRollbackError.getCompanyId(), e);
+            // 登记回滚余额异常表
+            if (backError) {
+                balanceRollbackErrorMapper.insert(balanceRollbackError);
+            }
+        } finally {
+            // 只有在成功获取锁的情况下才释放锁
+            if (lockAcquired && lock2.isHeldByCurrentThread()) {
+                try {
+                    lock2.unlock();
+                } catch (IllegalMonitorStateException e) {
+                    logger.warn("尝试释放非当前线程持有的锁: balanceRollbackError={}", balanceRollbackError);
+                }
+            }
+        }
+    }
+
+    /**
+     * 发放直播积分奖励
+     *
+     * @param user   用户信息
+     * @param watchUser    观看日志
+     * @param config 配置信息
+     * @return 处理结果
+     */
+    private R sendLiveIntegralReward(LiveRedPacketParam param, FsUser user, LiveWatchUser watchUser, LiveWatchConfig config) {
+        // 更新用户积分
+        FsUser userMap = new FsUser();
+        userMap.setUserId(user.getUserId());
+        userMap.setIntegral(user.getIntegral() + config.getScoreAmount());
+        fsUserMapper.updateFsUser(userMap);
+
+        // 记录积分日志
+        FsUserIntegralLogs integralLogs = new FsUserIntegralLogs();
+        integralLogs.setIntegral(config.getScoreAmount());
+        integralLogs.setUserId(user.getUserId());
+        integralLogs.setBalance(userMap.getIntegral());
+        integralLogs.setLogType(25);
+        integralLogs.setBusinessId(StringUtils.isNotEmpty(watchUser.getId().toString()) ? watchUser.getId().toString() : null);
+        integralLogs.setCreateTime(new Date());
+        fsUserIntegralLogsMapper.insertFsUserIntegralLogs(integralLogs);
+
+//        //更新看课记录的奖励类型
+//        watchUser.setRewardType(2);
+//        watchUser.setSendType(1);
+//        watchUserMapper.updateLiveWatchUser(watchUser);
+        logger.info("发放奖励====================》直播看课记录,{}", watchUser);
+
+        //积分转换红包
+        LiveRedPacketLog liveRedPacketLog = new LiveRedPacketLog();
+
+        liveRedPacketLog.setOutBatchNo(integralLogs.getId().toString());
+        liveRedPacketLog.setCompanyId(param.getCompanyId());
+        liveRedPacketLog.setUserId(param.getUserId());
+        liveRedPacketLog.setLiveId(param.getLiveId());
+        liveRedPacketLog.setStatus(1L);
+        liveRedPacketLog.setCompanyUserId(param.getCompanyUserId());
+        liveRedPacketLog.setCreateTime(new Date());
+        liveRedPacketLog.setAmount(BigDecimal.valueOf(config.getScoreAmount()).divide(BigDecimal.valueOf(1000)));
+        liveRedPacketLog.setRemark("直播答题领取积分转");
+        liveRedPacketLog.setWatchLogId(watchUser.getId() != null ? watchUser.getId() : null);
+
+        redPacketLogMapper.insertLiveRedPacketLog(liveRedPacketLog);
+        return R.ok("直播积分奖励发放成功").put("rewardType", 2);
+    }
+
+}

+ 44 - 0
java/fs-service/src/main/java/com/fs/live/service/impl/LiveServiceImpl.java

@@ -14,6 +14,8 @@ import com.fs.common.core.page.PageRequest;
 import com.fs.common.exception.base.BaseException;
 import com.fs.company.mapper.CompanyMapper;
 import com.fs.company.vo.CompanyVO;
+import com.fs.course.domain.FsCourseQuestionBank;
+import com.fs.course.service.IFsCourseQuestionBankService;
 import com.fs.core.config.WxMaConfiguration;
 import com.fs.his.domain.FsStoreProduct;
 import com.fs.his.domain.FsUser;
@@ -35,6 +37,7 @@ import com.fs.common.utils.bean.BeanUtils;
 import com.fs.live.domain.*;
 import com.fs.live.mapper.*;
 import com.fs.live.param.LiveReplayParam;
+import com.fs.live.param.LiveRedPacketParam;
 import com.fs.live.service.*;
 import com.fs.live.utils.ProcessManager;
 import com.fs.live.vo.*;
@@ -134,6 +137,9 @@ public class LiveServiceImpl implements ILiveService
     @Lazy
     private ILiveWatchUserService liveWatchUserService;
 
+    @Autowired
+    private IFsCourseQuestionBankService fsCourseQuestionBankService;
+
     private static String TOKEN_VALID_CODE = "40001";
 
     private static volatile Integer version = 0;
@@ -1559,4 +1565,42 @@ public class LiveServiceImpl implements ILiveService
         String hexString = Long.toHexString(aLong);
         return hexString.toUpperCase();
     }
+
+    @Override
+    public R getQuestions(LiveRedPacketParam param) {
+        R liveStatusByUserID = liveWatchUserService.getLiveStatusByUserID(param.getLiveId(), param.getUserId());
+        String rewardType = liveStatusByUserID.get("liveStatus") == null ? null : liveStatusByUserID.get("liveStatus").toString();
+
+        Live live = this.selectLiveByLiveId(param.getLiveId());
+        LiveWatchConfig config = JSON.parseObject(live.getConfigJson(), LiveWatchConfig.class);
+        if (Boolean.TRUE.equals(config.getEnabled()) && Long.valueOf(3).equals(config.getParticipateCondition())) {
+            Long duration = live.getDuration();
+            Long completionRate = config.getCompletionRate();
+
+            if (duration == null || duration <= 0 || completionRate == null || completionRate <= 0) {
+                return R.error("直播完课配置异常");
+            }
+            if (StringUtils.isEmpty(config.getFinishQuestionIds())) {
+                return R.ok().put("questions", null);
+            }
+            List<Long> ids = Arrays.stream(config.getFinishQuestionIds().split(",")).map(Long::valueOf).collect(Collectors.toList());
+            List<FsCourseQuestionBank> questions = fsCourseQuestionBankService.selectFsCourseQuestionBankByIds(ids);
+
+            if ("直播".equals(rewardType)) {
+                return R.ok().put("questions", questions);
+            } else if ("录播".equals(rewardType)) {
+                List<RecordTimeRangeVO> ranges = LiveRedPacketLogServiceImpl.parseRecordRewards(config.getRecordTimeRangeStr());
+                RecordTimeRangeVO matched = LiveRedPacketLogServiceImpl.matchCurrentTime(ranges);
+                if (matched != null && matched.getRewardTypes() != null && matched.getRewardTypes().contains("1")) {
+                    return R.ok().put("questions", questions);
+                } else {
+                    return R.ok().put("questions", null);
+                }
+            } else {
+                return R.ok().put("questions", null);
+            }
+        } else {
+            return R.error("当前直播 并未启动 直播完课奖励 ");
+        }
+    }
 }

+ 373 - 0
java/fs-service/src/main/java/com/fs/live/service/impl/LiveVoteServiceImpl.java

@@ -0,0 +1,373 @@
+package com.fs.live.service.impl;
+
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.live.domain.LiveVote;
+import com.fs.live.domain.LiveVoteRecord;
+import com.fs.live.mapper.LiveVoteMapper;
+import com.fs.live.param.LiveVoteCastParam;
+import com.fs.live.service.ILiveVoteService;
+import com.fs.live.support.LiveTaskIndexSupport;
+import com.fs.live.support.LiveWsRoomPublisher;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 直播投票 Service 实现
+ *
+ * @author fs
+ * @date 2026-08-10
+ */
+@Service
+public class LiveVoteServiceImpl implements ILiveVoteService {
+
+    private static final Logger log = LoggerFactory.getLogger(LiveVoteServiceImpl.class);
+
+    private static final int DESC_MAX = 20;
+    private static final int OPTION_MAX = 12;
+    private static final int STATUS_NOT_STARTED = 0;
+    private static final int STATUS_RUNNING = 1;
+
+    @Autowired
+    private LiveVoteMapper liveVoteMapper;
+    @Autowired
+    private RedisCache redisCache;
+    @Autowired
+    private LiveTaskIndexSupport liveTaskIndexSupport;
+    @Autowired
+    private LiveWsRoomPublisher liveWsRoomPublisher;
+
+    @Override
+    public LiveVote selectLiveVoteById(Long id) {
+        LiveVote vote = liveVoteMapper.selectLiveVoteById(id);
+        fillHideAt(vote);
+        return vote;
+    }
+
+    @Override
+    public List<LiveVote> selectLiveVoteList(LiveVote liveVote) {
+        List<LiveVote> list = liveVoteMapper.selectLiveVoteList(liveVote);
+        if (list != null) {
+            for (LiveVote vote : list) {
+                fillHideAt(vote);
+            }
+        }
+        return list;
+    }
+
+    @Override
+    public int insertLiveVote(LiveVote liveVote) {
+        validateForSave(liveVote, true);
+        liveVote.setStatus(STATUS_NOT_STARTED);
+        liveVote.setVoteACount(0);
+        liveVote.setVoteBCount(0);
+        liveVote.setDelFlag(0);
+        if (liveVote.getHideDuration() == null) {
+            liveVote.setHideDuration(0);
+        }
+        liveVote.setCreateTime(DateUtils.getNowDate());
+        return liveVoteMapper.insertLiveVote(liveVote);
+    }
+
+    @Override
+    public int updateLiveVote(LiveVote liveVote) {
+        if (liveVote.getId() == null) {
+            throw new ServiceException("投票ID不能为空");
+        }
+        LiveVote exist = liveVoteMapper.selectLiveVoteById(liveVote.getId());
+        if (exist == null) {
+            throw new ServiceException("投票不存在");
+        }
+        if (exist.getStatus() == null || exist.getStatus() != STATUS_NOT_STARTED) {
+            throw new ServiceException("仅未开始的投票可修改");
+        }
+        validateForSave(liveVote, false);
+        liveVote.setUpdateTime(DateUtils.getNowDate());
+        // 不允许通过 update 改状态/票数
+        liveVote.setStatus(null);
+        liveVote.setVoteACount(null);
+        liveVote.setVoteBCount(null);
+        liveVote.setWinner(null);
+        liveVote.setStartTime(null);
+        liveVote.setVoteEndTime(null);
+        return liveVoteMapper.updateLiveVote(liveVote);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public int deleteLiveVoteByIds(Long[] ids) {
+        if (ids == null || ids.length == 0) {
+            return 0;
+        }
+        for (Long id : ids) {
+            LiveVote vote = liveVoteMapper.selectLiveVoteById(id);
+            if (vote == null) {
+                throw new ServiceException("投票不存在,ID=" + id);
+            }
+            // 仅未开始可删;进行中/已结束拒绝
+            if (vote.getStatus() == null || vote.getStatus() != STATUS_NOT_STARTED) {
+                throw new ServiceException("只能删除未开始的投票,投票ID=" + vote.getId());
+            }
+        }
+        return liveVoteMapper.deleteLiveVoteByIds(ids);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public R start(Long id) {
+        LiveVote vote = liveVoteMapper.selectLiveVoteById(id);
+        if (vote == null) {
+            return R.error("投票不存在");
+        }
+        if (vote.getStatus() == null || vote.getStatus() != STATUS_NOT_STARTED) {
+            return R.error("仅未开始的投票可开始");
+        }
+        if (liveVoteMapper.countActiveByLiveId(vote.getLiveId(), vote.getId()) > 0) {
+            return R.error("同一直播间同时只允许一个进行中的投票");
+        }
+        Date now = DateUtils.getNowDate();
+        LiveVote update = new LiveVote();
+        update.setId(vote.getId());
+        update.setStatus(STATUS_RUNNING);
+        update.setStartTime(now);
+        update.setUpdateTime(now);
+        liveVoteMapper.updateLiveVote(update);
+
+        vote.setStatus(STATUS_RUNNING);
+        vote.setStartTime(now);
+        vote.setUpdateTime(now);
+        addToZSet(vote);
+        fillHideAt(vote);
+        liveWsRoomPublisher.publishVote("vote", vote);
+        return R.ok().put("data", vote);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public R end(Long id) {
+        LiveVote vote = liveVoteMapper.selectLiveVoteById(id);
+        if (vote == null) {
+            return R.error("投票不存在");
+        }
+        if (vote.getStatus() == null || vote.getStatus() != STATUS_RUNNING) {
+            return R.error("仅进行中的投票可结束");
+        }
+        LiveVote ended = doEnd(vote);
+        liveWsRoomPublisher.publishVote("voteEnd", ended);
+        return R.ok().put("data", ended);
+    }
+
+    @Override
+    public List<LiveVote> finishDueByIds(Set<String> ids) {
+        List<LiveVote> endedList = new ArrayList<>();
+        if (ids == null || ids.isEmpty()) {
+            return endedList;
+        }
+        List<LiveVote> votes = liveVoteMapper.selectByIds(ids);
+        if (votes == null || votes.isEmpty()) {
+            return endedList;
+        }
+        for (LiveVote vote : votes) {
+            if (vote.getStatus() == null || vote.getStatus() != STATUS_RUNNING) {
+                removeFromZSet(vote);
+                continue;
+            }
+            try {
+                LiveVote ended = doEnd(vote);
+                liveWsRoomPublisher.publishVote("voteEnd", ended);
+                endedList.add(ended);
+            } catch (Exception e) {
+                log.error("到期结束投票失败 voteId={}", vote.getId(), e);
+            }
+        }
+        return endedList;
+    }
+
+    @Override
+    public LiveVote getCurrent(Long liveId, Long userId) {
+        if (liveId == null) {
+            return null;
+        }
+        LiveVote vote = liveVoteMapper.selectCurrentVisible(liveId);
+        if (vote == null) {
+            return null;
+        }
+        fillHideAt(vote);
+        if (userId != null) {
+            LiveVoteRecord record = liveVoteMapper.selectRecordByVoteAndUser(vote.getId(), userId);
+            if (record != null) {
+                vote.setMyChoice(record.getOptionChoice());
+            }
+        }
+        return vote;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public R cast(LiveVoteCastParam param) {
+        if (param == null || param.getVoteId() == null) {
+            return R.error("投票ID不能为空");
+        }
+        if (param.getUserId() == null) {
+            return R.error("用户未登录");
+        }
+        String choice = param.getOptionChoice() == null ? null : param.getOptionChoice().trim().toUpperCase();
+        if (!"A".equals(choice) && !"B".equals(choice)) {
+            return R.error("选项只能是 A 或 B");
+        }
+        LiveVote vote = liveVoteMapper.selectLiveVoteById(param.getVoteId());
+        if (vote == null) {
+            return R.error("投票不存在");
+        }
+        if (vote.getStatus() == null || vote.getStatus() != STATUS_RUNNING) {
+            return R.error("投票未开始或已结束");
+        }
+        LiveVoteRecord exist = liveVoteMapper.selectRecordByVoteAndUser(vote.getId(), param.getUserId());
+        if (exist != null) {
+            return R.error("您已投过票,不可修改");
+        }
+
+        LiveVoteRecord record = new LiveVoteRecord();
+        record.setVoteId(vote.getId());
+        record.setLiveId(vote.getLiveId());
+        record.setUserId(param.getUserId());
+        record.setOptionChoice(choice);
+        record.setCreateTime(DateUtils.getNowDate());
+        try {
+            liveVoteMapper.insertLiveVoteRecord(record);
+        } catch (DuplicateKeyException e) {
+            return R.error("您已投过票,不可修改");
+        }
+
+        int rows = "A".equals(choice)
+                ? liveVoteMapper.incrVoteACount(vote.getId())
+                : liveVoteMapper.incrVoteBCount(vote.getId());
+        if (rows <= 0) {
+            throw new ServiceException("投票失败,请稍后重试");
+        }
+
+        LiveVote latest = liveVoteMapper.selectLiveVoteById(vote.getId());
+        fillHideAt(latest);
+        latest.setMyChoice(choice);
+        liveWsRoomPublisher.publishVote("voteUpdate", latest);
+        return R.ok().put("data", latest);
+    }
+
+    private LiveVote doEnd(LiveVote vote) {
+        LiveVote latest = liveVoteMapper.selectLiveVoteById(vote.getId());
+        if (latest == null || latest.getStatus() == null || latest.getStatus() != STATUS_RUNNING) {
+            removeFromZSet(vote);
+            throw new ServiceException("结束投票失败,状态已变更");
+        }
+        Date now = DateUtils.getNowDate();
+        int a = latest.getVoteACount() == null ? 0 : latest.getVoteACount();
+        int b = latest.getVoteBCount() == null ? 0 : latest.getVoteBCount();
+        String winner = null;
+        if (a > b) {
+            winner = "A";
+        } else if (b > a) {
+            winner = "B";
+        }
+        int rows = liveVoteMapper.endLiveVote(latest.getId(), winner, now);
+        if (rows <= 0) {
+            throw new ServiceException("结束投票失败,状态已变更");
+        }
+        removeFromZSet(latest);
+        LiveVote ended = liveVoteMapper.selectLiveVoteById(latest.getId());
+        fillHideAt(ended);
+        return ended;
+    }
+
+    private void addToZSet(LiveVote vote) {
+        if (vote.getLiveId() == null || vote.getId() == null || vote.getDuration() == null) {
+            return;
+        }
+        String cacheKey = LiveKeysConstant.LIVE_VOTE_TASK_PREFIX + vote.getLiveId();
+        LocalDateTime endAt = LocalDateTime.now().plusMinutes(vote.getDuration());
+        double score = endAt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
+        redisCache.redisTemplate.opsForZSet().add(cacheKey, String.valueOf(vote.getId()), score);
+        int expireMinutes = Math.max(vote.getDuration() + 30, 60);
+        redisCache.redisTemplate.expire(cacheKey, expireMinutes, TimeUnit.MINUTES);
+        liveTaskIndexSupport.registerVote(vote.getLiveId());
+    }
+
+    private void removeFromZSet(LiveVote vote) {
+        if (vote == null || vote.getLiveId() == null || vote.getId() == null) {
+            return;
+        }
+        String cacheKey = LiveKeysConstant.LIVE_VOTE_TASK_PREFIX + vote.getLiveId();
+        redisCache.redisTemplate.opsForZSet().remove(cacheKey, String.valueOf(vote.getId()));
+        liveTaskIndexSupport.cleanupIfEmpty(
+                LiveKeysConstant.LIVE_VOTE_TASK_INDEX, cacheKey, vote.getLiveId());
+    }
+
+    private void fillHideAt(LiveVote vote) {
+        if (vote == null) {
+            return;
+        }
+        if (vote.getVoteEndTime() == null) {
+            vote.setHideAt(null);
+            return;
+        }
+        int hide = vote.getHideDuration() == null ? 0 : vote.getHideDuration();
+        Calendar cal = Calendar.getInstance();
+        cal.setTime(vote.getVoteEndTime());
+        cal.add(Calendar.MINUTE, hide);
+        vote.setHideAt(cal.getTime());
+    }
+
+    private void validateForSave(LiveVote liveVote, boolean create) {
+        if (create && liveVote.getLiveId() == null) {
+            throw new ServiceException("直播间ID不能为空");
+        }
+        if (create || liveVote.getDescription() != null) {
+            if (StringUtils.isEmpty(liveVote.getDescription())) {
+                throw new ServiceException("投票描述不能为空");
+            }
+            if (liveVote.getDescription().length() > DESC_MAX) {
+                throw new ServiceException("投票描述不能超过" + DESC_MAX + "字");
+            }
+        }
+        if (create || liveVote.getOptionA() != null) {
+            if (StringUtils.isEmpty(liveVote.getOptionA())) {
+                throw new ServiceException("选项A不能为空");
+            }
+            if (liveVote.getOptionA().length() > OPTION_MAX) {
+                throw new ServiceException("选项A不能超过" + OPTION_MAX + "字");
+            }
+        }
+        if (create || liveVote.getOptionB() != null) {
+            if (StringUtils.isEmpty(liveVote.getOptionB())) {
+                throw new ServiceException("选项B不能为空");
+            }
+            if (liveVote.getOptionB().length() > OPTION_MAX) {
+                throw new ServiceException("选项B不能超过" + OPTION_MAX + "字");
+            }
+        }
+        if (create || liveVote.getDuration() != null) {
+            if (liveVote.getDuration() == null || liveVote.getDuration() <= 0) {
+                throw new ServiceException("可投票时间必须大于0分钟");
+            }
+        }
+        if (liveVote.getHideDuration() != null && liveVote.getHideDuration() < 0) {
+            throw new ServiceException("隐藏时间不能为负数");
+        }
+    }
+}

+ 103 - 0
java/fs-service/src/main/java/com/fs/live/service/impl/LiveWatchUserServiceImpl.java

@@ -1279,4 +1279,107 @@ public class LiveWatchUserServiceImpl implements ILiveWatchUserService {
         }
     }
 
+    @Override
+    public R sendLiveRewardClone(long liveId, long userId) {
+        Live live = liveMapper.selectLiveByLiveId(liveId);
+        if (live == null) {
+            return R.error("直播间不存在");
+        }
+        try {
+            String entryTimeKey = String.format(USER_ENTRY_TIME_KEY, liveId, userId);
+            Object entryTimeObj = redisCache.getCacheObject(entryTimeKey);
+            Long entryTime = null;
+            if (entryTimeObj != null) {
+                if (entryTimeObj instanceof Long) {
+                    entryTime = (Long) entryTimeObj;
+                } else if (entryTimeObj instanceof String) {
+                    try {
+                        entryTime = Long.parseLong((String) entryTimeObj);
+                    } catch (NumberFormatException e) {
+                        return R.error("无法解析进入时间字符串为Long:"+ entryTimeObj);
+                    }
+                } else if (entryTimeObj instanceof Number) {
+                    entryTime = ((Number) entryTimeObj).longValue();
+                }
+            }
+            if (entryTime == null) {
+                return R.error("未找到对应的观看时长-缓");
+            }
+            Map<String, Integer> flagMap = this.getLiveFlagWithCache(liveId);
+            Integer currentLiveFlag = flagMap.get("liveFlag");
+            Integer currentReplayFlag = flagMap.get("replayFlag");
+            LiveWatchUser liveWatchUser = baseMapper.selectByUniqueIndex(liveId, userId, currentLiveFlag, currentReplayFlag);
+            if (liveWatchUser == null) {
+                return R.error("未找到对应的观看记录");
+            }
+            long currentTimeMillis = System.currentTimeMillis();
+            Date now = new Date();
+            long durationSeconds = (currentTimeMillis - entryTime) / 1000;
+            if (durationSeconds <= 0) {
+                return R.error("在线时长不足");
+            }
+            Long onlineSeconds = liveWatchUser.getOnlineSeconds();
+            if (onlineSeconds == null) {
+                onlineSeconds = 0L;
+            }
+            liveWatchUser.setOnlineSeconds(onlineSeconds + durationSeconds);
+            liveWatchUser.setUpdateTime(now);
+            baseMapper.updateLiveWatchUser(liveWatchUser);
+            redisCache.setCacheObject(entryTimeKey, System.currentTimeMillis(), 24, TimeUnit.HOURS);
+        } catch (Exception e) {
+            log.error("更新用户在线时长异常:liveId={}, userId={}, error={}",
+                    liveId, userId, e.getMessage(), e);
+            return R.error("更新用户在线时长异常");
+        }
+        return R.ok();
+    }
+
+    @Override
+    public R getLiveStatusByUserID(Long liveId, Long userId) {
+        LiveWatchUser watchUser;
+        LiveWatchUser replayUser;
+
+        watchUser = baseMapper.getWatchLiveByLiveFlag(userId, liveId);
+
+        if (watchUser == null) {
+            replayUser = baseMapper.getWatchLiveByReplayFlag(userId, liveId);
+            if (replayUser == null) {
+                return R.error("没有直播看课记录");
+            }
+            LiveWatchUserStatusVO replayUserVO = new LiveWatchUserStatusVO();
+            BeanUtil.copyProperties(replayUser, replayUserVO);
+            return R.ok().put("watchUser", watchUser)
+                    .put("rewardType", replayUserVO.getRewardType())
+                    .put("replayUser", replayUserVO)
+                    .put("liveStatus", "录播");
+        } else {
+            Integer rewardType = null;
+            String liveStatus;
+            replayUser = baseMapper.getWatchLiveByReplayFlag(userId, liveId);
+
+            if (watchUser.getRewardType() != null || (replayUser != null && replayUser.getRewardType() != null)) {
+                rewardType = 1;
+            }
+
+            LiveWatchUserStatusVO watchUserVO = new LiveWatchUserStatusVO();
+            BeanUtil.copyProperties(watchUser, watchUserVO);
+
+            if (replayUser != null) {
+                liveStatus = "录播";
+                LiveWatchUserStatusVO replayUserVO = new LiveWatchUserStatusVO();
+                BeanUtil.copyProperties(replayUser, replayUserVO);
+                return R.ok().put("watchUser", watchUserVO)
+                        .put("rewardType", rewardType)
+                        .put("replayUser", replayUserVO)
+                        .put("liveStatus", liveStatus);
+            }
+
+            liveStatus = "直播";
+            return R.ok().put("watchUser", watchUserVO)
+                    .put("rewardType", rewardType)
+                    .put("replayUser", replayUser)
+                    .put("liveStatus", liveStatus);
+        }
+    }
+
 }

+ 143 - 0
java/fs-service/src/main/java/com/fs/live/support/LiveTaskIndexSupport.java

@@ -0,0 +1,143 @@
+package com.fs.live.support;
+
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.redis.RedisCache;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
+
+/**
+ * 直播到期任务 Redis 索引:用 Set 记录有待处理 ZSet 的 liveId,
+ * 替代秒级任务里的 SCAN/KEYS 全库扫描。
+ */
+@Component
+public class LiveTaskIndexSupport {
+
+    private static final Logger log = LoggerFactory.getLogger(LiveTaskIndexSupport.class);
+
+    private static final Pattern NUMERIC_SUFFIX = Pattern.compile("^\\d+$");
+
+    @Autowired
+    private RedisCache redisCache;
+
+    public void registerLottery(Long liveId) {
+        register(LiveKeysConstant.LIVE_LOTTERY_TASK_INDEX, liveId);
+    }
+
+    public void registerRed(Long liveId) {
+        register(LiveKeysConstant.LIVE_RED_TASK_INDEX, liveId);
+    }
+
+    public void registerAuto(Long liveId) {
+        register(LiveKeysConstant.LIVE_AUTO_TASK_INDEX, liveId);
+    }
+
+    public void registerVote(Long liveId) {
+        register(LiveKeysConstant.LIVE_VOTE_TASK_INDEX, liveId);
+    }
+
+    public void unregisterLottery(Long liveId) {
+        unregister(LiveKeysConstant.LIVE_LOTTERY_TASK_INDEX, liveId);
+    }
+
+    public void unregisterRed(Long liveId) {
+        unregister(LiveKeysConstant.LIVE_RED_TASK_INDEX, liveId);
+    }
+
+    public void unregisterAuto(Long liveId) {
+        unregister(LiveKeysConstant.LIVE_AUTO_TASK_INDEX, liveId);
+    }
+
+    public void unregisterVote(Long liveId) {
+        unregister(LiveKeysConstant.LIVE_VOTE_TASK_INDEX, liveId);
+    }
+
+    /**
+     * ZSet 为空时删 key 并从索引移除。
+     */
+    public void cleanupIfEmpty(String indexKey, String zsetKey, Long liveId) {
+        if (liveId == null || zsetKey == null) {
+            return;
+        }
+        Long size = redisCache.redisTemplate.opsForZSet().zCard(zsetKey);
+        if (size == null || size == 0L) {
+            redisCache.deleteObject(zsetKey);
+            unregister(indexKey, liveId);
+        }
+    }
+
+    /**
+     * 列出任务 ZSet 完整 key(先保证索引已迁移)。
+     */
+    public Set<String> listTaskKeys(String indexKey, String keyPrefix) {
+        migrateOnce(indexKey, keyPrefix);
+        Set<String> members = redisCache.getCacheSet(indexKey);
+        if (members == null || members.isEmpty()) {
+            return Collections.emptySet();
+        }
+        Set<String> keys = new HashSet<>(members.size());
+        for (String member : members) {
+            if (member == null) {
+                continue;
+            }
+            String liveId = member.trim();
+            if (NUMERIC_SUFFIX.matcher(liveId).matches()) {
+                keys.add(keyPrefix + liveId);
+            }
+        }
+        return keys;
+    }
+
+    public void register(String indexKey, Long liveId) {
+        if (liveId == null || indexKey == null) {
+            return;
+        }
+        redisCache.redisTemplate.opsForSet().add(indexKey, String.valueOf(liveId));
+    }
+
+    public void unregister(String indexKey, Long liveId) {
+        if (liveId == null || indexKey == null) {
+            return;
+        }
+        redisCache.redisTemplate.opsForSet().remove(indexKey, String.valueOf(liveId));
+    }
+
+    /**
+     * 一次性把存量 ZSet key 灌进索引;之后只走 SMEMBERS,不再 SCAN。
+     */
+    private void migrateOnce(String indexKey, String keyPrefix) {
+        String flagKey = indexKey + ":migrated";
+        if (redisCache.hasKey(flagKey)) {
+            return;
+        }
+        try {
+            Set<String> scanned = redisCache.scanBusinessKeys(keyPrefix + "*");
+            int added = 0;
+            if (scanned != null) {
+                for (String key : scanned) {
+                    if (key == null || !key.startsWith(keyPrefix)) {
+                        continue;
+                    }
+                    String suffix = key.substring(keyPrefix.length());
+                    if (!NUMERIC_SUFFIX.matcher(suffix).matches()) {
+                        continue;
+                    }
+                    redisCache.redisTemplate.opsForSet().add(indexKey, suffix);
+                    added++;
+                }
+            }
+            // 迁移标记长期保留;即使当时为空,后续写入始终会 register
+            redisCache.setCacheObject(flagKey, "1", 365, TimeUnit.DAYS);
+            log.info("直播任务索引迁移完成 indexKey={}, prefix={}, added={}", indexKey, keyPrefix, added);
+        } catch (Exception e) {
+            log.error("直播任务索引迁移失败 indexKey={}, prefix={}", indexKey, keyPrefix, e);
+        }
+    }
+}

+ 78 - 0
java/fs-service/src/main/java/com/fs/live/support/LiveWsRoomPublisher.java

@@ -0,0 +1,78 @@
+package com.fs.live.support;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.config.RedisTenantContext;
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.domain.R;
+import com.fs.common.live.queue.LiveWsRoomQueueItem;
+import com.fs.common.utils.SecurityUtils;
+import com.fs.live.domain.LiveVote;
+import org.redisson.api.RTopic;
+import org.redisson.api.RedissonClient;
+import org.redisson.client.codec.StringCodec;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+/**
+ * 跨模块直播间 WS 广播:向 Redisson Topic {@code live:ws:room} 发消息,
+ * 由 fs-live-app 各节点按 tenantId:liveId 本地推送。
+ */
+@Component
+public class LiveWsRoomPublisher {
+
+    private static final Logger log = LoggerFactory.getLogger(LiveWsRoomPublisher.class);
+
+    @Autowired
+    private RedissonClient redissonClient;
+
+    public void publishVote(String cmd, LiveVote vote) {
+        if (vote == null || vote.getLiveId() == null) {
+            return;
+        }
+        JSONObject data = new JSONObject();
+        data.put("liveId", vote.getLiveId());
+        data.put("cmd", cmd);
+        data.put("on", true);
+        data.put("voteId", vote.getId());
+        data.put("description", vote.getDescription());
+        data.put("optionA", vote.getOptionA());
+        data.put("optionB", vote.getOptionB());
+        data.put("duration", vote.getDuration());
+        data.put("hideDuration", vote.getHideDuration());
+        data.put("status", vote.getStatus());
+        data.put("voteACount", vote.getVoteACount());
+        data.put("voteBCount", vote.getVoteBCount());
+        data.put("winner", vote.getWinner());
+        data.put("startTime", vote.getStartTime());
+        data.put("endTime", vote.getVoteEndTime());
+        data.put("hideAt", vote.getHideAt());
+        data.put("createTime", vote.getCreateTime());
+        data.put("updateTime", vote.getUpdateTime());
+        publish(vote.getLiveId(), JSONObject.toJSONString(R.ok().put("data", data)));
+    }
+
+    public void publish(Long liveId, String message) {
+        if (liveId == null || message == null) {
+            return;
+        }
+        try {
+            Long tenantId = resolveTenantId();
+            LiveWsRoomQueueItem item = new LiveWsRoomQueueItem(tenantId, liveId, message);
+            RTopic topic = redissonClient.getTopic(LiveKeysConstant.LIVE_WS_ROOM_CHANNEL, StringCodec.INSTANCE);
+            topic.publish(JSON.toJSONString(item));
+        } catch (Exception e) {
+            log.warn("直播间 WS Redisson 广播失败 liveId={}", liveId, e);
+        }
+    }
+
+    private Long resolveTenantId() {
+        Long tenantId = RedisTenantContext.getTenantId();
+        if (tenantId == null) {
+            tenantId = SecurityUtils.getTenantId();
+        }
+        return tenantId == null ? 0L : tenantId;
+    }
+}

+ 24 - 0
java/fs-service/src/main/java/com/fs/live/vo/AnswerEvaluation.java

@@ -0,0 +1,24 @@
+package com.fs.live.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class AnswerEvaluation {
+    private final boolean allCorrect;
+    private final List<LiveCompletionAnswerDetailVO> details;
+
+    public AnswerEvaluation(boolean allCorrect, List<LiveCompletionAnswerDetailVO> details) {
+        this.allCorrect = allCorrect;
+        this.details = details;
+    }
+
+    public boolean isAllCorrect() {
+        return allCorrect;
+    }
+
+    public List<LiveCompletionAnswerDetailVO> getDetails() {
+        return details;
+    }
+}

+ 24 - 0
java/fs-service/src/main/java/com/fs/live/vo/LiveCompletionAnswerDetailVO.java

@@ -0,0 +1,24 @@
+package com.fs.live.vo;
+
+import lombok.Data;
+
+/**
+ * 完课答题单题明细(落库快照)
+ */
+@Data
+public class LiveCompletionAnswerDetailVO {
+
+    private Long questionId;
+
+    private String title;
+
+    /** 题型 1单选 2多选 */
+    private Long type;
+
+    private String userAnswer;
+
+    private String correctAnswer;
+
+    /** 本题是否答对 0否 1是 */
+    private Integer isRight;
+}

+ 39 - 0
java/fs-service/src/main/java/com/fs/live/vo/LiveCompletionAnswerRecordListVO.java

@@ -0,0 +1,39 @@
+package com.fs.live.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 直播完课答题记录列表 VO
+ */
+@Data
+public class LiveCompletionAnswerRecordListVO {
+
+    private Long id;
+
+    @Excel(name = "直播ID")
+    private Long liveId;
+
+    @Excel(name = "直播名称")
+    private String liveName;
+
+    @Excel(name = "用户ID")
+    private Long userId;
+
+    @Excel(name = "用户名称")
+    private String userName;
+
+    @Excel(name = "是否全部正确", readConverterExp = "0=否,1=是")
+    private Integer isRight;
+
+    private String answerJson;
+
+    private String questionJson;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @Excel(name = "答题时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+}

+ 26 - 0
java/fs-service/src/main/java/com/fs/live/vo/LiveCompletionCouponAnswerResult.java

@@ -0,0 +1,26 @@
+package com.fs.live.vo;
+
+import lombok.Data;
+
+/**
+ * 完课优惠券答题结果
+ */
+@Data
+public class LiveCompletionCouponAnswerResult {
+
+    /**
+    *  是否答题次数结束
+    */
+    private boolean answerNumOver=false;
+
+    /** 是否全部答对 */
+    private boolean allCorrect;
+
+    /** 答题记录ID(写入 live_completion_answer_record) */
+    private Long recordId;
+
+    /**
+    * 剩余答题机会
+    */
+    private Integer correctNum;
+}

+ 28 - 0
java/fs-service/src/main/java/com/fs/live/vo/LiveWatchUserStatusVO.java

@@ -0,0 +1,28 @@
+package com.fs.live.vo;
+
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+@Data
+public class LiveWatchUserStatusVO {
+
+
+    /** 用户ID */
+    @Excel(name = "用户ID")
+    private Long userId;
+
+    private Long onlineSeconds;
+
+    /** 直播进入标记:0-否 1-是 */
+    @Excel(name = "直播进入标记")
+    private Integer liveFlag = 0;
+
+    /** 回放进入标记:0-否 1-是 */
+    @Excel(name = "回放进入标记")
+    private Integer replayFlag = 0;
+
+    /**
+     * 奖励类型 1红包 2积分
+     */
+    private Integer rewardType;
+}

+ 29 - 0
java/fs-service/src/main/java/com/fs/live/vo/RecordTimeRangeVO.java

@@ -0,0 +1,29 @@
+package com.fs.live.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+@Data
+public class RecordTimeRangeVO {
+
+    private String startTime;   // "17:44"
+    private String endTime;     // "18:00"
+    private List<String> rewardTypes;  // 1=红包 2=积分 3=核销卷,多个用|分隔
+    /**
+    * 红包金额
+    */
+    private BigDecimal redPacketAmount;
+
+    /**
+    * 积分
+    */
+    private Long scoreAmount;
+
+    /**
+    * 核销卷ID列表
+    */
+    private List<Long> couponIds;
+
+}

+ 26 - 0
java/fs-service/src/main/java/com/fs/lobster/domain/LobsterKbCategory.java

@@ -0,0 +1,26 @@
+package com.fs.lobster.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+import java.io.Serializable;
+
+@Data
+@Accessors(chain = true)
+@TableName("lobster_kb_category")
+public class LobsterKbCategory implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 分类名称 */
+    private String name;
+
+    /** 分类描述 */
+    private String description;
+}

+ 43 - 0
java/fs-service/src/main/java/com/fs/lobster/domain/LobsterKbMeta.java

@@ -0,0 +1,43 @@
+package com.fs.lobster.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@Accessors(chain = true)
+@TableName("lobster_kb_meta")
+public class LobsterKbMeta implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** Qdrant collection 名称 */
+    private String collectionName;
+
+    /** 显示名称 */
+    private String displayName;
+
+    /** 分类 */
+    private String category;
+
+    /** 描述 */
+    private String description;
+
+    /** 0=停用, 1=启用 */
+    private Integer status;
+
+    /** 检索配置(JSON) */
+    private String retrievalConfig;
+
+    private Date createTime;
+
+    private Date updateTime;
+}

+ 20 - 0
java/fs-service/src/main/java/com/fs/lobster/mapper/KbCategoryMapper.java

@@ -0,0 +1,20 @@
+package com.fs.lobster.mapper;
+
+import com.fs.lobster.domain.LobsterKbCategory;
+
+import java.util.List;
+
+public interface KbCategoryMapper {
+
+    public LobsterKbCategory selectById(Long id);
+
+    public List<LobsterKbCategory> selectAll();
+
+    public LobsterKbCategory selectByName(String name);
+
+    public int insert(LobsterKbCategory category);
+
+    public int update(LobsterKbCategory category);
+
+    public int deleteById(Long id);
+}

+ 20 - 0
java/fs-service/src/main/java/com/fs/lobster/mapper/KbMetaMapper.java

@@ -0,0 +1,20 @@
+package com.fs.lobster.mapper;
+
+import com.fs.lobster.domain.LobsterKbMeta;
+
+import java.util.List;
+
+public interface KbMetaMapper {
+
+    public LobsterKbMeta selectById(Long id);
+
+    public List<LobsterKbMeta> selectList(LobsterKbMeta meta);
+
+    public LobsterKbMeta selectByCollectionName(String collectionName);
+
+    public int insert(LobsterKbMeta meta);
+
+    public int update(LobsterKbMeta meta);
+
+    public int deleteById(Long id);
+}

+ 20 - 0
java/fs-service/src/main/java/com/fs/lobster/service/IKbCategoryService.java

@@ -0,0 +1,20 @@
+package com.fs.lobster.service;
+
+import com.fs.lobster.domain.LobsterKbCategory;
+
+import java.util.List;
+
+public interface IKbCategoryService {
+
+    public LobsterKbCategory selectById(Long id);
+
+    public List<LobsterKbCategory> selectAll();
+
+    public LobsterKbCategory selectByName(String name);
+
+    public int insert(LobsterKbCategory category);
+
+    public int update(LobsterKbCategory category);
+
+    public int deleteById(Long id);
+}

+ 22 - 0
java/fs-service/src/main/java/com/fs/lobster/service/IKbMetaService.java

@@ -0,0 +1,22 @@
+package com.fs.lobster.service;
+
+import com.fs.lobster.domain.LobsterKbMeta;
+
+import java.util.List;
+
+public interface IKbMetaService {
+
+    public LobsterKbMeta getByCollectionName(String collectionName);
+
+    public List<LobsterKbMeta> listAll();
+
+    public List<LobsterKbMeta> list(LobsterKbMeta meta);
+
+    public int save(LobsterKbMeta meta);
+
+    public int update(LobsterKbMeta meta);
+
+    public int deleteByCollectionName(String collectionName);
+
+    public int toggleStatus(String collectionName, Integer status);
+}

Some files were not shown because too many files changed in this diff