Просмотр исходного кода

feat(chat): 添加WebSocket聊天功能和消息管理

- 配置Spring Boot调度功能启用
- 注释掉原有的websocket配置避免冲突
- 添加ChatWebSocketConfig配置类实现WebSocket消息代理
- 创建ChatWebSocketController处理聊天消息和用户通知
- 定义CsMessage实体类存储消息数据
- 创建CsMessageBo业务对象处理消息输入
- 实现CsMessageController提供消息发送和查询接口
- 添加CsMessageMapper数据访问层
- 实现CsMessageServiceImpl业务逻辑处理
- 创建CsMessageVo视图对象返回消息数据
- 添加CsOrderCard实体类处理订单卡片
- 创建CsOrderCardBo业务对象
- 实现CsOrderCardController提供订单卡片操作接口
- 添加CsOrderCardMapper数据访问层
- 实现CsOrderCardServiceImpl业务逻辑
- 创建CsOrderCardVo视图对象
- 添加CsReadRecord实体类记录阅读状态
- 创建CsReadRecordMapper数据访问层
- 添加CsSeatConfig实体类配置客服坐席
- 创建CsSeatConfigBo业务对象
- 实现CsSeatConfigController提供坐席配置管理接口
西格玛许 2 недель назад
Родитель
Сommit
38b8c1c66d
51 измененных файлов с 2833 добавлено и 12 удалено
  1. 8 5
      ruoyi-admin/src/main/resources/application.yml
  2. 22 1
      ruoyi-modules/ruoyi-demo/src/main/java/org/dromara/demo/service/impl/KaoshixingService.java
  3. 4 0
      ruoyi-modules/ruoyi-main/pom.xml
  4. 34 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/config/ChatWebSocketConfig.java
  5. 65 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/controller/ChatWebSocketController.java
  6. 106 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/controller/CsMessageController.java
  7. 56 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/controller/CsOrderCardController.java
  8. 96 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/controller/CsSeatConfigController.java
  9. 66 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/controller/CsSessionController.java
  10. 42 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/controller/CsTicketController.java
  11. 35 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/controller/MainExamEvaluationController.java
  12. 4 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/controller/MainTrainingController.java
  13. 91 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/CsMessage.java
  14. 67 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/CsOrderCard.java
  15. 36 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/CsReadRecord.java
  16. 38 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/CsSeatConfig.java
  17. 31 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/CsSeatWaiter.java
  18. 86 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/CsSession.java
  19. 71 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/CsTicket.java
  20. 69 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/bo/CsMessageBo.java
  21. 48 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/bo/CsOrderCardBo.java
  22. 52 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/bo/CsSeatConfigBo.java
  23. 36 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/bo/CsSessionBo.java
  24. 36 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/bo/CsTicketBo.java
  25. 111 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/vo/CsMessageVo.java
  26. 68 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/vo/CsOrderCardVo.java
  27. 70 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/vo/CsSeatConfigVo.java
  28. 107 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/vo/CsSessionVo.java
  29. 60 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/vo/CsTicketVo.java
  30. 9 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/mapper/CsMessageMapper.java
  31. 9 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/mapper/CsOrderCardMapper.java
  32. 8 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/mapper/CsReadRecordMapper.java
  33. 9 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/mapper/CsSeatConfigMapper.java
  34. 8 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/mapper/CsSeatWaiterMapper.java
  35. 10 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/mapper/CsSessionMapper.java
  36. 9 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/mapper/CsTicketMapper.java
  37. 45 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/ICsMessageService.java
  38. 32 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/ICsOrderCardService.java
  39. 60 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/ICsSeatConfigService.java
  40. 48 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/ICsSessionService.java
  41. 19 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/ICsTicketService.java
  42. 199 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/impl/CsMessageServiceImpl.java
  43. 133 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/impl/CsOrderCardServiceImpl.java
  44. 203 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/impl/CsSeatConfigServiceImpl.java
  45. 127 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/impl/CsSessionServiceImpl.java
  46. 66 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/impl/CsTicketServiceImpl.java
  47. 28 0
      ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/task/OrderCardExpireTask.java
  48. 1 1
      ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysUserBo.java
  49. 1 0
      ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysPostServiceImpl.java
  50. 1 1
      ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysUserServiceImpl.java
  51. 193 4
      script/sql/main.sql

+ 8 - 5
ruoyi-admin/src/main/resources/application.yml

@@ -91,6 +91,8 @@ spring:
     deserialization:
       # 允许对象忽略json中不存在的属性
       fail_on_unknown_properties: false
+  scheduling:
+    enable: true
 
 # Sa-Token配置
 sa-token:
@@ -247,11 +249,12 @@ sse:
   path: /resource/sse
 
 --- # websocket
-websocket:
-  # 如果关闭 需要和前端开关一起关闭
-  enabled: false
-  # 路径
-  path: /resource/websocket
+#websocket:
+#  # 如果关闭 需要和前端开关一起关闭
+#  enabled: false
+#  # 路径
+#  path: /resource/websocket
+
   # 设置访问源地址
   allowedOrigins: '*'
 

+ 22 - 1
ruoyi-modules/ruoyi-demo/src/main/java/org/dromara/demo/service/impl/KaoshixingService.java

