Browse Source

基础对话;SSE 流式响应;会话创建+历史查选接口

WangKang 2 months ago
parent
commit
d2bd9e28

+ 8 - 0
emoon-openplatform/pom.xml

@@ -26,6 +26,14 @@
             <groupId>com.emoon</groupId>
             <artifactId>emoon-common-core</artifactId>
         </dependency>
+        <dependency>
+            <groupId>com.emoon</groupId>
+            <artifactId>emoon-mcp-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.emoon</groupId>
+            <artifactId>emoon-system-api</artifactId>
+        </dependency>
         <dependency>
             <groupId>com.mysql</groupId>
             <artifactId>mysql-connector-j</artifactId>

+ 165 - 0
emoon-openplatform/src/main/java/com/emoon/openplatform/controller/v1/AgentController.java

@@ -0,0 +1,165 @@
+package com.emoon.openplatform.controller.v1;
+
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.json.JSONUtil;
+import com.emoon.common.core.domain.R;
+import com.emoon.openplatform.domain.SysProjectDo;
+import com.emoon.openplatform.domain.dto.request.AgentChatRequest;
+import com.emoon.openplatform.domain.dto.request.ConversationCreateRequest;
+import com.emoon.openplatform.domain.dto.resp.AgentChatResponse;
+import com.emoon.openplatform.domain.dto.resp.ConversationVo;
+import com.emoon.openplatform.enums.SystemEnum;
+import com.emoon.openplatform.service.IAgentChatService;
+import com.emoon.openplatform.service.IConversationService;
+import com.emoon.openplatform.service.ISysProjectService;
+import com.emoon.openplatform.util.SignUtil;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.MediaType;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * 智能体对话 + 会话管理 API
+ */
+@Slf4j
+@Validated
+@RestController
+@RequestMapping("/api/v1")
+@RequiredArgsConstructor
+public class AgentController {
+
+    private static final String HEADER_TIMESTAMP = "X-Emoon-Timestamp";
+    private static final String HEADER_REQUEST_ID = "X-Emoon-Request-Id";
+    private static final String HEADER_PUBLIC_KEY = "X-Emoon-Public-Key";
+    private static final String HEADER_SIGN = "X-Emoon-Sign";
+
+    private final IAgentChatService agentChatService;
+    private final IConversationService conversationService;
+    private final ISysProjectService sysProjectService;
+
+    /**
+     * 对话接口(支持同步和 SSE 流式)
+     */
+    @PostMapping(value = "/agent/chat", produces = {MediaType.APPLICATION_JSON_VALUE, MediaType.TEXT_EVENT_STREAM_VALUE})
+    public Object chat(
+        @RequestHeader(HEADER_TIMESTAMP) String timestamp,
+        @RequestHeader(HEADER_REQUEST_ID) String requestId,
+        @RequestHeader(HEADER_PUBLIC_KEY) String publicKey,
+        @RequestHeader(HEADER_SIGN) String sign,
+        @Valid @RequestBody AgentChatRequest request
+    ) {
+        // 鉴权
+        AuthContext authCtx = authenticate(publicKey, sign, requestId, timestamp, JSONUtil.toJsonStr(request), request.getAgentId());
+
+        // 判断同步 or 流式
+        if (Boolean.TRUE.equals(request.getStream())) {
+            // SSE 流式响应
+            SseEmitter emitter = new SseEmitter(0L);
+            CompletableFuture.runAsync(() -> 
+                agentChatService.chatStream(request, authCtx.projectId, authCtx.tenantId, emitter)
+            );
+            return emitter;
+        } else {
+            // 同步响应
+            AgentChatResponse response = agentChatService.chat(request, authCtx.projectId, authCtx.tenantId);
+            return R.ok(response);
+        }
+    }
+
+    /**
+     * 创建会话
+     */
+    @PostMapping("/conversation/create")
+    public R<ConversationVo> createConversation(
+        @RequestHeader(HEADER_TIMESTAMP) String timestamp,
+        @RequestHeader(HEADER_REQUEST_ID) String requestId,
+        @RequestHeader(HEADER_PUBLIC_KEY) String publicKey,
+        @RequestHeader(HEADER_SIGN) String sign,
+        @Valid @RequestBody ConversationCreateRequest request
+    ) {
+        AuthContext authCtx = authenticate(publicKey, sign, requestId, timestamp, JSONUtil.toJsonStr(request), request.getAgentId());
+        ConversationVo vo = conversationService.create(request, authCtx.projectId, authCtx.tenantId);
+        return R.ok(vo);
+    }
+
+    /**
+     * 查询会话列表
+     */
+    @GetMapping("/conversation/list")
+    public R<List<ConversationVo>> listConversations(
+        @RequestHeader(HEADER_TIMESTAMP) String timestamp,
+        @RequestHeader(HEADER_REQUEST_ID) String requestId,
+        @RequestHeader(HEADER_PUBLIC_KEY) String publicKey,
+        @RequestHeader(HEADER_SIGN) String sign,
+        @RequestParam(required = false) String agentId,
+        @RequestParam(required = false) Long userId
+    ) {
+        // GET 请求 payload 为空字符串
+        AuthContext authCtx = authenticate(publicKey, sign, requestId, timestamp, "", agentId);
+        List<ConversationVo> list = conversationService.list(authCtx.projectId, agentId, userId);
+        return R.ok(list);
+    }
+
+    /**
+     * 查询会话详情
+     */
+    @GetMapping("/conversation/{conversationId}")
+    public R<ConversationVo> getConversation(
+        @RequestHeader(HEADER_TIMESTAMP) String timestamp,
+        @RequestHeader(HEADER_REQUEST_ID) String requestId,
+        @RequestHeader(HEADER_PUBLIC_KEY) String publicKey,
+        @RequestHeader(HEADER_SIGN) String sign,
+        @PathVariable String conversationId
+    ) {
+        // 鉴权(无 agentId,跳过智能体权限校验)
+        AuthContext authCtx = authenticate(publicKey, sign, requestId, timestamp, "", null);
+        ConversationVo vo = conversationService.detail(conversationId);
+        return R.ok(vo);
+    }
+
+    /**
+     * 统一鉴权
+     */
+    private AuthContext authenticate(String publicKey, String sign, String requestId, String timestamp, String payload, String agentId) {
+        // 1. 查询项目
+        if (StrUtil.isBlank(publicKey)) {
+            throw new com.emoon.common.core.exception.exception.ServiceException(
+                SystemEnum.INVALID_PUBLIC_KEY.getMessage(), SystemEnum.INVALID_PUBLIC_KEY.getCode());
+        }
+        SysProjectDo project = sysProjectService.queryByPublicKey(publicKey);
+        if (project == null) {
+            throw new com.emoon.common.core.exception.exception.ServiceException(
+                SystemEnum.PROJECT_NOT_EXISTS.getMessage(), SystemEnum.PROJECT_NOT_EXISTS.getCode());
+        }
+
+        // 2. 校验智能体权限
+        if (StrUtil.isNotBlank(agentId)) {
+            List<String> agentIds = project.getAgentIds();
+            if (agentIds == null || !agentIds.contains(agentId)) {
+                throw new com.emoon.common.core.exception.exception.ServiceException(
+                    SystemEnum.PROJECT_NO_AGENT_PERMISSION.getMessage(), SystemEnum.PROJECT_NO_AGENT_PERMISSION.getCode());
+            }
+        }
+
+        // 3. 验证签名
+        if (!SignUtil.verify(requestId, timestamp, payload, sign, project.getPrivateKey())) {
+            throw new com.emoon.common.core.exception.exception.ServiceException(
+                SystemEnum.ILLEGAL_REQUEST.getMessage(), SystemEnum.ILLEGAL_REQUEST.getCode());
+        }
+
+        // SysProjectDo 没有 tenantId 字段,使用默认租户
+        return new AuthContext(project.getId().longValue(), "000000");
+    }
+
+    /**
+     * 鉴权上下文
+     */
+    private record AuthContext(Long projectId, String tenantId) {
+    }
+}

