feat: 添加会议创建事件和任务调度监听器

- 新增 `MeetingCreatedEvent` 事件类
- 实现 `MeetingTaskDispatchListener` 监听器,处理会议创建后的任务调度
- 更新 `MeetingServiceImpl` 发布会议创建事件
- 新增 `DashboardController` 提供仪表板统计和最近会议接口
- 更新 `SpeakerController` 和 `SpeakerServiceImpl` 支持声纹注册调用外部接口
- 添加测试配置文件 `application-test.yml`
- 优化 `WebConfig` 配置上传路径
- 更新前端 API 封装 `dashboard.ts`
dev_na
chenhao 2026-03-03 15:51:17 +08:00
parent 5e4a2aa2d1
commit eaadc4ee51
13 changed files with 336 additions and 54 deletions

View File

@ -1,5 +1,6 @@
package com.imeeting.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@ -7,10 +8,15 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Value("${app.upload-path}")
private String uploadPath;
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// Map /api/static/audio/** to local directory
String base = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
String audioPath = "file:" + base + "audio/";
registry.addResourceHandler("/api/static/audio/**")
.addResourceLocations("file:D:/data/imeeting/uploads/audio/");
.addResourceLocations(audioPath);
}
}

View File

@ -0,0 +1,41 @@
package com.imeeting.controller.biz;
import com.imeeting.common.ApiResponse;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.security.LoginUser;
import com.imeeting.service.biz.MeetingService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/biz/dashboard")
public class DashboardController {
private final MeetingService meetingService;
public DashboardController(MeetingService meetingService) {
this.meetingService = meetingService;
}
@GetMapping("/stats")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Map<String, Object>> getStats() {
LoginUser user = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
boolean isAdmin = Boolean.TRUE.equals(user.getIsPlatformAdmin()) || Boolean.TRUE.equals(user.getIsTenantAdmin());
return ApiResponse.ok(meetingService.getDashboardStats(user.getTenantId(), user.getUserId(), isAdmin));
}
@GetMapping("/recent")
@PreAuthorize("isAuthenticated()")
public ApiResponse<List<MeetingVO>> getRecent() {
LoginUser user = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
boolean isAdmin = Boolean.TRUE.equals(user.getIsPlatformAdmin()) || Boolean.TRUE.equals(user.getIsTenantAdmin());
return ApiResponse.ok(meetingService.getRecentMeetings(user.getTenantId(), user.getUserId(), isAdmin, 10));
}
}

View File