@@ -41,6 +41,8 @@ public class KaoshixingService {
     private static final String ACTION_ID_USER_EXAMS = "702";
     // 静默登录 action_id
     private static final String ACTION_ID_SILENT_LOGIN = "203";
+    // 在线学习内容列表 action_id
+    private static final String ACTION_ID_LEARNING_CONTENTS = "605";
     // 考生登录(可注册) action_id
     private static final String ACTION_ID_LOGIN = "201";
     // 查询考生信息 action_id
@@ -55,7 +57,7 @@ public class KaoshixingService {
     private final ObjectMapper objectMapper = new ObjectMapper();
     /**
      * 调用试卷试题列表接口
-     * @param examInfoId 考试ID(示例:119551)
+     * examInfoId 考试ID(示例:119551)
      * @return 试题列表JSON字符串
      */
     public String fetchExamPaperQuestions(KaoshixingRequest request) {
@@ -169,6 +171,25 @@ public class KaoshixingService {
         return sendPostRequest(requestUrl, requestBody);
     }
 
+    /**
+     * 在线学习内容列表(action_id=605)
+     */
+    public String fetchLearningContents(KaoshixingExamListRequest req) {
+        validateExamListParams(req);
+
+        String jwtInfo = generateJwt(FIXED_APP_KEY, ACTION_ID_LEARNING_CONTENTS);
+        log.info("在线学习内容列表接口JWT:{}", jwtInfo);
+
+        String requestUrl = String.format("%s%s/?jwt=%s", BASE_API_URL, FIXED_APP_ID, jwtInfo);
+        log.info("在线学习内容列表接口请求地址:{}", requestUrl);
+
+        // 分页获取学习内容
+        String requestBody = String.format("{\"page\":%d}", req.getPage());
+        log.info("在线学习内容列表接口请求体:{}", requestBody);
+
+        return sendPostRequest(requestUrl, requestBody);
+    }
+
 
     /**
      * 生成JWT(完全按文档示例代码实现)

+ 4 - 0
ruoyi-modules/ruoyi-main/pom.xml

@@ -67,6 +67,10 @@
             <version>2.62.0</version>
             <scope>compile</scope>
         </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-websocket</artifactId>
+        </dependency>
 
     </dependencies>
 

+ 34 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/config/ChatWebSocketConfig.java

@@ -0,0 +1,34 @@
+package org.dromara.main.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.messaging.simp.config.MessageBrokerRegistry;
+import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
+import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
+import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
+
+@Configuration
+@EnableWebSocketMessageBroker
+public class ChatWebSocketConfig implements WebSocketMessageBrokerConfigurer {
+
+    @Override
+    public void configureMessageBroker(MessageBrokerRegistry config) {
+        // 启用简单的消息代理,用于向客户端发送消息
+        // /topic 用于广播消息(会话消息)
+        // /queue 用于点对点消息(用户通知)
+        config.enableSimpleBroker("/topic", "/queue");
+
+        // 客户端发送消息的前缀
+        config.setApplicationDestinationPrefixes("/app");
+
+        // 用户消息前缀
+        config.setUserDestinationPrefix("/user");
+    }
+
+    @Override
+    public void registerStompEndpoints(StompEndpointRegistry registry) {
+        // 注册STOMP端点,使用SockJS作为备用方案
+        registry.addEndpoint("/api/chat/ws/chat")
+            .setAllowedOriginPatterns("*")
+            .withSockJS();
+    }
+}

+ 65 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/controller/ChatWebSocketController.java

@@ -0,0 +1,65 @@
+package org.dromara.main.controller;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.main.domain.bo.CsMessageBo;
+import org.dromara.main.domain.vo.CsMessageVo;
+import org.dromara.main.service.ICsMessageService;
+import org.springframework.messaging.handler.annotation.MessageMapping;
+import org.springframework.messaging.handler.annotation.Payload;
+import org.springframework.messaging.simp.SimpMessagingTemplate;
+import org.springframework.stereotype.Controller;
+
+@Slf4j
+@RequiredArgsConstructor
+@Controller
+public class ChatWebSocketController {
+
+    private final ICsMessageService messageService;
+    private final SimpMessagingTemplate messagingTemplate;
+
+    /**
+     * 处理客户端发送的文本消息
+     * 前端发送到:/app/chat/send
+     *
+     * @param message 消息对象
+     */
+    @MessageMapping("/chat/send")
+    public void handleChatMessage(@Payload CsMessageBo message) {
+        try {
+            log.info("收到WebSocket消息: sessionId={}, content={}",
+                message.getSessionId(), message.getContent());
+
+            // 保存消息到数据库
+            CsMessageVo savedMessage = messageService.sendTextMessage(message);
+
+            // 推送消息到会话频道(所有订阅该会话的用户都能收到)
+            String destination = "/topic/session/" + savedMessage.getSessionId();
+            messagingTemplate.convertAndSend(destination, savedMessage);
+
+            log.info("消息已发送到会话: sessionId={}, msgId={}",
+                savedMessage.getSessionId(), savedMessage.getId());
+        } catch (Exception e) {
+            log.error("处理聊天消息失败", e);
+        }
+    }
+
+    /**
+     * 发送系统通知给指定用户
+     *
+     * @param userId 用户ID
+     * @param notification 通知内容
+     */
+    public void sendNotifyToUser(Long userId, Object notification) {
+        try {
+            messagingTemplate.convertAndSendToUser(
+                userId.toString(),
+                "/queue/notify",
+                notification
+            );
+            log.info("通知已发送给用户: userId={}", userId);
+        } catch (Exception e) {
+            log.error("发送用户通知失败: userId={}", userId, e);
+        }
+    }
+}

+ 106 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/controller/CsMessageController.java

@@ -0,0 +1,106 @@
+package org.dromara.main.controller;
+
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.domain.R;
+import org.dromara.common.log.annotation.Log;
+import org.dromara.common.log.enums.BusinessType;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.common.satoken.utils.LoginHelper;
+import org.dromara.common.web.core.BaseController;
+import org.dromara.main.domain.bo.CsMessageBo;
+import org.dromara.main.domain.vo.CsMessageVo;
+import org.dromara.main.service.ICsMessageService;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.Map;
+
+@Validated
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/main/message")
+public class CsMessageController extends BaseController {
+
+    private final ICsMessageService messageService;
+
+    /**
+     * 获取历史消息
+     */
+    @GetMapping("/history")
+    public TableDataInfo<CsMessageVo> history(
+        @RequestParam Long sessionId,
+        @RequestParam(required = false) Long beforeMsgId,
+        PageQuery pageQuery) {
+        return messageService.queryHistoryMessages(sessionId, beforeMsgId, pageQuery);
+    }
+
+    /**
+     * 发送文本消息
+     */
+    @Log(title = "发送文本消息", businessType = BusinessType.INSERT)
+    @PostMapping("/send/text")
+    public R<CsMessageVo> sendText(@Validated @RequestBody CsMessageBo bo) {
+        Long currentUserId = LoginHelper.getUserId();
+        bo.setSenderId(currentUserId);
+        return R.ok(messageService.sendTextMessage(bo));
+    }
+
+    /**
+     * 发送图片消息
+     */
+    @Log(title = "发送图片消息", businessType = BusinessType.INSERT)
+    @PostMapping("/send/image")
+    public R<CsMessageVo> sendImage(
+        @RequestParam Long sessionId,
+        @RequestParam String msgNo,
+//        @RequestParam Long senderId,
+        @RequestParam("file") MultipartFile file) {
+        Long currentUserId = LoginHelper.getUserId();
+        return R.ok(messageService.sendImageMessage(sessionId, msgNo, currentUserId, file));
+    }
+
+    /**
+     * 发送文件消息
+     */
+    @Log(title = "发送文件消息", businessType = BusinessType.INSERT)
+    @PostMapping("/send/file")
+    public R<CsMessageVo> sendFile(
+        @RequestParam Long sessionId,
+        @RequestParam String msgNo,
+        @RequestParam("file") MultipartFile file) {
+        Long currentUserId = LoginHelper.getUserId();
+
+        return R.ok(messageService.sendFileMessage(sessionId, msgNo, currentUserId, file));
+    }
+
+    /**
+     * 发送岗位卡片
+     */
+    @Log(title = "发送岗位卡片", businessType = BusinessType.INSERT)
+    @PostMapping("/send/job-card")
+    public R<CsMessageVo> sendJobCard(@Validated @RequestBody CsMessageBo bo) {
+        return R.ok(messageService.sendJobCard(bo));
+    }
+
+    /**
+     * 标记消息已读
+     */
+    @PutMapping("/read")
+    public R<Void> markAsRead(@RequestBody Map<String, Long> params) {
+        return toAjax(messageService.markAsRead(
+            params.get("sessionId"),
+            params.get("lastReadMsgId")
+        ));
+    }
+
+    /**
+     * 撤回消息
+     */
+    @Log(title = "撤回消息", businessType = BusinessType.UPDATE)
+    @PutMapping("/{msgId}/recall")
+    public R<Void> recallMessage(@PathVariable Long msgId) {
+        return toAjax(messageService.recallMessage(msgId));
+    }
+}

+ 56 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/controller/CsOrderCardController.java

@@ -0,0 +1,56 @@
+package org.dromara.main.controller;
+
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.domain.R;
+import org.dromara.common.log.annotation.Log;
+import org.dromara.common.log.enums.BusinessType;
+import org.dromara.common.web.core.BaseController;
+import org.dromara.main.domain.bo.CsOrderCardBo;
+import org.dromara.main.domain.vo.CsOrderCardVo;
+import org.dromara.main.service.ICsOrderCardService;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+@Validated
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/main/order-card")
+public class CsOrderCardController extends BaseController {
+
+    private final ICsOrderCardService orderCardService;
+
+    /**
+     * 发送结算单
+     */
+    @Log(title = "发送结算单", businessType = BusinessType.INSERT)
+    @PostMapping("/send")
+    public R<CsOrderCardVo> sendOrderCard(@Validated @RequestBody CsOrderCardBo bo) {
+        return R.ok(orderCardService.sendOrderCard(bo));
+    }
+
+    /**
+     * 查询结算单状态
+     */
+    @GetMapping("/{orderCardId}")
+    public R<CsOrderCardVo> getInfo(@PathVariable Long orderCardId) {
+        return R.ok(orderCardService.queryById(orderCardId));
+    }
+
+    /**
+     * 用户支付结算单
+     */
+    @Log(title = "支付结算单", businessType = BusinessType.UPDATE)
+    @PostMapping("/{orderCardId}/pay")
+    public R<Void> payOrderCard(@PathVariable Long orderCardId, @RequestParam Long userId) {
+        return toAjax(orderCardService.payOrderCard(orderCardId, userId));
+    }
+
+    /**
+     * 取消结算单
+     */
+    @Log(title = "取消结算单", businessType = BusinessType.UPDATE)
+    @PutMapping("/{orderCardId}/cancel")
+    public R<Void> cancelOrderCard(@PathVariable Long orderCardId) {
+        return toAjax(orderCardService.cancelOrderCard(orderCardId));
+    }
+}

+ 96 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/controller/CsSeatConfigController.java

@@ -0,0 +1,96 @@
+package org.dromara.main.controller;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.domain.R;
+import org.dromara.common.core.validate.AddGroup;
+import org.dromara.common.core.validate.EditGroup;
+import org.dromara.common.log.annotation.Log;
+import org.dromara.common.log.enums.BusinessType;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.common.oss.core.OssClient;
+import org.dromara.common.oss.entity.UploadResult;
+import org.dromara.common.oss.factory.OssFactory;
+import org.dromara.common.web.core.BaseController;
+import org.dromara.main.domain.bo.CsSeatConfigBo;
+import org.dromara.main.domain.vo.CsSeatConfigVo;
+import org.dromara.main.service.ICsSeatConfigService;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.InputStream;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Validated
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/main/seat")
+public class CsSeatConfigController extends BaseController {
+
+    private final ICsSeatConfigService seatConfigService;
+
+    /**
+     * 查询坐席配置列表
+     */
+    @SaCheckPermission("main:seat:list")
+    @GetMapping("/list")
+    public TableDataInfo<CsSeatConfigVo> list(CsSeatConfigBo bo, PageQuery pageQuery) {
+        return seatConfigService.queryPageList(bo, pageQuery);
+    }
+
+    /**
+     * 获取坐席配置详细信息
+     */
+    @SaCheckPermission("main:seat:query")
+    @GetMapping("/{id}")
+    public R<CsSeatConfigVo> getInfo(@PathVariable Long id) {
+        return R.ok(seatConfigService.queryById(id));
+    }
+
+    /**
+     * 新增坐席配置
+     */
+    @SaCheckPermission("main:seat:add")
+    @Log(title = "坐席配置", businessType = BusinessType.INSERT)
+    @PostMapping()
+    public R<Void> add(@Validated(AddGroup.class) @RequestBody CsSeatConfigBo bo) {
+        return toAjax(seatConfigService.insertByBo(bo));
+    }
+
+    /**
+     * 修改坐席配置
+     */
+    @SaCheckPermission("main:seat:edit")
+    @Log(title = "坐席配置", businessType = BusinessType.UPDATE)
+    @PutMapping("/{id}")
+    public R<Void> edit(@PathVariable Long id,
+                        @Validated(EditGroup.class) @RequestBody CsSeatConfigBo bo) {
+        bo.setId(id);
+        return toAjax(seatConfigService.updateByBo(bo));
+    }
+
+    /**
+     * 删除坐席配置
+     */
+    @SaCheckPermission("main:seat:remove")
+    @Log(title = "坐席配置", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public R<Void> remove(@PathVariable List<Long> ids) {
+        return toAjax(seatConfigService.deleteByIds(ids));
+    }
+
+    /**
+     * 修改坐席状态
+     */
+    @SaCheckPermission("main:seat:edit")
+    @Log(title = "修改坐席状态", businessType = BusinessType.UPDATE)
+    @PutMapping("/{id}/status")
+    public R<Void> changeStatus(@PathVariable Long id, @RequestBody Map<String, Integer> params) {
+        return toAjax(seatConfigService.changeStatus(id, params.get("status")));
+    }
+
+}

+ 66 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/controller/CsSessionController.java

@@ -0,0 +1,66 @@
+package org.dromara.main.controller;
+
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.domain.R;
+import org.dromara.common.log.annotation.Log;
+import org.dromara.common.log.enums.BusinessType;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.common.web.core.BaseController;
+import org.dromara.main.domain.bo.CsSessionBo;
+import org.dromara.main.domain.vo.CsSessionVo;
+import org.dromara.main.service.ICsSessionService;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+@Validated
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/main/session")
+public class CsSessionController extends BaseController {
+
+    private final ICsSessionService sessionService;
+
+    /**
+     * 创建或获取会话
+     */
+    @PostMapping("/create")
+    public R<CsSessionVo> createOrGetSession(@RequestBody Map<String, Object> params) {
+        Integer sessionType = Integer.valueOf(params.get("sessionType").toString());
+        Long fromUserId = Long.valueOf(params.get("fromUserId").toString());
+        String fromUserName = (String) params.get("fromUserName");
+        String fromUserAvatar = (String) params.get("fromUserAvatar");
+
+        CsSessionVo session = sessionService.createOrGetSession(
+            sessionType, fromUserId, fromUserName, fromUserAvatar
+        );
+        return R.ok(session);
+    }
+
+    /**
+     * 获取会话列表
+     */
+    @GetMapping("/list")
+    public TableDataInfo<CsSessionVo> list(CsSessionBo bo, PageQuery pageQuery) {
+        return sessionService.queryPageList(bo, pageQuery);
+    }
+
+    /**
+     * 获取会话详情
+     */
+    @GetMapping("/{sessionId}")
+    public R<CsSessionVo> getInfo(@PathVariable Long sessionId) {
+        return R.ok(sessionService.queryById(sessionId));
+    }
+
+    /**
+     * 结束会话
+     */
+    @Log(title = "结束会话", businessType = BusinessType.UPDATE)
+    @PutMapping("/{sessionId}/end")
+    public R<Void> endSession(@PathVariable Long sessionId) {
+        return toAjax(sessionService.endSession(sessionId));
+    }
+}

+ 42 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/controller/CsTicketController.java

@@ -0,0 +1,42 @@
+package org.dromara.main.controller;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.domain.R;
+import org.dromara.common.log.annotation.Log;
+import org.dromara.common.log.enums.BusinessType;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.common.web.core.BaseController;
+import org.dromara.main.domain.bo.CsTicketBo;
+import org.dromara.main.domain.vo.CsTicketVo;
+import org.dromara.main.service.ICsTicketService;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+@Validated
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/main/ticket")
+public class CsTicketController extends BaseController {
+    private final ICsTicketService ticketService;
+    /** 查询客服工单列表 */
+    @SaCheckPermission("main:ticket:list")
+    @GetMapping("/list")
+    public TableDataInfo<CsTicketVo> list(CsTicketBo bo, PageQuery pageQuery) {
+        return ticketService.queryPageList(bo, pageQuery);
+    }
+    /** 获取客服工单详细信息 */
+    @SaCheckPermission("main:ticket:query")
+    @GetMapping("/{id}")
+    public R<CsTicketVo> getInfo(@PathVariable String id) {
+        return R.ok(ticketService.queryById(id));
+    }
+    /** 处理工单 */
+    @SaCheckPermission("main:ticket:edit")
+    @Log(title = "处理工单", businessType = BusinessType.UPDATE)
+    @PutMapping("/process")
+    public R<Void> process(@RequestBody CsTicketBo bo) {
+        return toAjax(ticketService.handleTicket(bo));
+    }
+}

+ 35 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/controller/MainExamEvaluationController.java

@@ -12,7 +12,9 @@ import org.dromara.common.log.enums.BusinessType;
 import org.dromara.common.mybatis.core.page.PageQuery;
 import org.dromara.common.mybatis.core.page.TableDataInfo;
 import org.dromara.common.web.core.BaseController;
+import org.dromara.demo.domain.dto.KaoshixingAnswerListRequest;
 import org.dromara.demo.domain.dto.KaoshixingExamListRequest;
+import org.dromara.demo.domain.dto.KaoshixingRequest;
 import org.dromara.demo.service.impl.KaoshixingService;
 import org.dromara.main.domain.bo.MainExamEvaluationBo;
 import org.dromara.main.domain.vo.MainExamEvaluationVo;
@@ -136,4 +138,37 @@ public class MainExamEvaluationController extends BaseController {
         String result = kaoshixingService.fetchExamList(request);
         return R.ok(result);
     }
+
+    /**
+     * 获取试卷试题列表 (603)
+     */
+    @SaCheckPermission("main:evaluation:list")
+    @PostMapping("/paper-questions")
+    public R<String> getPaperQuestions(@RequestBody Map<String, Object> params) {
+        KaoshixingRequest request = new KaoshixingRequest();
+        request.setExamInfoId(Long.valueOf(params.get("examInfoId").toString()));
+        return R.ok(kaoshixingService.fetchExamPaperQuestions(request));
+    }
+
+    /**
+     * 获取考生答案列表 (604)
+     */
+    @SaCheckPermission("main:evaluation:list")
+    @PostMapping("/answer-list")
+    public R<String> getAnswerList(@RequestBody Map<String, Object> params) {
+        KaoshixingAnswerListRequest request = new KaoshixingAnswerListRequest();
+        request.setExamInfoId(Long.valueOf(params.get("examInfoId").toString()));
+        return R.ok(kaoshixingService.fetchAnswerList(request));
+    }
+
+    /**
+     * 获取在线学习内容列表 (605)
+     */
+    @SaCheckPermission("main:evaluation:list")
+    @PostMapping("/learning-contents")
+    public R<String> getLearningContents(@RequestBody Map<String, Object> params) {
+        KaoshixingExamListRequest request = new KaoshixingExamListRequest();
+        request.setPage((Integer) params.getOrDefault("page", 1));
+        return R.ok(kaoshixingService.fetchLearningContents(request));
+    }
 }

+ 4 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/controller/MainTrainingController.java

@@ -0,0 +1,4 @@
+package org.dromara.main.controller;
+
+public class MainTrainingController {
+}

+ 91 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/CsMessage.java

@@ -0,0 +1,91 @@
+package org.dromara.main.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;
+
+@Data
+@TableName("cs_message")
+public class CsMessage {
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    /**
+     * 归属会话ID
+     */
+    private Long sessionId;
+
+    /**
+     * 消息唯一编号
+     */
+    private String msgNo;
+
+    /**
+     * 发送方类型: 1=用户/商家, 2=客服, 3=系统
+     */
+    private Integer senderType;
+
+    /**
+     * 发送者ID
+     */
+    private Long senderId;
+
+    /**
+     * 消息类型: text/image/file/job_card/order_card/emoji
+     */
+    private String msgType;
+
+    /**
+     * 文本消息内容
+     */
+    private String content;
+
+    /**
+     * 图片/文件URL
+     */
+    private String fileUrl;
+
+    /**
+     * 文件原始名称
+     */
+    private String fileName;
+
+    /**
+     * 文件大小(字节)
+     */
+    private Long fileSize;
+
+    /**
+     * 文件MIME类型
+     */
+    private String fileType;
+
+    /**
+     * 卡片类消息的结构化数据(JSON字符串)
+     */
+    private String payload;
+
+    /**
+     * 状态: 1=正常, 2=已撤回
+     */
+    private Integer status;
+
+    /**
+     * 是否已读: 0=未读, 1=已读
+     */
+    private Integer isRead;
+
+    /**
+     * 发送时间
+     */
+    private LocalDateTime sendTime;
+
+    /**
+     * 创建时间
+     */
+    private LocalDateTime createTime;
+}

+ 67 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/CsOrderCard.java

@@ -0,0 +1,67 @@
+package org.dromara.main.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("cs_order_card")
+public class CsOrderCard {
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    /**
+     * 关联消息ID
+     */
+    private Long msgId;
+
+    /**
+     * 关联会话ID
+     */
+    private Long sessionId;
+
+    /**
+     * 项目名称
+     */
+    private String orderName;
+
+    /**
+     * 支付金额
+     */
+    private BigDecimal orderPrice;
+
+    /**
+     * 订单类型说明
+     */
+    private String orderType;
+
+    /**
+     * 关联平台真实订单ID
+     */
+    private Long originalOrderId;
+
+    /**
+     * 状态: pending=待支付, paid=已支付, cancelled=已取消, expired=已失效
+     */
+    private String status;
+
+    /**
+     * 结算单过期时间
+     */
+    private LocalDateTime expireTime;
+
+    /**
+     * 实际支付时间
+     */
+    private LocalDateTime payTime;
+
+    /**
+     * 创建时间
+     */
+    private LocalDateTime createTime;
+}

+ 36 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/CsReadRecord.java

@@ -0,0 +1,36 @@
+package org.dromara.main.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;
+
+@Data
+@TableName("cs_read_record")
+public class CsReadRecord {
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    /**
+     * 会话ID
+     */
+    private Long sessionId;
+
+    /**
+     * 用户ID
+     */
+    private Long userId;
+
+    /**
+     * 已读到的消息ID
+     */
+    private Long lastReadMsgId;
+
+    /**
+     * 读取时间
+     */
+    private LocalDateTime readTime;
+}

+ 38 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/CsSeatConfig.java

@@ -0,0 +1,38 @@
+package org.dromara.main.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.dromara.common.mybatis.core.domain.BaseEntity;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("cs_seat_config")
+public class CsSeatConfig extends BaseEntity {
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    /**
+     * 坐席名称
+     */
+    private String seatName;
+
+    /**
+     * 坐席头像URL
+     */
+    private String avatar;
+
+    /**
+     * 负责模块: mini(小程序), merchant(商家), all(全部)
+     */
+    private String module;
+
+    /**
+     * 状态: 0=停用, 1=启用
+     */
+    private Integer status;
+
+}

+ 31 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/CsSeatWaiter.java

@@ -0,0 +1,31 @@
+package org.dromara.main.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;
+
+@Data
+@TableName("cs_seat_waiter")
+public class CsSeatWaiter {
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    /**
+     * 坐席ID
+     */
+    private Long seatId;
+
+    /**
+     * 客服用户ID(关联sys_user)
+     */
+    private Long userId;
+
+    /**
+     * 创建时间
+     */
+    private LocalDateTime createTime;
+}

+ 86 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/CsSession.java

@@ -0,0 +1,86 @@
+package org.dromara.main.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;
+
+@Data
+@TableName("cs_session")
+public class CsSession {
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    /**
+     * 会话编号
+     */
+    private String sessionNo;
+
+    /**
+     * 会话类型: 1=小程序用户, 2=PC商家
+     */
+    private Integer sessionType;
+
+    /**
+     * 发起用户ID
+     */
+    private Long fromUserId;
+
+    /**
+     * 发起方昵称
+     */
+    private String fromUserName;
+
+    /**
+     * 发起方头像
+     */
+    private String fromUserAvatar;
+
+    /**
+     * 当前分配坐席ID
+     */
+    private Long seatId;
+
+    /**
+     * 当前接待客服ID
+     */
+    private Long waiterId;
+
+    /**
+     * 状态: 1=进行中, 2=已结束
+     */
+    private Integer status;
+
+    /**
+     * 最后一条消息摘要
+     */
+    private String lastMsg;
+
+    /**
+     * 最后消息时间
+     */
+    private LocalDateTime lastMsgTime;
+
+    /**
+     * 客服侧未读消息数
+     */
+    private Integer unreadCount;
+
+    /**
+     * 会话建立时间
+     */
+    private LocalDateTime createTime;
+
+    /**
+     * 更新时间
+     */
+    private LocalDateTime updateTime;
+
+    /**
+     * 会话结束时间
+     */
+    private LocalDateTime endTime;
+}

+ 71 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/CsTicket.java

@@ -0,0 +1,71 @@
+package org.dromara.main.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;
+
+@Data
+@TableName("cs_ticket")
+public class CsTicket {
+
+    @TableId(value = "id", type = IdType.INPUT)
+    private String id;
+
+    /**
+     * 关联会话ID
+     */
+    private Long sessionId;
+
+    /**
+     * 反馈用户ID
+     */
+    private String userId;
+
+    /**
+     * 反馈用户昵称
+     */
+    private String userName;
+
+    /**
+     * 反馈内容
+     */
+    private String content;
+
+    /**
+     * 反馈渠道: 小程序/商家
+     */
+    private String source;
+
+    /**
+     * 问题分类
+     */
+    private String category;
+
+    /**
+     * 状态: pending=待处理, processing=处理中, completed=已完成, abandoned=已废弃
+     */
+    private String status;
+
+    /**
+     * 处理客服ID
+     */
+    private Long handlerId;
+
+    /**
+     * 客服处理回复
+     */
+    private String handlerReply;
+
+    /**
+     * 创建时间
+     */
+    private LocalDateTime createTime;
+
+    /**
+     * 完结时间
+     */
+    private LocalDateTime finishTime;
+}

+ 69 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/bo/CsMessageBo.java

@@ -0,0 +1,69 @@
+package org.dromara.main.domain.bo;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.util.Map;
+
+@Data
+public class CsMessageBo {
+
+    /**
+     * 会话ID
+     */
+    @NotNull(message = "会话ID不能为空")
+    private Long sessionId;
+
+    /**
+     * 消息唯一编号
+     */
+    @NotBlank(message = "消息编号不能为空")
+    private String msgNo;
+
+    /**
+     * 发送者ID
+     */
+    private Long senderId;
+
+    /**
+     * 消息类型
+     */
+    @NotBlank(message = "消息类型不能为空")
+    private String msgType;
+
+    /**
+     * 文本消息内容
+     */
+    private String content;
+
+    /**
+     * 文件URL
+     */
+    private String fileUrl;
+
+    /**
+     * 文件名称
+     */
+    private String fileName;
+
+    /**
+     * 文件大小
+     */
+    private Long fileSize;
+
+    /**
+     * 文件类型
+     */
+    private String fileType;
+
+    /**
+     * 卡片数据(用于job_card和order_card)
+     */
+    private Map<String, Object> payload;
+
+
+    // 在 CsMessageBo 类中确认或补充以下字段
+//    private Long sessionId;
+    private Long beforeMsgId; // 可选,用于增量加载(上一页最后一条消息的ID)
+}

+ 48 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/bo/CsOrderCardBo.java

@@ -0,0 +1,48 @@
+package org.dromara.main.domain.bo;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 结算单业务对象
+ */
+@Data
+public class CsOrderCardBo {
+
+    /**
+     * 会话ID
+     */
+    @NotNull(message = "会话ID不能为空")
+    private Long sessionId;
+
+    /**
+     * 消息编号
+     */
+    @NotBlank(message = "消息编号不能为空")
+    private String msgNo;
+
+    /**
+     * 发送者ID
+     */
+    private Long senderId;
+
+    /**
+     * 项目名称
+     */
+    @NotBlank(message = "项目名称不能为空")
+    private String orderName;
+
+    /**
+     * 支付金额
+     */
+    @NotNull(message = "支付金额不能为空")
+    private BigDecimal orderPrice;
+
+    /**
+     * 订单类型说明
+     */
+    private String orderType;
+}

+ 52 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/bo/CsSeatConfigBo.java

@@ -0,0 +1,52 @@
+package org.dromara.main.domain.bo;
+
+import io.github.linpeilie.annotations.AutoMapper;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.dromara.common.core.validate.AddGroup;
+import org.dromara.common.core.validate.EditGroup;
+import org.dromara.common.mybatis.core.domain.BaseEntity;
+import org.dromara.main.domain.CsSeatConfig;
+
+import java.util.List;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@AutoMapper(target = CsSeatConfig.class, reverseConvertGenerate = false)
+public class CsSeatConfigBo extends BaseEntity {
+
+    /**
+     * 坐席ID
+     */
+    @NotNull(message = "坐席ID不能为空", groups = {EditGroup.class})
+    private Long id;
+
+    /**
+     * 坐席名称
+     */
+    @NotBlank(message = "坐席名称不能为空", groups = {AddGroup.class, EditGroup.class})
+    private String seatName;
+
+    /**
+     * 坐席头像URL
+     */
+    private String avatar;
+
+    /**
+     * 负责模块
+     */
+    @NotBlank(message = "负责模块不能为空", groups = {AddGroup.class, EditGroup.class})
+    private String module;
+
+    /**
+     * 状态
+     */
+    private Integer status;
+
+    /**
+     * 关联的客服人员ID列表
+     */
+    private List<Long> waiterIds;
+}

+ 36 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/bo/CsSessionBo.java

@@ -0,0 +1,36 @@
+package org.dromara.main.domain.bo;
+
+import lombok.Data;
+import org.dromara.common.mybatis.core.domain.BaseEntity;
+
+@Data
+public class CsSessionBo extends BaseEntity {
+
+    /**
+     * 会话ID
+     */
+    private Long id;
+
+    /**
+     * 会话类型: 1=小程序用户, 2=PC商家
+     */
+    private Integer sessionType;
+
+    /**
+     * 状态: 1=进行中, 2=已结束
+     */
+    private Integer status;
+
+    /**
+     * 搜索关键词(用户昵称)
+     */
+    private String keyword;
+
+    /**
+     * 当前接待客服ID
+     */
+    private Long waiterId;
+
+
+
+}

+ 36 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/bo/CsTicketBo.java

@@ -0,0 +1,36 @@
+package org.dromara.main.domain.bo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.dromara.common.mybatis.core.domain.BaseEntity;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Schema(description = "客服工单业务对象")
+public class CsTicketBo extends BaseEntity {
+    /** 反馈ID */
+    @Schema(description = "反馈ID")
+    private String id;
+    /** 关联会话ID */
+    @Schema(description = "关联会话ID")
+    private Long sessionId;
+    /** 反馈用户ID */
+    @Schema(description = "反馈用户ID")
+    private String userId;
+    /** 反馈渠道 */
+    @Schema(description = "反馈渠道")
+    private String source;
+    /** 问题分类 */
+    @Schema(description = "问题分类")
+    private String category;
+    /** 状态: pending=待处理, processing=处理中, completed=已完成, abandoned=已废弃 */
+    @Schema(description = "状态")
+    private String status;
+    /** 客服处理回复 */
+    @Schema(description = "客服处理回复")
+    private String handlerReply;
+    /** 处理客服ID */
+    @Schema(description = "处理客服ID")
+    private Long handlerId;
+}

+ 111 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/vo/CsMessageVo.java

@@ -0,0 +1,111 @@
+package org.dromara.main.domain.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import org.dromara.main.domain.CsMessage;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+@Data
+@AutoMapper(target = CsMessage.class)
+public class CsMessageVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 消息ID
+     */
+    @JsonProperty("msgId")
+    private Long id;
+
+    /**
+     * 会话ID
+     */
+    private Long sessionId;
+
+    /**
+     * 消息编号
+     */
+    private String msgNo;
+
+    /**
+     * 发送方类型
+     */
+    private Integer senderType;
+
+    /**
+     * 发送者ID
+     */
+    private Long senderId;
+
+    /**
+     * 发送者名称
+     */
+    private String senderName;
+
+    /**
+     * 发送者头像
+     */
+    private String senderAvatar;
+
+    /**
+     * 发送者角色(user/waiter/system)
+     */
+    private String senderRole;
+
+    /**
+     * 消息类型
+     */
+    private String msgType;
+
+    /**
+     * 文本内容
+     */
+    private String content;
+
+    /**
+     * 文件URL
+     */
+    private String fileUrl;
+
+    /**
+     * 文件名称
+     */
+    private String fileName;
+
+    /**
+     * 文件大小
+     */
+    private Long fileSize;
+
+    /**
+     * 文件类型
+     */
+    private String fileType;
+
+    /**
+     * 卡片数据(JSON字符串)
+     */
+    private String payload;
+
+    /**
+     * 状态
+     */
+    private Integer status;
+
+    /**
+     * 是否已读
+     */
+    private Integer isRead;
+
+    /**
+     * 发送时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime sendTime;
+}

+ 68 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/vo/CsOrderCardVo.java

@@ -0,0 +1,68 @@
+package org.dromara.main.domain.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+@Data
+public class CsOrderCardVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 结算单ID
+     */
+    private Long orderCardId;
+
+    /**
+     * 消息ID
+     */
+    private Long msgId;
+
+    /**
+     * 会话ID
+     */
+    private Long sessionId;
+
+    /**
+     * 项目名称
+     */
+    private String orderName;
+
+    /**
+     * 支付金额
+     */
+    private BigDecimal orderPrice;
+
+    /**
+     * 订单类型
+     */
+    private String orderType;
+
+    /**
+     * 状态
+     */
+    private String status;
+
+    /**
+     * 过期时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime expireTime;
+
+    /**
+     * 倒计时秒数
+     */
+    private Long countdownSeconds;
+
+    /**
+     * 支付时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime payTime;
+}

+ 70 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/vo/CsSeatConfigVo.java

@@ -0,0 +1,70 @@
+package org.dromara.main.domain.vo;
+
+import cn.idev.excel.annotation.ExcelProperty;
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import org.dromara.main.domain.CsSeatConfig;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 坐席配置视图对象
+ */
+@Data
+@AutoMapper(target = CsSeatConfig.class)
+public class CsSeatConfigVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 坐席ID
+     */
+    @ExcelProperty(value = "坐席ID")
+    private Long id;
+
+    /**
+     * 坐席名称
+     */
+    @ExcelProperty(value = "坐席名称")
+    private String seatName;
+
+    /**
+     * 坐席头像URL
+     */
+    @ExcelProperty(value = "坐席头像")
+    private String avatar;
+
+    /**
+     * 负责模块
+     */
+    @ExcelProperty(value = "负责模块")
+    private String module;
+
+    /**
+     * 状态
+     */
+    @ExcelProperty(value = "状态")
+    private Integer status;
+
+    /**
+     * 创建时间
+     */
+    @ExcelProperty(value = "创建时间")
+    private LocalDateTime createTime;
+
+    /**
+     * 关联的客服人员列表
+     */
+    private List<WaiterInfo> waiters;
+
+    @Data
+    public static class WaiterInfo {
+        private Long userId;
+        private String userName;
+        private String nickName;
+    }
+}

+ 107 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/vo/CsSessionVo.java

@@ -0,0 +1,107 @@
+package org.dromara.main.domain.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import org.dromara.main.domain.CsSession;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+@Data
+@AutoMapper(target = CsSession.class)
+public class CsSessionVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 会话ID
+     */
+    @JsonProperty("sessionId")
+    private Long id;
+
+    /**
+     * 会话编号
+     */
+    private String sessionNo;
+
+    /**
+     * 会话类型
+     */
+    private Integer sessionType;
+
+    /**
+     * 发起用户ID
+     */
+    private Long fromUserId;
+
+    /**
+     * 发起方昵称
+     */
+    private String fromUserName;
+
+    /**
+     * 发起方头像
+     */
+    private String fromUserAvatar;
+
+    /**
+     * 坐席ID
+     */
+    private Long seatId;
+
+    /**
+     * 坐席名称
+     */
+    private String seatName;
+
+    /**
+     * 接待客服ID
+     */
+    private Long waiterId;
+
+    /**
+     * 接待客服名称
+     */
+    private String waiterName;
+
+    /**
+     * 接待客服头像
+     */
+    private String waiterAvatar;
+
+    /**
+     * 客服在线状态
+     */
+    private Boolean isOnline;
+
+    /**
+     * 状态
+     */
+    private Integer status;
+
+    /**
+     * 最后一条消息
+     */
+    private String lastMsg;
+
+    /**
+     * 最后消息时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime lastMsgTime;
+
+    /**
+     * 未读消息数
+     */
+    private Integer unreadCount;
+
+    /**
+     * 创建时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime createTime;
+}

+ 60 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/vo/CsTicketVo.java

@@ -0,0 +1,60 @@
+package org.dromara.main.domain.vo;
+
+import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
+import cn.idev.excel.annotation.ExcelProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+import java.util.List;
+
+import io.github.linpeilie.annotations.AutoMapper;
+import org.dromara.main.domain.CsTicket;
+
+@Data
+@ExcelIgnoreUnannotated
+@Schema(description = "客服工单视图对象")
+@AutoMapper(target = CsTicket.class)
+public class CsTicketVo implements Serializable {
+    @Serial
+    private static final long serialVersionUID = 1L;
+    @ExcelProperty(value = "反馈ID")
+    @Schema(description = "反馈ID")
+    private String id;
+    @Schema(description = "关联会话ID")
+    private Long sessionId;
+    @ExcelProperty(value = "用户ID")
+    @Schema(description = "用户ID")
+    private String userId;
+    @ExcelProperty(value = "用户昵称")
+    @Schema(description = "用户昵称")
+    private String userName;
+    @ExcelProperty(value = "反馈内容")
+    @Schema(description = "反馈内容")
+    private String content;
+    @ExcelProperty(value = "反馈渠道")
+    @Schema(description = "反馈渠道")
+    private String source;
+    @ExcelProperty(value = "问题分类")
+    @Schema(description = "问题分类")
+    private String category;
+    @ExcelProperty(value = "状态")
+    @Schema(description = "状态")
+    private String status;
+    @Schema(description = "处理客服ID")
+    private Long handlerId;
+    @ExcelProperty(value = "客服回复")
+    @Schema(description = "客服回复")
+    private String handlerReply;
+    @ExcelProperty(value = "创建时间")
+    @Schema(description = "创建时间")
+    private LocalDateTime createTime;
+    @ExcelProperty(value = "完结时间")
+    @Schema(description = "完结时间")
+    private LocalDateTime finishTime;
+    /** 沟通历史追踪 */
+    @Schema(description = "沟通历史追踪")
+    private List<CsMessageVo> messages;
+}