+ 56 - 0
emoon-openplatform/src/main/java/com/emoon/openplatform/domain/dto/request/AgentChatRequest.java

@@ -0,0 +1,56 @@
+package com.emoon.openplatform.domain.dto.request;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 对话请求
+ */
+@Data
+public class AgentChatRequest implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 智能体业务 ID
+     */
+    @NotBlank(message = "agentId 不能为空")
+    private String agentId;
+
+    /**
+     * 用户输入消息
+     */
+    @NotBlank(message = "query 不能为空")
+    private String query;
+
+    /**
+     * 会话 ID(首次对话为空,后续复用)
+     */
+    private String conversationId;
+
+    /**
+     * 是否开启流式响应(默认 false)
+     */
+    private Boolean stream;
+
+    /**
+     * 用户 ID(可选)
+     */
+    private Long userId;
+
+    /**
+     * 业务输入参数(可选)
+     */
+    private Map<String, Object> inputs;
+
+    /**
+     * 文件列表(可选)
+     */
+    private List<String> files;
+}

+ 33 - 0
emoon-openplatform/src/main/java/com/emoon/openplatform/domain/dto/request/ConversationCreateRequest.java

@@ -0,0 +1,33 @@
+package com.emoon.openplatform.domain.dto.request;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * 创建会话请求
+ */
+@Data
+public class ConversationCreateRequest implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 智能体业务 ID
+     */
+    @NotBlank(message = "agentId 不能为空")
+    private String agentId;
+
+    /**
+     * 用户 ID(可选)
+     */
+    private Long userId;
+
+    /**
+     * 会话名称(可选)
+     */
+    private String conversationName;
+}