@ -8,6 +8,7 @@ import com.imeeting.dto.biz.MeetingTranscriptVO;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.security.LoginUser;
import com.imeeting.service.biz.MeetingService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
@ -24,15 +25,18 @@ import java.util.UUID;
public class MeetingController {
private final MeetingService meetingService;
private final String uploadPath;
public MeetingController(MeetingService meetingService) {
public MeetingController(MeetingService meetingService, @Value("${app.upload-path}") String uploadPath) {
this.meetingService = meetingService;
this.uploadPath = uploadPath;
}
@PostMapping("/upload")
@PreAuthorize("isAuthenticated()")
public ApiResponse<String> upload(@RequestParam("file") MultipartFile file) throws IOException {
String uploadDir = "D:/data/imeeting/uploads/audio/";
String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
String uploadDir = basePath + "audio/";
File dir = new File(uploadDir);
if (!dir.exists()) dir.mkdirs();

View File

@ -54,7 +54,7 @@ public class SpeakerController {
List<Speaker> list = speakerService.list(new LambdaQueryWrapper<Speaker>()
.eq(Speaker::getUserId, loginUser.getUserId())
.orderByDesc(Speaker::getCreatedAt));
.orderByDesc(Speaker::getUpdatedAt));
List<SpeakerVO> vos = list.stream().map(this::toVO).collect(Collectors.toList());
return ApiResponse.ok(vos);
@ -71,6 +71,7 @@ public class SpeakerController {
vo.setStatus(speaker.getStatus());
vo.setRemark(speaker.getRemark());
vo.setCreatedAt(speaker.getCreatedAt());
vo.setUpdatedAt(speaker.getUpdatedAt());
return vo;
}
}

View File

@ -1,5 +1,6 @@
package com.imeeting.dto.biz;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
@ -13,5 +14,10 @@ public class SpeakerVO {
private Long voiceSize;
private Integer status;
private String remark;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createdAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,13 @@
package com.imeeting.event;
public class MeetingCreatedEvent {
private final Long meetingId;
public MeetingCreatedEvent(Long meetingId) {
this.meetingId = meetingId;
}
public Long getMeetingId() {
return meetingId;
}
}

View File

@ -0,0 +1,20 @@
package com.imeeting.listener;
import com.imeeting.event.MeetingCreatedEvent;
import com.imeeting.service.biz.AiTaskService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
@Component
@RequiredArgsConstructor
public class MeetingTaskDispatchListener {
private final AiTaskService aiTaskService;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onMeetingCreated(MeetingCreatedEvent event) {
aiTaskService.dispatchTasks(event.getMeetingId());
}
}

View File

@ -17,12 +17,14 @@ import com.imeeting.entity.SysUser;
import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.mapper.SysUserMapper;
import com.imeeting.event.MeetingCreatedEvent;
import com.imeeting.service.biz.MeetingService;
import com.imeeting.service.biz.AiModelService;
import com.imeeting.service.biz.PromptTemplateService;
import com.imeeting.service.biz.AiTaskService;
import com.imeeting.service.biz.HotWordService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -42,6 +44,7 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
private final MeetingTranscriptMapper transcriptMapper;
private final HotWordService hotWordService;
private final SysUserMapper sysUserMapper;
private final ApplicationEventPublisher eventPublisher;
@Override
@Transactional(rollbackFor = Exception.class)
@ -75,7 +78,7 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
meeting.setStatus(0);
this.save(meeting);
aiTaskService.dispatchTasks(meeting.getId());
eventPublisher.publishEvent(new MeetingCreatedEvent(meeting.getId()));
return toVO(meeting);
}

View File

@ -1,21 +1,34 @@
package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.dto.biz.SpeakerRegisterDTO;
import com.imeeting.dto.biz.SpeakerVO;
import com.imeeting.entity.biz.Speaker;
import com.imeeting.mapper.biz.SpeakerMapper;
import com.imeeting.security.LoginUser;
import com.imeeting.service.biz.AiModelService;
import com.imeeting.service.biz.SpeakerService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@Slf4j
@ -25,6 +38,23 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
@Value("${app.upload-path}")
private String uploadPath;
@Value("${app.server-base-url}")
private String serverBaseUrl;
@Value("${app.resource-prefix}")
private String resourcePrefix;
private final AiModelService aiModelService;
private final ObjectMapper objectMapper;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
public SpeakerServiceImpl(AiModelService aiModelService, ObjectMapper objectMapper) {
this.aiModelService = aiModelService;
this.objectMapper = objectMapper;
}
@Override
@Transactional(rollbackFor = Exception.class)
public SpeakerVO register(SpeakerRegisterDTO registerDTO) {
@ -60,7 +90,7 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
throw new RuntimeException("Failed to initialize storage");
}
// 2. 生成文件名如果是更新可以考虑删除旧文件这里简单起见生成新UUID
// 2. 生成文件名
String fileName = UUID.randomUUID().toString() + extension;
Path filePath = voiceprintDir.resolve(fileName);
@ -72,17 +102,91 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
}
// 3. 更新实体信息
speaker.setName(registerDTO.getName()); // 由 Controller 传入当前登录人姓名
speaker.setName(registerDTO.getName());
speaker.setVoicePath("voiceprints/" + fileName);
speaker.setVoiceExt(extension.replace(".", ""));
speaker.setVoiceSize(file.getSize());
speaker.setStatus(1); // 已保存
speaker.setUpdatedAt(LocalDateTime.now());
this.saveOrUpdate(speaker);
// 4. 调用外部声纹注册接口
callExternalVoiceprintReg(speaker, isNew);
return toVO(speaker);
}
private void callExternalVoiceprintReg(Speaker speaker, boolean isNew) {
try {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long tenantId = loginUser.getTenantId();
AiModelVO asrModel = aiModelService.getDefaultModel("ASR", tenantId);
if (asrModel == null || asrModel.getBaseUrl() == null) {
log.warn("Default ASR model not configured, skipping external voiceprint registration");
return;
}
String baseUrl = asrModel.getBaseUrl();
String url = baseUrl.endsWith("/") ? baseUrl + "api/speakers" : baseUrl + "/api/speakers";
// 如果是更新,使用 PUT /api/speakers/{name}
if (!isNew) {
url += "/" + speaker.getUserId();
}
Map<String, Object> body = new HashMap<>();
body.put("name", String.valueOf(speaker.getUserId()));
// 拼接完整下载路径: serverBaseUrl + resourcePrefix + voicePath
String fullPath = serverBaseUrl;
if (!fullPath.endsWith("/") && !resourcePrefix.startsWith("/")) {
fullPath += "/";
}
fullPath += resourcePrefix;
if (!fullPath.endsWith("/") && !speaker.getVoicePath().startsWith("/")) {
fullPath += "/";
}
fullPath += speaker.getVoicePath();
body.put("file_path", fullPath);
String jsonBody = objectMapper.writeValueAsString(body);
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json");
if (isNew) {
requestBuilder.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
} else {
requestBuilder.PUT(HttpRequest.BodyPublishers.ofString(jsonBody));
}
if (asrModel.getApiKey() != null && !asrModel.getApiKey().isEmpty()) {
requestBuilder.header("Authorization", "Bearer " + asrModel.getApiKey());
}
log.info("Calling external voiceprint registration: {} with body: {}", url, jsonBody);
HttpResponse<String> response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
log.error("External voiceprint registration failed: status={}, body={}", response.statusCode(), response.body());
// 这里可以根据业务决定是否抛出异常导致事务回滚
// 目前选择仅记录日志
} else {
log.info("External voiceprint registration success for userId: {}", speaker.getUserId());
// 如果需要,可以解析返回结果更新状态
speaker.setStatus(3); // 已注册 (根据之前的定义)
this.updateById(speaker);
}
} catch (Exception e) {
log.error("Call external voiceprint registration error", e);
}
}
private SpeakerVO toVO(Speaker speaker) {
SpeakerVO vo = new SpeakerVO();
vo.setId(speaker.getId());
@ -94,6 +198,7 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
vo.setStatus(speaker.getStatus());
vo.setRemark(speaker.getRemark());
vo.setCreatedAt(speaker.getCreatedAt());
vo.setUpdatedAt(speaker.getUpdatedAt());
return vo;
}
}