+ 9 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/mapper/CsMessageMapper.java

@@ -0,0 +1,9 @@
+package org.dromara.main.mapper;
+
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.main.domain.CsMessage;
+import org.dromara.main.domain.vo.CsMessageVo;
+
+public interface CsMessageMapper extends BaseMapperPlus<CsMessage, CsMessageVo> {
+
+}

+ 9 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/mapper/CsOrderCardMapper.java

@@ -0,0 +1,9 @@
+package org.dromara.main.mapper;
+
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.main.domain.CsOrderCard;
+import org.dromara.main.domain.vo.CsOrderCardVo;
+
+public interface CsOrderCardMapper extends BaseMapperPlus<CsOrderCard, CsOrderCardVo> {
+
+}

+ 8 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/mapper/CsReadRecordMapper.java

@@ -0,0 +1,8 @@
+package org.dromara.main.mapper;
+
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.main.domain.CsReadRecord;
+
+public interface CsReadRecordMapper extends BaseMapperPlus<CsReadRecord, CsReadRecord> {
+
+}

+ 9 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/mapper/CsSeatConfigMapper.java

@@ -0,0 +1,9 @@
+package org.dromara.main.mapper;
+
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.main.domain.CsSeatConfig;
+import org.dromara.main.domain.vo.CsSeatConfigVo;
+
+public interface CsSeatConfigMapper extends BaseMapperPlus<CsSeatConfig, CsSeatConfigVo> {
+
+}