+ 74 - 0
emoon-openplatform/src/main/java/com/emoon/openplatform/domain/dto/resp/AgentChatResponse.java

@@ -0,0 +1,74 @@
+package com.emoon.openplatform.domain.dto.resp;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * 对话响应(同步和 SSE 共用)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class AgentChatResponse implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * AI 回复片段
+     */
+    private String reply;
+
+    /**
+     * 卡片标识(可选)
+     */
+    private String cardKey;
+
+    /**
+     * 卡片数据 JSON(可选)
+     */
+    private String cardData;
+
+    /**
+     * 会话 ID
+     */
+    private String conversationId;
+
+    /**
+     * 消息 ID
+     */
+    private String messageId;
+
+    /**
+     * Token 统计(仅最终消息有值)
+     */
+    private Usage usage;
+
+    /**
+     * 流式是否结束
+     */
+    private Boolean finished;
+
+    /**
+     * Token 使用统计
+     */
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    public static class Usage implements Serializable {
+
+        @Serial
+        private static final long serialVersionUID = 1L;
+
+        private Integer promptTokens;
+        private Integer completionTokens;
+        private Integer totalTokens;
+    }
+}

+ 63 - 0
emoon-openplatform/src/main/java/com/emoon/openplatform/domain/dto/resp/ConversationVo.java

@@ -0,0 +1,63 @@
+package com.emoon.openplatform.domain.dto.resp;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 会话展示对象
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ConversationVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 会话 ID(对外暴露的 UUID)
+     */
+    private String conversationId;
+
+    /**
+     * 智能体 ID
+     */
+    private Long agentId;
+
+    /**
+     * 会话名称
+     */
+    private String conversationName;
+
+    /**
+     * 会话状态(active/archived/deleted)
+     */
+    private String status;
+
+    /**
+     * 消息数量
+     */
+    private Integer messageCount;
+
+    /**
+     * 累计 Token 消耗
+     */
+    private Integer totalTokens;
+
+    /**
+     * 最后消息时间
+     */
+    private Date lastMessageTime;
+
+    /**
+     * 创建时间
+     */
+    private Date createTime;
+}

+ 21 - 1
emoon-openplatform/src/main/java/com/emoon/openplatform/enums/SystemEnum.java