View File

@ -0,0 +1,50 @@
server:
port: 8080
spring:
datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://10.100.51.199:5432/imeeting_db}
username: ${SPRING_DATASOURCE_USERNAME:postgres}
password: ${SPRING_DATASOURCE_PASSWORD:postgres}
data:
redis:
host: ${SPRING_DATA_REDIS_HOST:10.100.51.199}
port: ${SPRING_DATA_REDIS_PORT:6379}
password: ${SPRING_DATA_REDIS_PASSWORD:unis@123}
database: ${SPRING_DATA_REDIS_DATABASE:15}
cache:
type: redis
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MB
jackson:
date-format: yyyy-MM-dd HH:mm:ss
serialization:
write-dates-as-timestamps: false
time-zone: GMT+8
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
logic-delete-field: isDeleted
logic-delete-value: 1
logic-not-delete-value: 0
security:
jwt:
secret: ${SECURITY_JWT_SECRET:change-me-please-change-me-32bytes}
app:
server-base-url: ${APP_SERVER_BASE_URL:http://127.0.0.1:8080}
upload-path: ${APP_UPLOAD_PATH:/data/imeeting/uploads/}
resource-prefix: /api/static/
captcha:
ttl-seconds: 120
max-attempts: 5
token:
access-default-minutes: 30
refresh-default-days: 7

View File

@ -0,0 +1,21 @@
import http from "../http";
import { MeetingVO } from "./meeting";
export interface DashboardStats {
totalMeetings: number;
processingTasks: number;
todayNew: number;
successRate: number;
}
export const getDashboardStats = () => {
return http.get<any, { code: string; data: DashboardStats; msg: string }>(
"/api/biz/dashboard/stats"
);
};
export const getRecentTasks = () => {
return http.get<any, { code: string; data: MeetingVO[]; msg: string }>(
"/api/biz/dashboard/recent"
);
};

View File

@ -239,7 +239,8 @@ const SpeakerReg: React.FC = () => {
<Tag color="success" style={{ marginBottom: 16, padding: '4px 12px' }}></Tag>
<div style={{ marginBottom: 16 }}>
<Text type="secondary" size="small" style={{ display: 'block' }}></Text>
<Text strong>{dayjs(existingSpeaker.createdAt).format('YYYY-MM-DD HH:mm')}</Text>
<Text strong>{dayjs(existingSpeaker.updatedAt).format('YYYY-MM-DD HH:mm')}</Text>
</div>
<Divider style={{ margin: '12px 0' }} />
<audio

99
init.md
View File

@ -1,53 +1,64 @@
# 项目初始化说明init.md
# iMeeting New 项目设计与开发规范 (已固化)
## 1. 项目背景
智能语音识别并总结系统。AI 转录能力通过外部接口调用,不在本项目内实现。
## 1. 项目背景与愿景
高性能智能会议管理系统,核心能力包括:实时语音转写、声纹识别、离线文件分析及 AI 自动纪要总结。
系统定位于企业级 SaaS 架构,支持多租户隔离与个性化 AI 配置。
## 2. 技术栈
- 后端Java 17 / Spring Boot 3 / Spring Security / MyBatis-Plus
- 认证与登录框架Spring Security + JWT无状态适配前后端分离与 RBAC 权限模型)
- 登录要求:图形验证码
- Token 规则:按用户与设备区分 Token建议以设备码换取 Token设备码与用户绑定
- 验证码策略:登录接口必须先校验图形验证码;验证码有效期 2 分钟;同一会话最多尝试 5 次,超限需重新获取;验证码与会话或设备码绑定;验证码错误返回统一提示且不暴露是否命中用户。
- Token 失效与刷新策略Access Token 与 Refresh Token 有效期通过系统参数配置Refresh Token 与用户+设备码绑定且可单设备吊销;每次刷新旋转 Refresh Token旧 Token 立即失效;登出或设备解绑时立即吊销该设备所有 Token。
- OAuth2.0 预留:认证与授权模块接口保持可扩展,预留 OAuth2.0 升级路径Token 结构、授权端点与客户端管理兼容扩展)。
- 数据库PostgreSQL 15+(支持向量扩展,建议 `pgvector`
- 缓存Redis必须
- 前端React 18 / Vite / React Router / Zustand / Ant Design
- 构建Maven
## 2. 核心技术栈
- **后端**Java 17 / Spring Boot 3 / Spring Security / MyBatis-Plus 3.5+
- **数据库**PostgreSQL 15+ (内置 `pgvector` 扩展支持)
- **缓存**Redis (用于 Token 旋转、验证码及系统参数缓存)
- **前端**React 18 / Vite / Ant Design 5.x / React-Markdown
- **集成**:外部 ASR/LLM 引擎接口调用 (OpenAI / DeepSeek / 专用 ASR 接口)
## 3. 系统模块(一期)
- 用户管理User
- 权限管理Role / Permission
- 设备管理Device
- 租户管理:当前不启用,保留扩展能力(数据模型与接口预留 `tenant_id`
## 3. 身份与权限模型 (双层管理员架构)
系统采用三级权限隔离体系,确保租户间安全与个人隐私:
- **Platform Admin (平台管理员)**
- 标识:`is_platform_admin = true`
- 权限:管理全局租户、开通权限、维护“系统预置”模型与模板。
- **Tenant Admin (租户管理员)**
- 标识:`sys_tenant_user.is_tenant_admin = true`
- 权限:管理本租户全量数据、维护本租户“公开”资源(如公开热词)。
- **Regular User (普通用户)**
- 权限:仅可见本租户公开资源及“自己创建”的私有资源。
- 数据过滤:`WHERE creator_id = current_user_id OR is_public = 1 OR is_system = 1`。
## 4. 系统架构
- 后端:管理端与业务端共用同一服务与 API 命名空间(单体),预留后续多租户与服务拆分扩展点
- 前端:管理端与业务端共用同一应用(单体),预留后续拆分
- AI 转录:统一抽象为外部 HTTP API仅调用不实现
## 4. 数据库设计与数据治理
- **强制字段**:所有业务表 (`biz_`) 必须包含 `id`, `tenant_id`, `created_at`, `updated_at`, `is_deleted`
- **配置固化 (Snapshot)**
- 会议记录必须固化发起时的 `prompt_content` (Markdown原文) 和 `hot_words` (JSON快照)。
- 禁止在业务执行层依赖可变 ID确保历史记录的可追溯性。
- **JSONB 应用**:复杂元数据、转录 Segments、模型参数统一使用 PostgreSQL 的 `JSONB` 类型存储。
- **自动映射**:含有 JSONB 字段的实体类必须在 `@TableName` 注解中开启 `autoResultMap = true`
## 5. 数据库说明
- 以 `design/db_schema.md` 为核心参考
- 原文档为 MySQL 设计,需映射为 PostgreSQL 类型
- 多租户采用 `tenant_id` 逻辑隔离,当前仅预留字段与索引
- 预留向量字段与索引策略,用于后续知识库模块
## 5. 后端异步处理模型 (AI Pipeline)
系统采用分阶段异步任务流,状态通过 `status` 字段实时追踪:
- **状态机**`0:待处理` -> `1:识别中 (ASR)` -> `2:总结中 (LLM)` -> `3:已完成` -> `4:失败`
- **ASR 阶段**:提交任务 -> 状态轮询 -> 结果解析并批量插入 `biz_meeting_transcripts` (结构化存储)。
- **LLM 阶段**:基于转录明细拼接文本 -> 注入 Markdown 提示词模板 -> 调用 LLM 生成总结。
- **可观测性**:每一次 API 交互必须记录在 `biz_ai_tasks` 日志表中,包含原始 Request/Response。
## 6. 基础约定
- 统一时区UTC+8
- 统一时间字段:`created_at`, `updated_at`
- 软删除:`is_deleted`
- 状态字段:`status`1 启用 / 0 禁用)
## 6. 前端 UI/UX 规范 (1080p 适配)
- **分辨率标准**:以 **1920x1080** 为基准。核心页面(会议中心、发起会议)必须实现“一屏展示”,禁用全局滚动条。
- **滚动策略**
- 外层容器:`height: calc(100vh - 64px); overflow: hidden;`
- 内容区:`flex: 1; overflow-y: auto;` (局部滚动)。
- **交互选型**
- 大型配置/编辑器:右侧 `Drawer` (80% 宽度)。
- 会议展示:**响应式卡片流** (1080p 下 2行4列)。
- Markdown总结内容必须通过预览模式渲染支持实时同步。
- **实时性**:工作台总览开启 5-10 秒自动轮询,配合 `Steps` 组件展示任务动态。
## 7. 目录结构(后端)
- `backend/` Spring Boot 主服务
- `backend/src/main/java/...`
## 7. 环境与集成约定
- **资源映射**:本地 `D:/data/imeeting/uploads/` 映射为 Web 路径 `/api/static/`
- **外部回调地址**:必须通过 `app.server-base-url` 动态组合,确保内网/公网引擎可正确访问音频文件。
- **ASR 发现接口**:固定为 `baseUrl/api/asrconfig`
- **LLM 协议**:默认采用 OpenAI Chat Completion 兼容格式。
## 8. 目录结构(前端)
- `frontend/` 单体应用(管理端与业务端共存,按路由与权限区分)
## 8. 目录结构约定
- `backend/modules/biz`:存放所有核心业务逻辑 (Meeting, Speaker, HotWord, Prompt, AiModel)。
- `frontend/src/pages/business`:存放对应的 React 业务组件。
- `frontend/src/api/business`:存放独立的 API 封装。
## 9. 迭代范围
- 仅实现用户、权限、设备基础 CRUD
- 租户仅预留模型与扩展点,不提供管理功能
- AI 转录接口仅预留调用层,不包含实现
- 知识库模块后续再接入
---
**本规范自 2026-03-03 起生效,后续所有业务模块修改必须严格遵循此标准。**