+ 8 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/mapper/CsSeatWaiterMapper.java

@@ -0,0 +1,8 @@
+package org.dromara.main.mapper;
+
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.main.domain.CsSeatWaiter;
+
+public interface CsSeatWaiterMapper extends BaseMapperPlus<CsSeatWaiter, CsSeatWaiter> {
+
+}

+ 10 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/mapper/CsSessionMapper.java

@@ -0,0 +1,10 @@
+package org.dromara.main.mapper;
+
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.main.domain.CsMessage;
+import org.dromara.main.domain.CsSession;
+import org.dromara.main.domain.vo.CsSessionVo;
+
+public interface CsSessionMapper extends BaseMapperPlus<CsSession, CsSessionVo> {
+
+}

+ 9 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/mapper/CsTicketMapper.java

@@ -0,0 +1,9 @@
+package org.dromara.main.mapper;
+
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.main.domain.CsTicket;
+import org.dromara.main.domain.vo.CsTicketVo;
+
+public interface CsTicketMapper extends BaseMapperPlus<CsTicket, CsTicketVo> {
+
+}

+ 45 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/ICsMessageService.java

@@ -0,0 +1,45 @@
+package org.dromara.main.service;
+
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.main.domain.bo.CsMessageBo;
+import org.dromara.main.domain.vo.CsMessageVo;
+import org.springframework.web.multipart.MultipartFile;
+
+public interface ICsMessageService {
+
+    /**
+     * 查询历史消息
+     */
+    TableDataInfo<CsMessageVo> queryHistoryMessages(Long sessionId, Long beforeMsgId, PageQuery pageQuery);
+
+    /**
+     * 发送文本消息
+     */
+    CsMessageVo sendTextMessage(CsMessageBo bo);
+
+    /**
+     * 发送图片消息
+     */
+    CsMessageVo sendImageMessage(Long sessionId, String msgNo, Long senderId, MultipartFile file);
+
+    /**
+     * 发送文件消息
+     */
+    CsMessageVo sendFileMessage(Long sessionId, String msgNo, Long senderId, MultipartFile file);
+
+    /**
+     * 发送岗位卡片
+     */
+    CsMessageVo sendJobCard(CsMessageBo bo);
+
+    /**
+     * 标记消息已读
+     */
+    Boolean markAsRead(Long sessionId, Long lastReadMsgId);
+
+    /**
+     * 撤回消息
+     */
+    Boolean recallMessage(Long msgId);
+}

+ 32 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/ICsOrderCardService.java

@@ -0,0 +1,32 @@
+package org.dromara.main.service;
+
+import org.dromara.main.domain.bo.CsOrderCardBo;
+import org.dromara.main.domain.vo.CsOrderCardVo;
+
+public interface ICsOrderCardService {
+
+    /**
+     * 发送结算单
+     */
+    CsOrderCardVo sendOrderCard(CsOrderCardBo bo);
+
+    /**
+     * 查询结算单状态
+     */
+    CsOrderCardVo queryById(Long orderCardId);
+
+    /**
+     * 用户支付结算单
+     */
+    Boolean payOrderCard(Long orderCardId, Long userId);
+
+    /**
+     * 取消结算单
+     */
+    Boolean cancelOrderCard(Long orderCardId);
+
+    /**
+     * 检查并更新过期结算单
+     */
+    void checkExpiredOrderCards();
+}