@@ -68,7 +68,27 @@ public enum SystemEnum {
     /**
      * 该项目不具备该智能体权限
      */
-    PROJECT_NO_AGENT_PERMISSION(11, "该项目不具备该智能体权限,请联系管理员添加!"),;
+    PROJECT_NO_AGENT_PERMISSION(11, "该项目不具备该智能体权限,请联系管理员添加!"),
+
+    /**
+     * 智能体不存在
+     */
+    AGENT_NOT_FOUND(12, "智能体不存在!"),
+
+    /**
+     * 智能体已停用
+     */
+    AGENT_DISABLED(13, "智能体已停用!"),
+
+    /**
+     * 引擎配置不存在
+     */
+    ENGINE_CONFIG_NOT_FOUND(14, "引擎配置不存在!"),
+
+    /**
+     * 会话不存在
+     */
+    CONVERSATION_NOT_FOUND(15, "会话不存在!"),;
 
     private final int code;
     private final String message;

+ 33 - 0
emoon-openplatform/src/main/java/com/emoon/openplatform/service/IAgentChatService.java

@@ -0,0 +1,33 @@
+package com.emoon.openplatform.service;
+
+import com.baomidou.dynamic.datasource.annotation.DS;
+import com.emoon.openplatform.domain.dto.request.AgentChatRequest;
+import com.emoon.openplatform.domain.dto.resp.AgentChatResponse;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+/**
+ * 对话编排接口
+ */
+@DS("admin")
+public interface IAgentChatService {
+
+    /**
+     * 同步对话
+     *
+     * @param request   对话请求
+     * @param projectId 项目 ID
+     * @param tenantId  租户 ID
+     * @return 对话响应
+     */
+    AgentChatResponse chat(AgentChatRequest request, Long projectId, String tenantId);
+
+    /**
+     * 流式对话
+     *
+     * @param request   对话请求
+     * @param projectId 项目 ID
+     * @param tenantId  租户 ID
+     * @param emitter   SSE 发射器
+     */
+    void chatStream(AgentChatRequest request, Long projectId, String tenantId, SseEmitter emitter);
+}

+ 42 - 0
emoon-openplatform/src/main/java/com/emoon/openplatform/service/IConversationService.java

@@ -0,0 +1,42 @@
+package com.emoon.openplatform.service;
+
+import com.baomidou.dynamic.datasource.annotation.DS;
+import com.emoon.openplatform.domain.dto.request.ConversationCreateRequest;
+import com.emoon.openplatform.domain.dto.resp.ConversationVo;
+
+import java.util.List;
+
+/**
+ * 会话管理接口
+ */
+@DS("admin")
+public interface IConversationService {
+
+    /**
+     * 创建会话
+     *
+     * @param request   创建请求
+     * @param projectId 项目 ID
+     * @param tenantId  租户 ID
+     * @return 会话信息
+     */
+    ConversationVo create(ConversationCreateRequest request, Long projectId, String tenantId);
+
+    /**
+     * 查询会话列表
+     *
+     * @param projectId 项目 ID
+     * @param agentId   智能体 ID(可选)
+     * @param userId    用户 ID(可选)
+     * @return 会话列表
+     */
+    List<ConversationVo> list(Long projectId, String agentId, Long userId);
+
+    /**
+     * 查询会话详情
+     *
+     * @param conversationId 会话 ID
+     * @return 会话信息
+     */
+    ConversationVo detail(String conversationId);
+}

+ 281 - 0
emoon-openplatform/src/main/java/com/emoon/openplatform/service/impl/AgentChatServiceImpl.java

@@ -0,0 +1,281 @@
+package com.emoon.openplatform.service.impl;
+
+import cn.hutool.core.util.IdUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.json.JSONUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.emoon.common.core.exception.exception.ServiceException;
+import com.emoon.mcp.domain.AiConversation;
+import com.emoon.mcp.engine.AgentEngine;
+import com.emoon.mcp.engine.AgentEngineFactory;
+import com.emoon.mcp.engine.AgentRequest;
+import com.emoon.mcp.engine.AgentResponse;
+import com.emoon.mcp.mapper.AiConversationMapper;
+import com.emoon.openplatform.domain.dto.request.AgentChatRequest;
+import com.emoon.openplatform.domain.dto.resp.AgentChatResponse;
+import com.emoon.openplatform.enums.SystemEnum;
+import com.emoon.openplatform.service.IAgentChatService;
+import com.emoon.system.domain.AiAgentApp;
+import com.emoon.system.domain.AiAgentEngineConfig;
+import com.emoon.system.domain.AiUsageLog;
+import com.emoon.system.mapper.AiAgentAppMapper;
+import com.emoon.system.mapper.AiAgentEngineConfigMapper;
+import com.emoon.system.mapper.AiUsageLogMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+import java.io.IOException;
+import java.util.Date;
+
+/**
+ * 对话编排实现(核心类)
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AgentChatServiceImpl implements IAgentChatService {
+
+    private final AgentEngineFactory agentEngineFactory;
+    private final AiAgentAppMapper aiAgentAppMapper;
+    private final AiAgentEngineConfigMapper aiAgentEngineConfigMapper;
+    private final AiConversationMapper aiConversationMapper;
+    private final AiUsageLogMapper aiUsageLogMapper;
+
+    @Override
+    public AgentChatResponse chat(AgentChatRequest request, Long projectId, String tenantId) {
+        // 1. 查询智能体配置
+        AgentContext ctx = prepareContext(request, projectId, tenantId);
+
+        // 2. 获取引擎并执行对话
+        AgentEngine engine = agentEngineFactory.getEngine(ctx.engineType());
+        AgentRequest agentRequest = buildAgentRequest(request, ctx);
+        AgentResponse response = engine.chat(agentRequest);
+
+        // 3. 会话管理
+        String conversationId = handleConversation(request, ctx, response, tenantId);
+
+        // 4. 异步写入使用日志
+        asyncLogUsage(ctx, response, tenantId);
+
+        // 5. 转换响应
+        return toResponse(response, conversationId);
+    }
+
+    @Override
+    public void chatStream(AgentChatRequest request, Long projectId, String tenantId, SseEmitter emitter) {
+        try {
+            // 1. 查询智能体配置
+            AgentContext ctx = prepareContext(request, projectId, tenantId);
+
+            // 2. 获取引擎
+            AgentEngine engine = agentEngineFactory.getEngine(ctx.engineType());
+            AgentRequest agentRequest = buildAgentRequest(request, ctx);
+
+            // 会话 ID 用于流式响应
+            final String[] conversationIdHolder = {request.getConversationId()};
+            if (StrUtil.isBlank(conversationIdHolder[0])) {
+                conversationIdHolder[0] = IdUtil.fastSimpleUUID();
+            }
+
+            // 3. 流式对话
+            engine.chatStream(agentRequest, chunk -> {
+                try {
+                    AgentChatResponse resp = toResponse(chunk, conversationIdHolder[0]);
+                    emitter.send(SseEmitter.event().data(JSONUtil.toJsonStr(resp)));
+
+                    // 最后一个 chunk 处理会话和日志
+                    if (Boolean.TRUE.equals(chunk.getFinished())) {
+                        handleConversation(request, ctx, chunk, tenantId);
+                        asyncLogUsage(ctx, chunk, tenantId);
+                    }
+                } catch (IOException e) {
+                    log.warn("SSE 发送失败(客户端可能断连): {}", e.getMessage());
+                }
+            });
+
+            emitter.complete();
+        } catch (Exception e) {
+            log.error("流式对话异常: {}", e.getMessage(), e);
+            emitter.completeWithError(e);
+        }
+    }
+
+    /**
+     * 准备对话上下文
+     */
+    private AgentContext prepareContext(AgentChatRequest request, Long projectId, String tenantId) {
+        // 查询智能体
+        AiAgentApp app = aiAgentAppMapper.selectOne(
+            new LambdaQueryWrapper<AiAgentApp>()
+                .eq(AiAgentApp::getAgentId, request.getAgentId())
+                .eq(AiAgentApp::getDelFlag, "0")
+        );
+        if (app == null) {
+            throw new ServiceException(SystemEnum.AGENT_NOT_FOUND.getMessage(), SystemEnum.AGENT_NOT_FOUND.getCode());
+        }
+        if (!"0".equals(app.getStatus())) {
+            throw new ServiceException(SystemEnum.AGENT_DISABLED.getMessage(), SystemEnum.AGENT_DISABLED.getCode());
+        }
+
+        // 查询引擎配置
+        AiAgentEngineConfig config = aiAgentEngineConfigMapper.selectOne(
+            new LambdaQueryWrapper<AiAgentEngineConfig>()
+                .eq(AiAgentEngineConfig::getId, app.getEngineConfigId())
+                .eq(AiAgentEngineConfig::getDelFlag, "0")
+        );
+        if (config == null) {
+            throw new ServiceException(SystemEnum.ENGINE_CONFIG_NOT_FOUND.getMessage(), SystemEnum.ENGINE_CONFIG_NOT_FOUND.getCode());
+        }
+
+        // 查询现有会话
+        AiConversation conversation = null;
+        if (StrUtil.isNotBlank(request.getConversationId())) {
+            conversation = aiConversationMapper.selectOne(
+                new LambdaQueryWrapper<AiConversation>()
+                    .eq(AiConversation::getConversationId, request.getConversationId())
+            );
+            if (conversation == null) {
+                throw new ServiceException(SystemEnum.CONVERSATION_NOT_FOUND.getMessage(), SystemEnum.CONVERSATION_NOT_FOUND.getCode());
+            }
+        }
+
+        return new AgentContext(app, config, conversation, projectId);
+    }
+
+    /**
+     * 构建 AgentRequest
+     */
+    private AgentRequest buildAgentRequest(AgentChatRequest request, AgentContext ctx) {
+        return AgentRequest.builder()
+            .agentId(request.getAgentId())
+            .conversationId(ctx.conversation != null ? ctx.conversation.getExternalConversationId() : null)
+            .query(request.getQuery())
+            .userId(request.getUserId())
+            .engineConfig(ctx.config.getConfigJson())
+            .inputs(request.getInputs())
+            .files(request.getFiles())
+            .build();
+    }
+
+    /**
+     * 处理会话(新建或更新)
+     */
+    private String handleConversation(AgentChatRequest request, AgentContext ctx, AgentResponse response, String tenantId) {
+        String conversationId = request.getConversationId();
+
+        if (StrUtil.isBlank(conversationId)) {
+            // 新建会话
+            conversationId = IdUtil.fastSimpleUUID();
+            AiConversation conversation = new AiConversation();
+            conversation.setProjectId(ctx.projectId.intValue());
+            conversation.setAgentId(ctx.app.getId());
+            conversation.setConversationId(conversationId);
+            conversation.setConversationName(truncate(request.getQuery(), 50));
+            conversation.setUserId(request.getUserId());
+            conversation.setEngineType(ctx.config.getEngineType());
+            conversation.setExternalConversationId(response.getConversationId());
+            conversation.setStatus("active");
+            conversation.setMessageCount(1);
+            conversation.setTotalTokens(getTotalTokens(response));
+            conversation.setLastMessageTime(new Date());
+            conversation.setTenantId(tenantId);
+            aiConversationMapper.insert(conversation);
+        } else {
+            // 更新会话
+            aiConversationMapper.update(null,
+                new LambdaUpdateWrapper<AiConversation>()
+                    .eq(AiConversation::getConversationId, conversationId)
+                    .setSql("message_count = message_count + 1")
+                    .setSql("total_tokens = total_tokens + " + getTotalTokens(response))
+                    .set(AiConversation::getLastMessageTime, new Date())
+                    .set(AiConversation::getExternalConversationId, response.getConversationId())
+            );
+        }
+
+        return conversationId;
+    }
+
+    /**
+     * 异步写入使用日志
+     */
+    @Async
+    public void asyncLogUsage(AgentContext ctx, AgentResponse response, String tenantId) {
+        try {
+            if (response.getUsage() == null) {
+                return;
+            }
+            AiUsageLog usageLog = new AiUsageLog();
+            usageLog.setAgentId(ctx.app.getId());
+            usageLog.setEngineType(ctx.config.getEngineType());
+            usageLog.setPromptTokens(response.getUsage().getPromptTokens());
+            usageLog.setCompletionTokens(response.getUsage().getCompletionTokens());
+            usageLog.setTotalTokens(response.getUsage().getTotalTokens());
+            usageLog.setTenantId(tenantId);
+            aiUsageLogMapper.insert(usageLog);
+        } catch (Exception e) {
+            log.error("写入使用日志失败: {}", e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 转换为响应对象
+     */
+    private AgentChatResponse toResponse(AgentResponse response, String conversationId) {
+        AgentChatResponse.Usage usage = null;
+        if (response.getUsage() != null) {
+            usage = AgentChatResponse.Usage.builder()
+                .promptTokens(response.getUsage().getPromptTokens())
+                .completionTokens(response.getUsage().getCompletionTokens())
+                .totalTokens(response.getUsage().getTotalTokens())
+                .build();
+        }
+
+        return AgentChatResponse.builder()
+            .reply(response.getReply())
+            .cardKey(response.getCardKey())
+            .cardData(response.getCardData())
+            .conversationId(conversationId)
+            .messageId(response.getMessageId())
+            .usage(usage)
+            .finished(response.getFinished())
+            .build();
+    }
+
+    /**
+     * 获取 token 消耗
+     */
+    private int getTotalTokens(AgentResponse response) {
+        if (response.getUsage() == null || response.getUsage().getTotalTokens() == null) {
+            return 0;
+        }
+        return response.getUsage().getTotalTokens();
+    }
+
+    /**
+     * 截断字符串
+     */
+    private String truncate(String str, int maxLen) {
+        if (StrUtil.isBlank(str)) {
+            return "新会话";
+        }
+        return str.length() > maxLen ? str.substring(0, maxLen) : str;
+    }
+
+    /**
+     * 对话上下文
+     */
+    private record AgentContext(
+        AiAgentApp app,
+        AiAgentEngineConfig config,
+        AiConversation conversation,
+        Long projectId
+    ) {
+        String engineType() {
+            return config.getEngineType();
+        }
+    }
+}

+ 131 - 0
emoon-openplatform/src/main/java/com/emoon/openplatform/service/impl/ConversationServiceImpl.java

@@ -0,0 +1,131 @@
+package com.emoon.openplatform.service.impl;
+
+import cn.hutool.core.util.IdUtil;
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.emoon.common.core.exception.exception.ServiceException;
+import com.emoon.mcp.domain.AiConversation;
+import com.emoon.mcp.mapper.AiConversationMapper;
+import com.emoon.openplatform.domain.dto.request.ConversationCreateRequest;
+import com.emoon.openplatform.domain.dto.resp.ConversationVo;
+import com.emoon.openplatform.enums.SystemEnum;
+import com.emoon.openplatform.service.IConversationService;
+import com.emoon.system.domain.AiAgentApp;
+import com.emoon.system.domain.AiAgentEngineConfig;
+import com.emoon.system.mapper.AiAgentAppMapper;
+import com.emoon.system.mapper.AiAgentEngineConfigMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 会话管理实现
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ConversationServiceImpl implements IConversationService {
+
+    private final AiConversationMapper aiConversationMapper;
+    private final AiAgentAppMapper aiAgentAppMapper;
+    private final AiAgentEngineConfigMapper aiAgentEngineConfigMapper;
+
+    @Override
+    public ConversationVo create(ConversationCreateRequest request, Long projectId, String tenantId) {
+        // 验证智能体存在
+        AiAgentApp app = aiAgentAppMapper.selectOne(
+            new LambdaQueryWrapper<AiAgentApp>()
+                .eq(AiAgentApp::getAgentId, request.getAgentId())
+                .eq(AiAgentApp::getDelFlag, "0")
+        );
+        if (app == null) {
+            throw new ServiceException(SystemEnum.AGENT_NOT_FOUND.getMessage(), SystemEnum.AGENT_NOT_FOUND.getCode());
+        }
+        if (!"0".equals(app.getStatus())) {
+            throw new ServiceException(SystemEnum.AGENT_DISABLED.getMessage(), SystemEnum.AGENT_DISABLED.getCode());
+        }
+
+        // 查询引擎配置获取 engineType
+        AiAgentEngineConfig config = aiAgentEngineConfigMapper.selectById(app.getEngineConfigId());
+        String engineType = config != null ? config.getEngineType() : "mock";
+
+        // 生成 conversationId
+        String conversationId = IdUtil.fastSimpleUUID();
+        String conversationName = StrUtil.isNotBlank(request.getConversationName())
+            ? request.getConversationName()
+            : "新会话";
+
+        // 插入会话记录
+        AiConversation conversation = new AiConversation();
+        conversation.setProjectId(projectId.intValue());
+        conversation.setAgentId(app.getId());
+        conversation.setConversationId(conversationId);
+        conversation.setConversationName(conversationName);
+        conversation.setUserId(request.getUserId());
+        conversation.setEngineType(engineType);
+        conversation.setStatus("active");
+        conversation.setMessageCount(0);
+        conversation.setTotalTokens(0);
+        conversation.setTenantId(tenantId);
+
+        aiConversationMapper.insert(conversation);
+
+        return toVo(conversation);
+    }
+
+    @Override
+    public List<ConversationVo> list(Long projectId, String agentId, Long userId) {
+        LambdaQueryWrapper<AiConversation> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(AiConversation::getProjectId, projectId.intValue());
+        if (StrUtil.isNotBlank(agentId)) {
+            // agentId 是业务 ID,需要先查出 app 的主键
+            AiAgentApp app = aiAgentAppMapper.selectOne(
+                new LambdaQueryWrapper<AiAgentApp>()
+                    .eq(AiAgentApp::getAgentId, agentId)
+                    .eq(AiAgentApp::getDelFlag, "0")
+            );
+            if (app != null) {
+                wrapper.eq(AiConversation::getAgentId, app.getId());
+            }
+        }
+        if (userId != null) {
+            wrapper.eq(AiConversation::getUserId, userId);
+        }
+        wrapper.ne(AiConversation::getStatus, "deleted");
+        wrapper.orderByDesc(AiConversation::getLastMessageTime);
+
+        List<AiConversation> conversations = aiConversationMapper.selectList(wrapper);
+        return conversations.stream().map(this::toVo).collect(Collectors.toList());
+    }
+
+    @Override
+    public ConversationVo detail(String conversationId) {
+        AiConversation conversation = aiConversationMapper.selectOne(
+            new LambdaQueryWrapper<AiConversation>()
+                .eq(AiConversation::getConversationId, conversationId)
+        );
+        if (conversation == null) {
+            throw new ServiceException(SystemEnum.CONVERSATION_NOT_FOUND.getMessage(), SystemEnum.CONVERSATION_NOT_FOUND.getCode());
+        }
+        return toVo(conversation);
+    }
+
+    /**
+     * DO -> VO 转换
+     */
+    private ConversationVo toVo(AiConversation conversation) {
+        return ConversationVo.builder()
+            .conversationId(conversation.getConversationId())
+            .agentId(conversation.getAgentId())
+            .conversationName(conversation.getConversationName())
+            .status(conversation.getStatus())
+            .messageCount(conversation.getMessageCount())
+            .totalTokens(conversation.getTotalTokens())
+            .lastMessageTime(conversation.getLastMessageTime())
+            .createTime(conversation.getCreateTime())
+            .build();
+    }
+}

+ 95 - 0
emoon-openplatform/src/main/java/com/emoon/openplatform/util/SignUtil.java

@@ -0,0 +1,95 @@
+package com.emoon.openplatform.util;
+
+import cn.hutool.core.util.StrUtil;
+import lombok.extern.slf4j.Slf4j;
+
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+
+/**
+ * 签名验证工具类
+ */
+@Slf4j
+public class SignUtil {
+
+    /**
+     * 签名有效期(5 分钟)
+     */
+    private static final long SIGN_EXPIRE_MILLIS = 5 * 60 * 1000L;
+
+    private SignUtil() {
+    }
+
+    /**
+     * 验证签名
+     *
+     * @param requestId  请求 ID
+     * @param timestamp  时间戳(毫秒)
+     * @param payload    请求体
+     * @param sign       签名
+     * @param privateKey 私钥
+     * @return 签名是否有效
+     */
+    public static boolean verify(String requestId, String timestamp, String payload, String sign, String privateKey) {
+        if (StrUtil.isBlank(sign) || StrUtil.isBlank(privateKey)) {
+            return false;
+        }
+        // 验证时间戳
+        if (!verifyTimestamp(timestamp)) {
+            log.warn("签名验证失败:时间戳过期,timestamp={}", timestamp);
+            return false;
+        }
+        // 生成签名并比对
+        String expectedSign = generateSign(requestId, timestamp, payload, privateKey);
+        boolean result = sign.equalsIgnoreCase(expectedSign);
+        if (!result) {
+            log.warn("签名验证失败:签名不匹配,expected={}, actual={}", expectedSign, sign);
+        }
+        return result;
+    }
+
+    /**
+     * 生成签名
+     * MD5(requestId + "&" + timestamp + "&" + payload + "&" + privateKey)
+     */
+    public static String generateSign(String requestId, String timestamp, String payload, String privateKey) {
+        StringBuilder sb = new StringBuilder();
+        sb.append(requestId != null ? requestId : "");
+        sb.append("&");
+        sb.append(timestamp != null ? timestamp : "");
+        sb.append("&");
+        sb.append(payload != null ? payload : "");
+        sb.append("&");
+        sb.append(privateKey != null ? privateKey : "");
+        String signString = sb.toString();
+        try {
+            MessageDigest md = MessageDigest.getInstance("MD5");
+            byte[] md5Bytes = md.digest(signString.getBytes(StandardCharsets.UTF_8));
+            String sign = new BigInteger(1, md5Bytes).toString(16);
+            // 补齐前导零
+            while (sign.length() < 32) {
+                sign = "0" + sign;
+            }
+            return sign.toLowerCase();
+        } catch (Exception e) {
+            throw new RuntimeException("签名生成失败", e);
+        }
+    }
+
+    /**
+     * 验证时间戳是否在有效期内
+     */
+    private static boolean verifyTimestamp(String timestamp) {
+        if (StrUtil.isBlank(timestamp)) {
+            return false;
+        }
+        try {
+            long ts = Long.parseLong(timestamp);
+            long now = System.currentTimeMillis();
+            return Math.abs(now - ts) <= SIGN_EXPIRE_MILLIS;
+        } catch (NumberFormatException e) {
+            return false;
+        }
+    }
+}