+ 60 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/ICsSeatConfigService.java

@@ -0,0 +1,60 @@
+package org.dromara.main.service;
+
+
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.main.domain.bo.CsSeatConfigBo;
+import org.dromara.main.domain.vo.CsSeatConfigVo;
+
+import java.util.List;
+
+/**
+ * 坐席配置Service接口
+ */
+public interface ICsSeatConfigService {
+
+    /**
+     * 查询坐席配置列表
+     */
+    TableDataInfo<CsSeatConfigVo> queryPageList(CsSeatConfigBo bo, PageQuery pageQuery);
+
+    /**
+     * 查询所有坐席配置
+     */
+    List<CsSeatConfigVo> queryList(CsSeatConfigBo bo);
+
+    /**
+     * 查询坐席配置详情
+     */
+    CsSeatConfigVo queryById(Long id);
+
+    /**
+     * 新增坐席配置
+     */
+    Boolean insertByBo(CsSeatConfigBo bo);
+
+    /**
+     * 修改坐席配置
+     */
+    Boolean updateByBo(CsSeatConfigBo bo);
+
+    /**
+     * 删除坐席配置
+     */
+    Boolean deleteById(Long id);
+
+    /**
+     * 批量删除坐席配置
+     */
+    Boolean deleteByIds(List<Long> ids);
+
+    /**
+     * 切换坐席状态
+     */
+    Boolean changeStatus(Long id, Integer status);
+
+    /**
+     * 为坐席分配客服人员
+     */
+    Boolean assignWaiters(Long seatId, List<Long> userIds);
+}

+ 48 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/ICsSessionService.java

@@ -0,0 +1,48 @@
+package org.dromara.main.service;
+
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.main.domain.bo.CsMessageBo;
+import org.dromara.main.domain.bo.CsSessionBo;
+import org.dromara.main.domain.vo.CsMessageVo;
+import org.dromara.main.domain.vo.CsSessionVo;
+
+public interface ICsSessionService {
+
+    /**
+     * 创建或获取会话
+     */
+    CsSessionVo createOrGetSession(Integer sessionType, Long fromUserId,
+                                   String fromUserName, String fromUserAvatar);
+
+    /**
+     * 查询会话列表
+     */
+    TableDataInfo<CsSessionVo> queryPageList(CsSessionBo bo, PageQuery pageQuery);
+
+    /**
+     * 查询会话详情
+     */
+    CsSessionVo queryById(Long sessionId);
+
+    /**
+     * 结束会话
+     */
+    Boolean endSession(Long sessionId);
+
+    /**
+     * 更新会话最后消息
+     */
+    Boolean updateLastMessage(Long sessionId, String lastMsg);
+
+    /**
+     * 增加未读数
+     */
+    Boolean incrementUnreadCount(Long sessionId);
+
+    /**
+     * 清空未读数
+     */
+    Boolean clearUnreadCount(Long sessionId);
+
+}

+ 19 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/ICsTicketService.java

@@ -0,0 +1,19 @@
+package org.dromara.main.service;
+
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.main.domain.bo.CsTicketBo;
+import org.dromara.main.domain.vo.CsTicketVo;
+
+import java.util.Collection;
+
+public interface ICsTicketService {
+    /** 查询详情(含沟通历史) */
+    CsTicketVo queryById(String id);
+    /** 分页查询列表 */
+    TableDataInfo<CsTicketVo> queryPageList(CsTicketBo bo, PageQuery pageQuery);
+    /** 处理工单 */
+    Boolean handleTicket(CsTicketBo bo);
+    /** 批量删除 */
+    Boolean deleteByIds(Collection<String> ids);
+}

+ 199 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/impl/CsMessageServiceImpl.java

@@ -0,0 +1,199 @@
+package org.dromara.main.service.impl;
+
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.json.JSONUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import io.github.linpeilie.Converter;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.common.oss.core.OssClient;
+import org.dromara.common.oss.entity.UploadResult;
+import org.dromara.common.oss.factory.OssFactory;
+import org.dromara.main.domain.CsMessage;
+import org.dromara.main.domain.bo.CsMessageBo;
+import org.dromara.main.domain.vo.CsMessageVo;
+import org.dromara.main.mapper.CsMessageMapper;
+import org.dromara.main.service.ICsMessageService;
+import org.dromara.main.service.ICsSessionService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.InputStream;
+import java.time.LocalDateTime;
+
+@RequiredArgsConstructor
+@Service
+public class CsMessageServiceImpl implements ICsMessageService {
+
+    private final CsMessageMapper baseMapper;
+    private final ICsSessionService sessionService;
+    private final Converter converter;
+
+    @Override
+    public TableDataInfo<CsMessageVo> queryHistoryMessages(Long sessionId, Long beforeMsgId, PageQuery pageQuery) {
+        LambdaQueryWrapper<CsMessage> lqw = Wrappers.lambdaQuery();
+        lqw.eq(CsMessage::getSessionId, sessionId);
+        lqw.eq(CsMessage::getStatus, 1);
+        if (beforeMsgId != null) {
+            lqw.lt(CsMessage::getId, beforeMsgId);
+        }
+        lqw.orderByDesc(CsMessage::getSendTime);
+
+        Page<CsMessage> page = baseMapper.selectPage(pageQuery.build(), lqw);
+        Page<CsMessageVo> voPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
+        voPage.setRecords(converter.convert(page.getRecords(), CsMessageVo.class));
+        return TableDataInfo.build(voPage);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public CsMessageVo sendTextMessage(CsMessageBo bo) {
+        CsMessage message = new CsMessage();
+        message.setSessionId(bo.getSessionId());
+        message.setMsgNo(bo.getMsgNo());
+        message.setSenderType(2);
+        if (bo.getSenderId() != null) {
+            message.setSenderId(bo.getSenderId());
+        } else {
+            // 使用 RuoYi 系统库的 LoginHelper,或者直接从 bo 中带过来
+            // 这里我建议先临时写死为 1L 用于测试,或者是手动加上:
+            message.setSenderId(org.dromara.common.satoken.utils.LoginHelper.getUserId());
+        }
+
+        message.setSenderId(bo.getSenderId());
+        message.setMsgType(bo.getMsgType());
+        message.setContent(bo.getContent());
+        message.setStatus(1);
+        message.setIsRead(0);
+        message.setSendTime(LocalDateTime.now());
+
+        baseMapper.insert(message);
+        sessionService.updateLastMessage(bo.getSessionId(),
+            StrUtil.sub(bo.getContent(), 0, 100));
+
+        return converter.convert(message, CsMessageVo.class);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public CsMessageVo sendImageMessage(Long sessionId, String msgNo, Long senderId, MultipartFile file) {
+        try {
+            OssClient ossClient = OssFactory.instance();
+            String fileName = file.getOriginalFilename();
+            InputStream inputStream = file.getInputStream();
+            Long fileSize = file.getSize();
+            String contentType = file.getContentType();
+
+            // 生成存储路径
+            String key = "chat/images/" + System.currentTimeMillis() + "_" + fileName;
+
+            // 上传文件
+            UploadResult uploadResult = ossClient.upload(inputStream, key, fileSize, contentType);
+
+            CsMessage message = new CsMessage();
+            message.setSessionId(sessionId);
+            message.setMsgNo(msgNo);
+            message.setSenderType(2);
+            message.setSenderId(senderId);
+            message.setMsgType("image");
+            message.setFileUrl(uploadResult.getUrl());
+            message.setFileName(fileName);
+            message.setFileSize(fileSize);
+            message.setFileType(contentType);
+            message.setStatus(1);
+            message.setIsRead(0);
+            message.setSendTime(LocalDateTime.now());
+
+            baseMapper.insert(message);
+            sessionService.updateLastMessage(sessionId, "[图片]");
+
+            return converter.convert(message, CsMessageVo.class);
+        } catch (Exception e) {
+            throw new RuntimeException("上传图片失败: " + e.getMessage(), e);
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public CsMessageVo sendFileMessage(Long sessionId, String msgNo, Long senderId, MultipartFile file) {
+        try {
+            OssClient ossClient = OssFactory.instance();
+            String fileName = file.getOriginalFilename();
+            InputStream inputStream = file.getInputStream();
+            Long fileSize = file.getSize();
+            String contentType = file.getContentType();
+
+            // 生成存储路径
+            String key = "chat/files/" + System.currentTimeMillis() + "_" + fileName;
+
+            // 上传文件
+            UploadResult uploadResult = ossClient.upload(inputStream, key, fileSize, contentType);
+
+            CsMessage message = new CsMessage();
+            message.setSessionId(sessionId);
+            message.setMsgNo(msgNo);
+            message.setSenderType(2);
+            message.setSenderId(senderId);
+            message.setMsgType("file");
+            message.setFileUrl(uploadResult.getUrl());
+            message.setFileName(fileName);
+            message.setFileSize(fileSize);
+            message.setFileType(contentType);
+            message.setStatus(1);
+            message.setIsRead(0);
+            message.setSendTime(LocalDateTime.now());
+
+            baseMapper.insert(message);
+            sessionService.updateLastMessage(sessionId, "[文件]" + fileName);
+
+            return converter.convert(message, CsMessageVo.class);
+        } catch (Exception e) {
+            throw new RuntimeException("上传文件失败: " + e.getMessage(), e);
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public CsMessageVo sendJobCard(CsMessageBo bo) {
+        CsMessage message = new CsMessage();
+        message.setSessionId(bo.getSessionId());
+        message.setMsgNo(bo.getMsgNo());
+        message.setSenderType(2);
+        message.setSenderId(bo.getSenderId());
+        message.setMsgType("job_card");
+        message.setPayload(JSONUtil.toJsonStr(bo.getPayload()));
+        message.setStatus(1);
+        message.setIsRead(0);
+        message.setSendTime(LocalDateTime.now());
+
+        baseMapper.insert(message);
+        sessionService.updateLastMessage(bo.getSessionId(), "[岗位推荐]");
+
+        return converter.convert(message, CsMessageVo.class);
+    }
+
+    @Override
+    public Boolean markAsRead(Long sessionId, Long lastReadMsgId) {
+        baseMapper.update(null,
+            Wrappers.lambdaUpdate(CsMessage.class)
+                .set(CsMessage::getIsRead, 1)
+                .eq(CsMessage::getSessionId, sessionId)
+                .le(CsMessage::getId, lastReadMsgId)
+                .eq(CsMessage::getIsRead, 0));
+
+        sessionService.clearUnreadCount(sessionId);
+        return true;
+    }
+
+    @Override
+    public Boolean recallMessage(Long msgId) {
+        CsMessage update = new CsMessage();
+        update.setId(msgId);
+        update.setStatus(2);
+        return baseMapper.updateById(update) > 0;
+    }
+}

+ 133 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/impl/CsOrderCardServiceImpl.java

@@ -0,0 +1,133 @@
+package org.dromara.main.service.impl;
+
+import cn.hutool.json.JSONUtil;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import io.github.linpeilie.Converter;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.main.domain.CsMessage;
+import org.dromara.main.domain.CsOrderCard;
+import org.dromara.main.domain.bo.CsOrderCardBo;
+import org.dromara.main.domain.vo.CsOrderCardVo;
+import org.dromara.main.mapper.CsMessageMapper;
+import org.dromara.main.mapper.CsOrderCardMapper;
+import org.dromara.main.service.ICsOrderCardService;
+import org.dromara.main.service.ICsSessionService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@RequiredArgsConstructor
+@Service
+public class CsOrderCardServiceImpl implements ICsOrderCardService {
+
+    private final CsOrderCardMapper baseMapper;
+    private final CsMessageMapper messageMapper;
+    private final ICsSessionService sessionService;
+    private final Converter converter;
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public CsOrderCardVo sendOrderCard(CsOrderCardBo bo) {
+        CsMessage message = new CsMessage();
+        message.setSessionId(bo.getSessionId());
+        message.setMsgNo(bo.getMsgNo());
+        message.setSenderType(2);
+        message.setSenderId(bo.getSenderId());
+        message.setMsgType("order_card");
+        message.setStatus(1);
+        message.setIsRead(0);
+        message.setSendTime(LocalDateTime.now());
+        messageMapper.insert(message);
+
+        CsOrderCard orderCard = new CsOrderCard();
+        orderCard.setMsgId(message.getId());
+        orderCard.setSessionId(bo.getSessionId());
+        orderCard.setOrderName(bo.getOrderName());
+        orderCard.setOrderPrice(bo.getOrderPrice());
+        orderCard.setOrderType(bo.getOrderType());
+        orderCard.setStatus("pending");
+        orderCard.setExpireTime(LocalDateTime.now().plusSeconds(60));
+        orderCard.setCreateTime(LocalDateTime.now());
+        baseMapper.insert(orderCard);
+
+        Map<String, Object> payload = new HashMap<>();
+        payload.put("orderCardId", orderCard.getId());
+        payload.put("name", orderCard.getOrderName());
+        payload.put("price", orderCard.getOrderPrice().toString());
+        payload.put("status", "pending");
+        payload.put("expireTime", orderCard.getExpireTime().toString());
+        payload.put("countdownSeconds", 60);
+        message.setPayload(JSONUtil.toJsonStr(payload));
+        messageMapper.updateById(message);
+
+        sessionService.updateLastMessage(bo.getSessionId(), "[结算单]" + bo.getOrderName());
+
+        CsOrderCardVo vo = converter.convert(orderCard, CsOrderCardVo.class);
+        if (vo != null) {
+            vo.setCountdownSeconds(60L);
+        }
+        return vo;
+    }
+
+    @Override
+    public CsOrderCardVo queryById(Long orderCardId) {
+        CsOrderCard entity = baseMapper.selectById(orderCardId);
+        CsOrderCardVo vo = converter.convert(entity, CsOrderCardVo.class);
+        if (vo != null && "pending".equals(vo.getStatus())) {
+            long seconds = Duration.between(LocalDateTime.now(), vo.getExpireTime()).getSeconds();
+            vo.setCountdownSeconds(Math.max(0, seconds));
+        }
+        return vo;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Boolean payOrderCard(Long orderCardId, Long userId) {
+        CsOrderCard orderCard = baseMapper.selectById(orderCardId);
+        if (orderCard == null) {
+            throw new RuntimeException("结算单不存在");
+        }
+        if (!"pending".equals(orderCard.getStatus())) {
+            throw new RuntimeException("结算单状态异常");
+        }
+        if (LocalDateTime.now().isAfter(orderCard.getExpireTime())) {
+            orderCard.setStatus("expired");
+            baseMapper.updateById(orderCard);
+            throw new RuntimeException("结算单已过期");
+        }
+
+        orderCard.setStatus("paid");
+        orderCard.setPayTime(LocalDateTime.now());
+        return baseMapper.updateById(orderCard) > 0;
+    }
+
+    @Override
+    public Boolean cancelOrderCard(Long orderCardId) {
+        CsOrderCard update = new CsOrderCard();
+        update.setId(orderCardId);
+        update.setStatus("cancelled");
+        return baseMapper.updateById(update) > 0;
+    }
+
+    @Override
+    public void checkExpiredOrderCards() {
+        List<CsOrderCard> expiredCards = baseMapper.selectList(
+            Wrappers.lambdaQuery(CsOrderCard.class)
+                .eq(CsOrderCard::getStatus, "pending")
+                .lt(CsOrderCard::getExpireTime, LocalDateTime.now())
+        );
+
+        for (CsOrderCard card : expiredCards) {
+            card.setStatus("expired");
+            baseMapper.updateById(card);
+            log.info("结算单已过期: orderCardId={}", card.getId());
+        }
+    }
+}

+ 203 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/impl/CsSeatConfigServiceImpl.java

@@ -0,0 +1,203 @@
+package org.dromara.main.service.impl;
+
+import cn.hutool.core.bean.BeanUtil;
+import cn.hutool.core.collection.CollUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import io.github.linpeilie.Converter;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.main.domain.CsSeatConfig;
+import org.dromara.main.domain.CsSeatWaiter;
+import org.dromara.main.domain.bo.CsSeatConfigBo;
+import org.dromara.main.domain.vo.CsSeatConfigVo;
+import org.dromara.main.mapper.CsSeatConfigMapper;
+import org.dromara.main.mapper.CsSeatWaiterMapper;
+import org.dromara.main.service.ICsSeatConfigService;
+import org.dromara.system.domain.SysUser;
+import org.dromara.system.mapper.SysUserMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@RequiredArgsConstructor
+@Service
+public class CsSeatConfigServiceImpl implements ICsSeatConfigService {
+
+    private final CsSeatConfigMapper baseMapper;
+    private final CsSeatWaiterMapper waiterMapper;
+    private final SysUserMapper sysUserMapper;
+    private final Converter converter;
+
+    @Override
+    public CsSeatConfigVo queryById(Long id) {
+        CsSeatConfigVo vo = baseMapper.selectVoById(id);
+        if (vo != null) {
+            fillWaitersForVo(vo);
+        }
+        return vo;
+    }
+
+    @Override
+    public TableDataInfo<CsSeatConfigVo> queryPageList(CsSeatConfigBo bo, PageQuery pageQuery) {
+        LambdaQueryWrapper<CsSeatConfig> lqw = buildQueryWrapper(bo);
+        // 使用 selectVoPage 直接查询 Vo 的 Page
+        Page<CsSeatConfigVo> result = baseMapper.selectVoPage(pageQuery.build(), lqw);
+        
+        List<CsSeatConfigVo> records = result.getRecords();
+        if (CollUtil.isNotEmpty(records)) {
+            // 批量查询所有关联记录以提高性能
+            List<Long> seatIds = records.stream().map(CsSeatConfigVo::getId).collect(Collectors.toList());
+            List<CsSeatWaiter> allWaiters = waiterMapper.selectList(Wrappers.lambdaQuery(CsSeatWaiter.class).in(CsSeatWaiter::getSeatId, seatIds));
+            
+            if (CollUtil.isNotEmpty(allWaiters)) {
+                List<Long> allUserIds = allWaiters.stream().map(CsSeatWaiter::getUserId).distinct().collect(Collectors.toList());
+                List<SysUser> users = sysUserMapper.selectBatchIds(allUserIds);
+                Map<Long, SysUser> userMap = users.stream().collect(Collectors.toMap(SysUser::getUserId, u -> u));
+                
+                // 按 seatId 分组
+                Map<Long, List<CsSeatWaiter>> seatWaiterMap = allWaiters.stream().collect(Collectors.groupingBy(CsSeatWaiter::getSeatId));
+                
+                for (CsSeatConfigVo vo : records) {
+                    List<CsSeatWaiter> seatWaiters = seatWaiterMap.get(vo.getId());
+                    if (CollUtil.isNotEmpty(seatWaiters)) {
+                        List<CsSeatConfigVo.WaiterInfo> waiterInfos = seatWaiters.stream().map(sw -> {
+                            SysUser user = userMap.get(sw.getUserId());
+                            if (user != null) {
+                                CsSeatConfigVo.WaiterInfo info = new CsSeatConfigVo.WaiterInfo();
+                                info.setUserId(user.getUserId());
+                                info.setUserName(user.getUserName());
+                                info.setNickName(user.getNickName());
+                                return info;
+                            }
+                            return null;
+                        }).filter(info -> info != null).collect(Collectors.toList());
+                        vo.setWaiters(waiterInfos);
+                    }
+                }
+            }
+        }
+        
+        return TableDataInfo.build(result);
+    }
+
+    @Override
+    public List<CsSeatConfigVo> queryList(CsSeatConfigBo bo) {
+        LambdaQueryWrapper<CsSeatConfig> lqw = buildQueryWrapper(bo);
+        List<CsSeatConfigVo> list = baseMapper.selectVoList(lqw);
+        if (CollUtil.isNotEmpty(list)) {
+            for (CsSeatConfigVo vo : list) {
+                fillWaitersForVo(vo);
+            }
+        }
+        return list;
+    }
+
+    private void fillWaitersForVo(CsSeatConfigVo vo) {
+        List<CsSeatWaiter> relations = waiterMapper.selectList(Wrappers.lambdaQuery(CsSeatWaiter.class).eq(CsSeatWaiter::getSeatId, vo.getId()));
+        if (CollUtil.isNotEmpty(relations)) {
+            List<Long> userIds = relations.stream().map(CsSeatWaiter::getUserId).collect(Collectors.toList());
+            List<SysUser> users = sysUserMapper.selectBatchIds(userIds);
+            if (CollUtil.isNotEmpty(users)) {
+                List<CsSeatConfigVo.WaiterInfo> waiterInfos = users.stream().map(u -> {
+                    CsSeatConfigVo.WaiterInfo info = new CsSeatConfigVo.WaiterInfo();
+                    info.setUserId(u.getUserId());
+                    info.setUserName(u.getUserName());
+                    info.setNickName(u.getNickName());
+                    return info;
+                }).collect(Collectors.toList());
+                vo.setWaiters(waiterInfos);
+            }
+        }
+    }
+
+    private LambdaQueryWrapper<CsSeatConfig> buildQueryWrapper(CsSeatConfigBo bo) {
+        LambdaQueryWrapper<CsSeatConfig> lqw = Wrappers.lambdaQuery();
+        lqw.like(StringUtils.isNotBlank(bo.getSeatName()), CsSeatConfig::getSeatName, bo.getSeatName());
+        lqw.eq(StringUtils.isNotBlank(bo.getModule()), CsSeatConfig::getModule, bo.getModule());
+        lqw.eq(bo.getStatus() != null, CsSeatConfig::getStatus, bo.getStatus());
+        lqw.orderByDesc(CsSeatConfig::getCreateTime);
+        return lqw;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Boolean insertByBo(CsSeatConfigBo bo) {
+        CsSeatConfig add = BeanUtil.toBean(bo, CsSeatConfig.class);
+        validEntityBeforeSave(add);
+        boolean flag = baseMapper.insert(add) > 0;
+        if (flag) {
+            bo.setId(add.getId());
+            if (CollUtil.isNotEmpty(bo.getWaiterIds())) {
+                assignWaiters(add.getId(), bo.getWaiterIds());
+            }
+        }
+        return flag;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Boolean updateByBo(CsSeatConfigBo bo) {
+        CsSeatConfig update = BeanUtil.toBean(bo, CsSeatConfig.class);
+        validEntityBeforeSave(update);
+        boolean flag = baseMapper.updateById(update) > 0;
+        if (flag && bo.getWaiterIds() != null) {
+            waiterMapper.delete(Wrappers.lambdaQuery(CsSeatWaiter.class)
+                .eq(CsSeatWaiter::getSeatId, bo.getId()));
+            if (CollUtil.isNotEmpty(bo.getWaiterIds())) {
+                assignWaiters(bo.getId(), bo.getWaiterIds());
+            }
+        }
+        return flag;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Boolean deleteById(Long id) {
+        waiterMapper.delete(Wrappers.lambdaQuery(CsSeatWaiter.class)
+            .eq(CsSeatWaiter::getSeatId, id));
+        return baseMapper.deleteById(id) > 0;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Boolean deleteByIds(List<Long> ids) {
+        waiterMapper.delete(Wrappers.lambdaQuery(CsSeatWaiter.class)
+            .in(CsSeatWaiter::getSeatId, ids));
+        return baseMapper.deleteBatchIds(ids) > 0;
+    }
+
+    @Override
+    public Boolean changeStatus(Long id, Integer status) {
+        CsSeatConfig update = new CsSeatConfig();
+        update.setId(id);
+        update.setStatus(status);
+        return baseMapper.updateById(update) > 0;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Boolean assignWaiters(Long seatId, List<Long> waiterIds) {
+        if (CollUtil.isEmpty(waiterIds)) {
+            return true;
+        }
+        List<CsSeatWaiter> waiters = waiterIds.stream().map(userId -> {
+            CsSeatWaiter waiter = new CsSeatWaiter();
+            waiter.setSeatId(seatId);
+            waiter.setUserId(userId);
+            return waiter;
+        }).collect(Collectors.toList());
+        return waiterMapper.insertBatch(waiters);
+    }
+
+    private void validEntityBeforeSave(CsSeatConfig entity) {
+        // TODO: 数据校验
+    }
+}

+ 127 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/impl/CsSessionServiceImpl.java

@@ -0,0 +1,127 @@
+package org.dromara.main.service.impl;
+
+import cn.hutool.core.util.IdUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import io.github.linpeilie.Converter;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.main.domain.CsMessage;
+import org.dromara.main.domain.CsSession;
+import org.dromara.main.domain.bo.CsMessageBo;
+import org.dromara.main.domain.bo.CsSessionBo;
+import org.dromara.main.domain.vo.CsMessageVo;
+import org.dromara.main.domain.vo.CsSessionVo;
+import org.dromara.main.mapper.CsSessionMapper;
+import org.dromara.main.service.ICsSessionService;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+
+@RequiredArgsConstructor
+@Service
+  public class CsSessionServiceImpl implements ICsSessionService {
+
+    private final CsSessionMapper baseMapper;
+    private final Converter converter;
+
+    @Override
+    public CsSessionVo createOrGetSession(Integer sessionType, Long fromUserId,
+                                          String fromUserName, String fromUserAvatar) {
+        LambdaQueryWrapper<CsSession> lqw = Wrappers.lambdaQuery();
+        lqw.eq(CsSession::getSessionType, sessionType)
+            .eq(CsSession::getFromUserId, fromUserId)
+            .eq(CsSession::getStatus, 1)
+            .orderByDesc(CsSession::getCreateTime)
+            .last("LIMIT 1");
+
+        CsSession existSession = baseMapper.selectOne(lqw);
+        if (existSession != null) {
+            return converter.convert(existSession, CsSessionVo.class);
+        }
+
+        CsSession newSession = new CsSession();
+        newSession.setSessionNo(generateSessionNo(sessionType, fromUserId));
+        newSession.setSessionType(sessionType);
+        newSession.setFromUserId(fromUserId);
+        newSession.setFromUserName(fromUserName);
+        newSession.setFromUserAvatar(fromUserAvatar);
+        newSession.setStatus(1);
+        newSession.setUnreadCount(0);
+        newSession.setCreateTime(LocalDateTime.now());
+
+        baseMapper.insert(newSession);
+        return converter.convert(newSession, CsSessionVo.class);
+    }
+
+    @Override
+    public TableDataInfo<CsSessionVo> queryPageList(CsSessionBo bo, PageQuery pageQuery) {
+        LambdaQueryWrapper<CsSession> lqw = buildQueryWrapper(bo);
+        Page<CsSession> page = baseMapper.selectPage(pageQuery.build(), lqw);
+        Page<CsSessionVo> voPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
+        voPage.setRecords(converter.convert(page.getRecords(), CsSessionVo.class));
+        return TableDataInfo.build(voPage);
+    }
+
+    private LambdaQueryWrapper<CsSession> buildQueryWrapper(CsSessionBo bo) {
+        LambdaQueryWrapper<CsSession> lqw = Wrappers.lambdaQuery();
+        lqw.eq(bo.getSessionType() != null, CsSession::getSessionType, bo.getSessionType());
+        lqw.eq(bo.getStatus() != null, CsSession::getStatus, bo.getStatus());
+        lqw.eq(bo.getWaiterId() != null, CsSession::getWaiterId, bo.getWaiterId());
+        lqw.like(StringUtils.isNotBlank(bo.getKeyword()), CsSession::getFromUserName, bo.getKeyword());
+        lqw.orderByDesc(CsSession::getLastMsgTime, CsSession::getCreateTime);
+        return lqw;
+    }
+
+    @Override
+    public CsSessionVo queryById(Long sessionId) {
+        CsSession entity = baseMapper.selectById(sessionId);
+        return converter.convert(entity, CsSessionVo.class);
+    }
+
+    @Override
+    public Boolean endSession(Long sessionId) {
+        CsSession update = new CsSession();
+        update.setId(sessionId);
+        update.setStatus(2);
+        update.setEndTime(LocalDateTime.now());
+        return baseMapper.updateById(update) > 0;
+    }
+
+    @Override
+    public Boolean updateLastMessage(Long sessionId, String lastMsg) {
+        CsSession update = new CsSession();
+        update.setId(sessionId);
+        update.setLastMsg(lastMsg);
+        update.setLastMsgTime(LocalDateTime.now());
+        return baseMapper.updateById(update) > 0;
+    }
+
+    @Override
+    public Boolean incrementUnreadCount(Long sessionId) {
+        return baseMapper.update(null,
+            Wrappers.lambdaUpdate(CsSession.class)
+                .setSql("unread_count = unread_count + 1")
+                .eq(CsSession::getId, sessionId)) > 0;
+    }
+
+    @Override
+    public Boolean clearUnreadCount(Long sessionId) {
+        CsSession update = new CsSession();
+        update.setId(sessionId);
+        update.setUnreadCount(0);
+        return baseMapper.updateById(update) > 0;
+    }
+
+    private String generateSessionNo(Integer sessionType, Long fromUserId) {
+        String prefix = sessionType == 1 ? "MINI" : "MERCHANT";
+        return String.format("SESSION_%s_%d_%s", prefix, fromUserId,
+            IdUtil.fastSimpleUUID().substring(0, 8));
+    }
+
+
+
+}

+ 66 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/impl/CsTicketServiceImpl.java

@@ -0,0 +1,66 @@
+package org.dromara.main.service.impl;
+
+import cn.hutool.core.bean.BeanUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.main.domain.CsMessage;
+import org.dromara.main.domain.CsTicket;
+import org.dromara.main.domain.bo.CsTicketBo;
+import org.dromara.main.domain.vo.CsTicketVo;
+import org.dromara.main.mapper.CsMessageMapper;
+import org.dromara.main.mapper.CsTicketMapper;
+import org.dromara.main.service.ICsTicketService;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.util.Collection;
+
+@RequiredArgsConstructor
+@Service
+public class CsTicketServiceImpl implements ICsTicketService {
+    private final CsTicketMapper baseMapper;
+    private final CsMessageMapper messageMapper;
+    @Override
+    public CsTicketVo queryById(String id) {
+        CsTicketVo vo = baseMapper.selectVoById(id);
+        if (vo != null && vo.getSessionId() != null) {
+            // 根据 sessionId 关联查询所有沟通消息,按时间正序
+            LambdaQueryWrapper<CsMessage> lqw = Wrappers.lambdaQuery();
+            lqw.eq(CsMessage::getSessionId, vo.getSessionId());
+            lqw.orderByAsc(CsMessage::getSendTime);
+            vo.setMessages(messageMapper.selectVoList(lqw));
+        }
+        return vo;
+    }
+    @Override
+    public TableDataInfo<CsTicketVo> queryPageList(CsTicketBo bo, PageQuery pageQuery) {
+        LambdaQueryWrapper<CsTicket> lqw = Wrappers.lambdaQuery();
+        lqw.eq(StringUtils.isNotBlank(bo.getId()), CsTicket::getId, bo.getId());
+        lqw.eq(StringUtils.isNotBlank(bo.getUserId()), CsTicket::getUserId, bo.getUserId());
+        lqw.eq(StringUtils.isNotBlank(bo.getSource()), CsTicket::getSource, bo.getSource());
+        lqw.eq(StringUtils.isNotBlank(bo.getCategory()), CsTicket::getCategory, bo.getCategory());
+        lqw.eq(StringUtils.isNotBlank(bo.getStatus()), CsTicket::getStatus, bo.getStatus());
+        lqw.orderByDesc(CsTicket::getCreateTime);
+
+        Page<CsTicketVo> result = baseMapper.selectVoPage(pageQuery.build(), lqw);
+        return TableDataInfo.build(result);
+    }
+    @Override
+    public Boolean handleTicket(CsTicketBo bo) {
+        CsTicket update = BeanUtil.toBean(bo, CsTicket.class);
+        // 如果状态变更为 已完成 或 已废弃,记录结束时间
+        if ("completed".equals(bo.getStatus()) || "abandoned".equals(bo.getStatus())) {
+            update.setFinishTime(LocalDateTime.now());
+        }
+        return baseMapper.updateById(update) > 0;
+    }
+    @Override
+    public Boolean deleteByIds(Collection<String> ids) {
+        return baseMapper.deleteByIds(ids) > 0;
+    }
+}

+ 28 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/task/OrderCardExpireTask.java

@@ -0,0 +1,28 @@
+package org.dromara.main.task;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.main.service.ICsOrderCardService;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@RequiredArgsConstructor
+@Component
+public class OrderCardExpireTask {
+
+    private final ICsOrderCardService orderCardService;
+
+    /**
+     * 每5秒检查一次过期结算单
+     * 检查状态为pending且已超过过期时间的结算单,将其状态更新为expired
+     */
+    @Scheduled(fixedRate = 5000)
+    public void checkExpiredOrderCards() {
+        try {
+            orderCardService.checkExpiredOrderCards();
+        } catch (Exception e) {
+            log.error("检查过期结算单失败", e);
+        }
+    }
+}

+ 1 - 1
ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysUserBo.java

@@ -120,7 +120,7 @@ public class SysUserBo extends BaseEntity {
     private Integer platformId;
 
 //    租户id
-    private Long tenantId;
+    private String tenantId;
 
     public SysUserBo(Long userId) {
         this.userId = userId;

+ 1 - 0
ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysPostServiceImpl.java

@@ -91,6 +91,7 @@ public class SysPostServiceImpl implements ISysPostService, PostService {
             .like(StringUtils.isNotBlank(bo.getPostCategory()), SysPost::getPostCategory, bo.getPostCategory())
             .like(StringUtils.isNotBlank(bo.getPostName()), SysPost::getPostName, bo.getPostName())
             .eq(StringUtils.isNotBlank(bo.getStatus()), SysPost::getStatus, bo.getStatus())
+            .eq(StringUtils.isNotBlank(bo.getTenantId()), SysPost::getTenantId, bo.getTenantId())
             .eq(SysPost::getPlatformId, platformId)
             .between(params.get("beginTime") != null && params.get("endTime") != null,
                 SysPost::getCreateTime, params.get("beginTime"), params.get("endTime"))

+ 1 - 1
ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysUserServiceImpl.java

@@ -96,7 +96,7 @@ public class SysUserServiceImpl implements ISysUserService, UserService {
         wrapper.eq(SysUser::getDelFlag, SystemConstants.NORMAL)
             .eq(ObjectUtil.isNotNull(user.getUserId()), SysUser::getUserId, user.getUserId())
             .eq(SysUser::getPlatformId,platformId)
-//            .eq(ObjectUtil.isNotNull(user.getTenantId()),SysUser::getTenantId, user.getTenantId())
+            .eq(ObjectUtil.isNotNull(user.getTenantId()),SysUser::getTenantId, user.getTenantId())
             .in(StringUtils.isNotBlank(user.getUserIds()), SysUser::getUserId, StringUtils.splitTo(user.getUserIds(), Convert::toLong))
             .like(StringUtils.isNotBlank(user.getUserName()), SysUser::getUserName, user.getUserName())
             .like(StringUtils.isNotBlank(user.getNickName()), SysUser::getNickName, user.getNickName())

+ 193 - 4
script/sql/main.sql

@@ -251,18 +251,18 @@ CREATE TABLE `main_audit` (
 -- ----------------------------
 -- 字典类型
 DELETE FROM `sys_dict_type` WHERE `dict_type` = 'sys_clause_type';
-INSERT INTO `sys_dict_type` (`dict_id`, `dict_name`, `dict_type`, `status`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) 
+INSERT INTO `sys_dict_type` (`dict_id`, `dict_name`, `dict_type`, `status`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
 VALUES (null, '背调条款类型', 'sys_clause_type', '0', 1, sysdate(), 1, sysdate(), '背调条款类型列表');
 
 -- 字典数据
 DELETE FROM `sys_dict_data` WHERE `dict_type` = 'sys_clause_type';
-INSERT INTO `sys_dict_data` (`dict_code`, `dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `status`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) 
+INSERT INTO `sys_dict_data` (`dict_code`, `dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `status`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
 VALUES (null, 1, '身份风险', '身份风险', 'sys_clause_type', '', 'primary', 'N', '0', 1, sysdate(), 1, sysdate(), '身份风险');
 
-INSERT INTO `sys_dict_data` (`dict_code`, `dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `status`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) 
+INSERT INTO `sys_dict_data` (`dict_code`, `dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `status`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
 VALUES (null, 2, '职业风险', '职业风险', 'sys_clause_type', '', 'warning', 'N', '0', 1, sysdate(), 1, sysdate(), '职业风险');
 
-INSERT INTO `sys_dict_data` (`dict_code`, `dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `status`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) 
+INSERT INTO `sys_dict_data` (`dict_code`, `dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `status`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
 VALUES (null, 3, '能力评估', '能力评估', 'sys_clause_type', '', 'success', 'N', '0', 1, sysdate(), 1, sysdate(), '能力评估');
 
 
@@ -435,3 +435,192 @@ CREATE TABLE IF NOT EXISTS `main_student_project` (
 
 
 
+-- =====================================================
+-- 1. 坐席配置表
+-- 对应前端: SeatConfig.vue
+-- =====================================================
+DROP TABLE IF EXISTS cs_seat_config;
+CREATE TABLE cs_seat_config (
+                                id          BIGINT       NOT NULL AUTO_INCREMENT COMMENT '坐席ID',
+                                seat_name   VARCHAR(50)  NOT NULL COMMENT '坐席名称(如:客服小A)',
+                                avatar      VARCHAR(500) DEFAULT NULL COMMENT '坐席头像URL',
+                                module      VARCHAR(20)  NOT NULL COMMENT '负责模块: mini(小程序), merchant(商家), all(全部)',
+                                status      TINYINT(1)   NOT NULL DEFAULT 1 COMMENT '状态: 0=停用, 1=启用',
+                                create_dept bigint DEFAULT NULL COMMENT '创建部门',
+                                create_time datetime DEFAULT NULL COMMENT '创建时间',
+                                create_by bigint DEFAULT NULL COMMENT '上传人',
+                                update_time datetime DEFAULT NULL COMMENT '更新时间',
+                                update_by bigint DEFAULT NULL COMMENT '更新人',
+                                PRIMARY KEY (id)
+) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='坐席配置表';
+
+-- 坐席与客服人员关联表
+DROP TABLE IF EXISTS cs_seat_waiter;
+CREATE TABLE cs_seat_waiter (
+                                id          BIGINT NOT NULL AUTO_INCREMENT,
+                                seat_id     BIGINT NOT NULL COMMENT '坐席ID',
+                                user_id     BIGINT NOT NULL COMMENT '客服用户ID(关联若依sys_user)',
+                                create_dept bigint DEFAULT NULL COMMENT '创建部门',
+                                create_time datetime DEFAULT NULL COMMENT '创建时间',
+                                create_by bigint DEFAULT NULL COMMENT '上传人',
+                                update_time datetime DEFAULT NULL COMMENT '更新时间',
+                                update_by bigint DEFAULT NULL COMMENT '更新人',
+                                PRIMARY KEY (id),
+                                KEY idx_seat_id (seat_id),
+                                KEY idx_user_id (user_id)
+) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='坐席-客服关联表';
+
+-- 初始数据
+INSERT INTO cs_seat_config (seat_name, avatar, module, status) VALUES
+                                                                   ('客服小A', 'https://api.dicebear.com/7.x/avataaars/svg?seed=A', '小程序', 1),
+                                                                   ('客服小B', 'https://api.dicebear.com/7.x/avataaars/svg?seed=B', '官网',   1),
+                                                                   ('客服小C', 'https://api.dicebear.com/7.x/avataaars/svg?seed=C', '小程序', 0);
+
+
+-- =====================================================
+-- 2. 会话表(一个 User/商家 与客服建立一个会话)
+-- =====================================================
+DROP TABLE IF EXISTS cs_session;
+CREATE TABLE cs_session (
+                            id              BIGINT        NOT NULL AUTO_INCREMENT COMMENT '会话ID',
+                            session_no      VARCHAR(32)   NOT NULL UNIQUE COMMENT '会话编号,唯一,用于WS频道路由',
+                            session_type    TINYINT(1)    NOT NULL COMMENT '会话类型: 1=小程序用户, 2=PC商家',
+                            from_user_id    BIGINT        NOT NULL COMMENT '发起用户ID(小程序用户或商家用户ID)',
+                            from_user_name  VARCHAR(100)  DEFAULT '' COMMENT '发起方昵称',
+                            from_user_avatarVARCHAR(500)  DEFAULT '' COMMENT '发起方头像',
+                            seat_id         BIGINT        DEFAULT NULL COMMENT '当前分配坐席ID',
+                            waiter_id       BIGINT        DEFAULT NULL COMMENT '当前接待客服ID',
+                            status          TINYINT(1)    NOT NULL DEFAULT 1 COMMENT '状态: 1=进行中, 2=已结束',
+                            last_msg        VARCHAR(500)  DEFAULT '' COMMENT '最后一条消息摘要(用于列表展示)',
+                            last_msg_time   DATETIME      DEFAULT NULL COMMENT '最后消息时间',
+                            unread_count    INT           NOT NULL DEFAULT 0 COMMENT '客服侧未读消息数',
+                            create_dept bigint DEFAULT NULL COMMENT '创建部门',
+                            create_time datetime DEFAULT NULL COMMENT '创建时间',
+                            create_by bigint DEFAULT NULL COMMENT '上传人',
+                            update_time datetime DEFAULT NULL COMMENT '更新时间',
+                            update_by bigint DEFAULT NULL COMMENT '更新人',
+                            PRIMARY KEY (id),
+                            KEY idx_from_user (from_user_id),
+                            KEY idx_waiter (waiter_id),
+                            KEY idx_status (status)
+) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='客服会话表';
+
+
+-- =====================================================
+-- 3. 消息表(核心表,存储所有聊天消息)
+-- =====================================================
+DROP TABLE IF EXISTS cs_message;
+CREATE TABLE cs_message (
+                            id           BIGINT        NOT NULL AUTO_INCREMENT COMMENT '消息ID',
+                            session_id   BIGINT        NOT NULL COMMENT '归属会话ID',
+                            msg_no       VARCHAR(32)   NOT NULL UNIQUE COMMENT '消息唯一编号(客户端生成,用于幂等)',
+                            sender_type  TINYINT(1)    NOT NULL COMMENT '发送方类型: 1=用户/商家, 2=客服, 3=系统',
+                            sender_id    BIGINT        NOT NULL COMMENT '发送者ID',
+                            msg_type     VARCHAR(20)   NOT NULL COMMENT '消息类型: text/image/file/job_card/order_card/emoji',
+                            content      TEXT          DEFAULT NULL COMMENT '文本消息内容',
+                            file_url     VARCHAR(500)  DEFAULT NULL COMMENT '图片/文件URL',
+                            file_name    VARCHAR(255)  DEFAULT NULL COMMENT '文件原始名称',
+                            file_size    BIGINT        DEFAULT NULL COMMENT '文件大小(字节)',
+                            file_type    VARCHAR(100)  DEFAULT NULL COMMENT '文件MIME类型',
+                            payload      JSON          DEFAULT NULL COMMENT '卡片类消息的结构化数据(job_card/order_card的JSON)',
+                            status       TINYINT(1)    NOT NULL DEFAULT 1 COMMENT '状态: 1=正常, 2=已撤回',
+                            is_read      TINYINT(1)    NOT NULL DEFAULT 0 COMMENT '是否已读: 0=未读, 1=已读',
+                            send_time    DATETIME      NOT NULL COMMENT '发送时间',
+                            create_dept bigint DEFAULT NULL COMMENT '创建部门',
+                            create_time datetime DEFAULT NULL COMMENT '创建时间',
+                            create_by bigint DEFAULT NULL COMMENT '上传人',
+                            update_time datetime DEFAULT NULL COMMENT '更新时间',
+                            update_by bigint DEFAULT NULL COMMENT '更新人',
+                            PRIMARY KEY (id),
+                            KEY idx_session (session_id),
+                            KEY idx_send_time (send_time),
+                            KEY idx_msg_type (msg_type)
+) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='聊天消息表';
+
+
+-- =====================================================
+-- 4. 结算单(order_card)扩展表
+-- 配合消息表中 msg_type='order_card' 的消息使用
+-- 实现: 60秒倒计时自动失效
+-- =====================================================
+DROP TABLE IF EXISTS cs_order_card;
+CREATE TABLE cs_order_card (
+                               id              BIGINT         NOT NULL AUTO_INCREMENT COMMENT '结算单ID',
+                               msg_id          BIGINT         NOT NULL COMMENT '关联消息ID',
+                               session_id      BIGINT         NOT NULL COMMENT '关联会话ID',
+                               order_name      VARCHAR(200)   NOT NULL COMMENT '项目名称',
+                               order_price     DECIMAL(10, 2) NOT NULL COMMENT '支付金额',
+                               order_type      VARCHAR(50)    DEFAULT NULL COMMENT '订单类型说明',
+                               original_order_id BIGINT       DEFAULT NULL COMMENT '关联平台真实订单ID',
+                               status          VARCHAR(20)    NOT NULL DEFAULT 'pending'
+                                   COMMENT '状态: pending=待支付, paid=已支付, cancelled=已取消, expired=已失效',
+                               expire_time     DATETIME       NOT NULL COMMENT '结算单过期时间(发送时间+60s)',
+                               pay_time        DATETIME       DEFAULT NULL COMMENT '实际支付时间',
+                               create_dept bigint DEFAULT NULL COMMENT '创建部门',
+                               create_time datetime DEFAULT NULL COMMENT '创建时间',
+                               create_by bigint DEFAULT NULL COMMENT '上传人',
+                               update_time datetime DEFAULT NULL COMMENT '更新时间',
+                               update_by bigint DEFAULT NULL COMMENT '更新人',
+                               PRIMARY KEY (id),
+                               KEY idx_msg_id (msg_id),
+                               KEY idx_session (session_id),
+                               KEY idx_status (status),
+                               KEY idx_expire (expire_time)
+) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='结算单表(order_card)';
+
+
+-- =====================================================
+-- 5. 工单表(客服工单管理)
+-- 对应前端: SeatConfig.vue 的工单子系统
+-- =====================================================
+DROP TABLE IF EXISTS cs_ticket;
+CREATE TABLE cs_ticket (
+                           id            VARCHAR(32)   NOT NULL COMMENT '工单编号(如: 20991216194)',
+                           session_id    BIGINT        DEFAULT NULL COMMENT '关联会话ID(若来自在线沟通)',
+                           user_id       VARCHAR(50)   NOT NULL COMMENT '反馈用户ID',
+                           user_name     VARCHAR(100)  NOT NULL COMMENT '反馈用户昵称',
+                           content       TEXT          NOT NULL COMMENT '反馈内容',
+                           source        VARCHAR(20)   NOT NULL COMMENT '反馈渠道: 小程序/商家',
+                           category      VARCHAR(50)   NOT NULL COMMENT '问题分类',
+                           status        VARCHAR(20)   NOT NULL DEFAULT 'pending'
+                               COMMENT '状态: pending=待处理, processing=处理中, completed=已完成, abandoned=已废弃',
+                           handler_id    BIGINT        DEFAULT NULL COMMENT '处理客服ID',
+                           handler_reply TEXT          DEFAULT NULL COMMENT '客服处理回复',
+                           create_dept bigint DEFAULT NULL COMMENT '创建部门',
+                           create_time datetime DEFAULT NULL COMMENT '创建时间',
+                           create_by bigint DEFAULT NULL COMMENT '上传人',
+                           update_time datetime DEFAULT NULL COMMENT '更新时间',
+                           update_by bigint DEFAULT NULL COMMENT '更新人',
+                           PRIMARY KEY (id),
+                           KEY idx_user (user_id),
+                           KEY idx_status (status),
+                           KEY idx_source (source)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客服工单表';
+
+
+-- =====================================================
+-- 7. 消息已读记录表(精确追踪已读状态)
+-- =====================================================
+DROP TABLE IF EXISTS cs_read_record;
+CREATE TABLE cs_read_record (
+                                id          BIGINT   NOT NULL AUTO_INCREMENT,
+                                session_id  BIGINT   NOT NULL COMMENT '会话ID',
+                                user_id     BIGINT   NOT NULL COMMENT '用户ID',
+                                last_read_msg_id BIGINT DEFAULT 0 COMMENT '已读到的消息ID',
+                                read_time   DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+                                create_dept bigint DEFAULT NULL COMMENT '创建部门',
+                                create_time datetime DEFAULT NULL COMMENT '创建时间',
+                                create_by bigint DEFAULT NULL COMMENT '上传人',
+                                update_time datetime DEFAULT NULL COMMENT '更新时间',
+                                update_by bigint DEFAULT NULL COMMENT '更新人',
+                                PRIMARY KEY (id),
+                                UNIQUE KEY uk_session_user (session_id, user_id)
+) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='消息已读记录表';
+
+
+
+
+
+
+
+