feat: 添加会议创建事件和任务调度监听器
- 新增 `MeetingCreatedEvent` 事件类 - 实现 `MeetingTaskDispatchListener` 监听器,处理会议创建后的任务调度 - 更新 `MeetingServiceImpl` 发布会议创建事件 - 新增 `DashboardController` 提供仪表板统计和最近会议接口 - 更新 `SpeakerController` 和 `SpeakerServiceImpl` 支持声纹注册调用外部接口 - 添加测试配置文件 `application-test.yml` - 优化 `WebConfig` 配置上传路径 - 更新前端 API 封装 `dashboard.ts`dev_na
parent
5e4a2aa2d1
commit
eaadc4ee51
|
|
@ -1,5 +1,6 @@
|
||||||
package com.imeeting.config;
|
package com.imeeting.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
@ -7,10 +8,15 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
@Configuration
|
@Configuration
|
||||||
public class WebConfig implements WebMvcConfigurer {
|
public class WebConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
@Value("${app.upload-path}")
|
||||||
|
private String uploadPath;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
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/**")
|
registry.addResourceHandler("/api/static/audio/**")
|
||||||
.addResourceLocations("file:D:/data/imeeting/uploads/audio/");
|
.addResourceLocations(audioPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import com.imeeting.dto.biz.MeetingTranscriptVO;
|
||||||
import com.imeeting.entity.biz.Meeting;
|
import com.imeeting.entity.biz.Meeting;
|
||||||
import com.imeeting.security.LoginUser;
|
import com.imeeting.security.LoginUser;
|
||||||
import com.imeeting.service.biz.MeetingService;
|
import com.imeeting.service.biz.MeetingService;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
@ -24,15 +25,18 @@ import java.util.UUID;
|
||||||
public class MeetingController {
|
public class MeetingController {
|
||||||
|
|
||||||
private final MeetingService meetingService;
|
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.meetingService = meetingService;
|
||||||
|
this.uploadPath = uploadPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/upload")
|
@PostMapping("/upload")
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
public ApiResponse<String> upload(@RequestParam("file") MultipartFile file) throws IOException {
|
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);
|
File dir = new File(uploadDir);
|
||||||
if (!dir.exists()) dir.mkdirs();
|
if (!dir.exists()) dir.mkdirs();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ public class SpeakerController {
|
||||||
|
|
||||||
List<Speaker> list = speakerService.list(new LambdaQueryWrapper<Speaker>()
|
List<Speaker> list = speakerService.list(new LambdaQueryWrapper<Speaker>()
|
||||||
.eq(Speaker::getUserId, loginUser.getUserId())
|
.eq(Speaker::getUserId, loginUser.getUserId())
|
||||||
.orderByDesc(Speaker::getCreatedAt));
|
.orderByDesc(Speaker::getUpdatedAt));
|
||||||
|
|
||||||
List<SpeakerVO> vos = list.stream().map(this::toVO).collect(Collectors.toList());
|
List<SpeakerVO> vos = list.stream().map(this::toVO).collect(Collectors.toList());
|
||||||
return ApiResponse.ok(vos);
|
return ApiResponse.ok(vos);
|
||||||
|
|
@ -71,6 +71,7 @@ public class SpeakerController {
|
||||||
vo.setStatus(speaker.getStatus());
|
vo.setStatus(speaker.getStatus());
|
||||||
vo.setRemark(speaker.getRemark());
|
vo.setRemark(speaker.getRemark());
|
||||||
vo.setCreatedAt(speaker.getCreatedAt());
|
vo.setCreatedAt(speaker.getCreatedAt());
|
||||||
|
vo.setUpdatedAt(speaker.getUpdatedAt());
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.imeeting.dto.biz;
|
package com.imeeting.dto.biz;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
|
@ -13,5 +14,10 @@ public class SpeakerVO {
|
||||||
private Long voiceSize;
|
private Long voiceSize;
|
||||||
private Integer status;
|
private Integer status;
|
||||||
private String remark;
|
private String remark;
|
||||||
|
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,12 +17,14 @@ import com.imeeting.entity.SysUser;
|
||||||
import com.imeeting.mapper.biz.MeetingMapper;
|
import com.imeeting.mapper.biz.MeetingMapper;
|
||||||
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
||||||
import com.imeeting.mapper.SysUserMapper;
|
import com.imeeting.mapper.SysUserMapper;
|
||||||
|
import com.imeeting.event.MeetingCreatedEvent;
|
||||||
import com.imeeting.service.biz.MeetingService;
|
import com.imeeting.service.biz.MeetingService;
|
||||||
import com.imeeting.service.biz.AiModelService;
|
import com.imeeting.service.biz.AiModelService;
|
||||||
import com.imeeting.service.biz.PromptTemplateService;
|
import com.imeeting.service.biz.PromptTemplateService;
|
||||||
import com.imeeting.service.biz.AiTaskService;
|
import com.imeeting.service.biz.AiTaskService;
|
||||||
import com.imeeting.service.biz.HotWordService;
|
import com.imeeting.service.biz.HotWordService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
|
@ -42,6 +44,7 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
|
||||||
private final MeetingTranscriptMapper transcriptMapper;
|
private final MeetingTranscriptMapper transcriptMapper;
|
||||||
private final HotWordService hotWordService;
|
private final HotWordService hotWordService;
|
||||||
private final SysUserMapper sysUserMapper;
|
private final SysUserMapper sysUserMapper;
|
||||||
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
|
@ -75,7 +78,7 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
|
||||||
|
|
||||||
meeting.setStatus(0);
|
meeting.setStatus(0);
|
||||||
this.save(meeting);
|
this.save(meeting);
|
||||||
aiTaskService.dispatchTasks(meeting.getId());
|
eventPublisher.publishEvent(new MeetingCreatedEvent(meeting.getId()));
|
||||||
return toVO(meeting);
|
return toVO(meeting);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,34 @@
|
||||||
package com.imeeting.service.biz.impl;
|
package com.imeeting.service.biz.impl;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
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.SpeakerRegisterDTO;
|
||||||
import com.imeeting.dto.biz.SpeakerVO;
|
import com.imeeting.dto.biz.SpeakerVO;
|
||||||
import com.imeeting.entity.biz.Speaker;
|
import com.imeeting.entity.biz.Speaker;
|
||||||
import com.imeeting.mapper.biz.SpeakerMapper;
|
import com.imeeting.mapper.biz.SpeakerMapper;
|
||||||
|
import com.imeeting.security.LoginUser;
|
||||||
|
import com.imeeting.service.biz.AiModelService;
|
||||||
import com.imeeting.service.biz.SpeakerService;
|
import com.imeeting.service.biz.SpeakerService;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.io.IOException;
|
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.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
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;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
|
|
@ -25,6 +38,23 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
|
||||||
@Value("${app.upload-path}")
|
@Value("${app.upload-path}")
|
||||||
private String uploadPath;
|
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
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public SpeakerVO register(SpeakerRegisterDTO registerDTO) {
|
public SpeakerVO register(SpeakerRegisterDTO registerDTO) {
|
||||||
|
|
@ -60,7 +90,7 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
|
||||||
throw new RuntimeException("Failed to initialize storage");
|
throw new RuntimeException("Failed to initialize storage");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 生成文件名(如果是更新,可以考虑删除旧文件,这里简单起见生成新UUID)
|
// 2. 生成文件名
|
||||||
String fileName = UUID.randomUUID().toString() + extension;
|
String fileName = UUID.randomUUID().toString() + extension;
|
||||||
Path filePath = voiceprintDir.resolve(fileName);
|
Path filePath = voiceprintDir.resolve(fileName);
|
||||||
|
|
||||||
|
|
@ -72,17 +102,91 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 更新实体信息
|
// 3. 更新实体信息
|
||||||
speaker.setName(registerDTO.getName()); // 由 Controller 传入当前登录人姓名
|
speaker.setName(registerDTO.getName());
|
||||||
speaker.setVoicePath("voiceprints/" + fileName);
|
speaker.setVoicePath("voiceprints/" + fileName);
|
||||||
speaker.setVoiceExt(extension.replace(".", ""));
|
speaker.setVoiceExt(extension.replace(".", ""));
|
||||||
speaker.setVoiceSize(file.getSize());
|
speaker.setVoiceSize(file.getSize());
|
||||||
speaker.setStatus(1); // 已保存
|
speaker.setStatus(1); // 已保存
|
||||||
|
speaker.setUpdatedAt(LocalDateTime.now());
|
||||||
this.saveOrUpdate(speaker);
|
this.saveOrUpdate(speaker);
|
||||||
|
|
||||||
|
// 4. 调用外部声纹注册接口
|
||||||
|
callExternalVoiceprintReg(speaker, isNew);
|
||||||
|
|
||||||
return toVO(speaker);
|
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) {
|
private SpeakerVO toVO(Speaker speaker) {
|
||||||
SpeakerVO vo = new SpeakerVO();
|
SpeakerVO vo = new SpeakerVO();
|
||||||
vo.setId(speaker.getId());
|
vo.setId(speaker.getId());
|
||||||
|
|
@ -94,6 +198,7 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
|
||||||
vo.setStatus(speaker.getStatus());
|
vo.setStatus(speaker.getStatus());
|
||||||
vo.setRemark(speaker.getRemark());
|
vo.setRemark(speaker.getRemark());
|
||||||
vo.setCreatedAt(speaker.getCreatedAt());
|
vo.setCreatedAt(speaker.getCreatedAt());
|
||||||
|
vo.setUpdatedAt(speaker.getUpdatedAt());
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -239,7 +239,8 @@ const SpeakerReg: React.FC = () => {
|
||||||
<Tag color="success" style={{ marginBottom: 16, padding: '4px 12px' }}>已完成注册</Tag>
|
<Tag color="success" style={{ marginBottom: 16, padding: '4px 12px' }}>已完成注册</Tag>
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<Text type="secondary" size="small" style={{ display: 'block' }}>最近一次更新:</Text>
|
<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>
|
</div>
|
||||||
<Divider style={{ margin: '12px 0' }} />
|
<Divider style={{ margin: '12px 0' }} />
|
||||||
<audio
|
<audio
|
||||||
|
|
|
||||||
99
init.md
99
init.md
|
|
@ -1,53 +1,64 @@
|
||||||
# 项目初始化说明(init.md)
|
# iMeeting New 项目设计与开发规范 (已固化)
|
||||||
|
|
||||||
## 1. 项目背景
|
## 1. 项目背景与愿景
|
||||||
智能语音识别并总结系统。AI 转录能力通过外部接口调用,不在本项目内实现。
|
高性能智能会议管理系统,核心能力包括:实时语音转写、声纹识别、离线文件分析及 AI 自动纪要总结。
|
||||||
|
系统定位于企业级 SaaS 架构,支持多租户隔离与个性化 AI 配置。
|
||||||
|
|
||||||
## 2. 技术栈
|
## 2. 核心技术栈
|
||||||
- 后端:Java 17 / Spring Boot 3 / Spring Security / MyBatis-Plus
|
- **后端**:Java 17 / Spring Boot 3 / Spring Security / MyBatis-Plus 3.5+
|
||||||
- 认证与登录框架:Spring Security + JWT(无状态,适配前后端分离与 RBAC 权限模型)
|
- **数据库**:PostgreSQL 15+ (内置 `pgvector` 扩展支持)
|
||||||
- 登录要求:图形验证码
|
- **缓存**:Redis (用于 Token 旋转、验证码及系统参数缓存)
|
||||||
- Token 规则:按用户与设备区分 Token;建议以设备码换取 Token(设备码与用户绑定)
|
- **前端**:React 18 / Vite / Ant Design 5.x / React-Markdown
|
||||||
- 验证码策略:登录接口必须先校验图形验证码;验证码有效期 2 分钟;同一会话最多尝试 5 次,超限需重新获取;验证码与会话或设备码绑定;验证码错误返回统一提示且不暴露是否命中用户。
|
- **集成**:外部 ASR/LLM 引擎接口调用 (OpenAI / DeepSeek / 专用 ASR 接口)
|
||||||
- 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
|
|
||||||
|
|
||||||
## 3. 系统模块(一期)
|
## 3. 身份与权限模型 (双层管理员架构)
|
||||||
- 用户管理(User)
|
系统采用三级权限隔离体系,确保租户间安全与个人隐私:
|
||||||
- 权限管理(Role / Permission)
|
- **Platform Admin (平台管理员)**:
|
||||||
- 设备管理(Device)
|
- 标识:`is_platform_admin = true`
|
||||||
- 租户管理:当前不启用,保留扩展能力(数据模型与接口预留 `tenant_id`)
|
- 权限:管理全局租户、开通权限、维护“系统预置”模型与模板。
|
||||||
|
- **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. 系统架构
|
## 4. 数据库设计与数据治理
|
||||||
- 后端:管理端与业务端共用同一服务与 API 命名空间(单体),预留后续多租户与服务拆分扩展点
|
- **强制字段**:所有业务表 (`biz_`) 必须包含 `id`, `tenant_id`, `created_at`, `updated_at`, `is_deleted`。
|
||||||
- 前端:管理端与业务端共用同一应用(单体),预留后续拆分
|
- **配置固化 (Snapshot)**:
|
||||||
- AI 转录:统一抽象为外部 HTTP API(仅调用,不实现)
|
- 会议记录必须固化发起时的 `prompt_content` (Markdown原文) 和 `hot_words` (JSON快照)。
|
||||||
|
- 禁止在业务执行层依赖可变 ID,确保历史记录的可追溯性。
|
||||||
|
- **JSONB 应用**:复杂元数据、转录 Segments、模型参数统一使用 PostgreSQL 的 `JSONB` 类型存储。
|
||||||
|
- **自动映射**:含有 JSONB 字段的实体类必须在 `@TableName` 注解中开启 `autoResultMap = true`。
|
||||||
|
|
||||||
## 5. 数据库说明
|
## 5. 后端异步处理模型 (AI Pipeline)
|
||||||
- 以 `design/db_schema.md` 为核心参考
|
系统采用分阶段异步任务流,状态通过 `status` 字段实时追踪:
|
||||||
- 原文档为 MySQL 设计,需映射为 PostgreSQL 类型
|
- **状态机**:`0:待处理` -> `1:识别中 (ASR)` -> `2:总结中 (LLM)` -> `3:已完成` -> `4:失败`。
|
||||||
- 多租户采用 `tenant_id` 逻辑隔离,当前仅预留字段与索引
|
- **ASR 阶段**:提交任务 -> 状态轮询 -> 结果解析并批量插入 `biz_meeting_transcripts` (结构化存储)。
|
||||||
- 预留向量字段与索引策略,用于后续知识库模块
|
- **LLM 阶段**:基于转录明细拼接文本 -> 注入 Markdown 提示词模板 -> 调用 LLM 生成总结。
|
||||||
|
- **可观测性**:每一次 API 交互必须记录在 `biz_ai_tasks` 日志表中,包含原始 Request/Response。
|
||||||
|
|
||||||
## 6. 基础约定
|
## 6. 前端 UI/UX 规范 (1080p 适配)
|
||||||
- 统一时区:UTC+8
|
- **分辨率标准**:以 **1920x1080** 为基准。核心页面(会议中心、发起会议)必须实现“一屏展示”,禁用全局滚动条。
|
||||||
- 统一时间字段:`created_at`, `updated_at`
|
- **滚动策略**:
|
||||||
- 软删除:`is_deleted`
|
- 外层容器:`height: calc(100vh - 64px); overflow: hidden;`
|
||||||
- 状态字段:`status`(1 启用 / 0 禁用)
|
- 内容区:`flex: 1; overflow-y: auto;` (局部滚动)。
|
||||||
|
- **交互选型**:
|
||||||
|
- 大型配置/编辑器:右侧 `Drawer` (80% 宽度)。
|
||||||
|
- 会议展示:**响应式卡片流** (1080p 下 2行4列)。
|
||||||
|
- Markdown:总结内容必须通过预览模式渲染,支持实时同步。
|
||||||
|
- **实时性**:工作台总览开启 5-10 秒自动轮询,配合 `Steps` 组件展示任务动态。
|
||||||
|
|
||||||
## 7. 目录结构(后端)
|
## 7. 环境与集成约定
|
||||||
- `backend/` Spring Boot 主服务
|
- **资源映射**:本地 `D:/data/imeeting/uploads/` 映射为 Web 路径 `/api/static/`。
|
||||||
- `backend/src/main/java/...`
|
- **外部回调地址**:必须通过 `app.server-base-url` 动态组合,确保内网/公网引擎可正确访问音频文件。
|
||||||
|
- **ASR 发现接口**:固定为 `baseUrl/api/asrconfig`。
|
||||||
|
- **LLM 协议**:默认采用 OpenAI Chat Completion 兼容格式。
|
||||||
|
|
||||||
## 8. 目录结构(前端)
|
## 8. 目录结构约定
|
||||||
- `frontend/` 单体应用(管理端与业务端共存,按路由与权限区分)
|
- `backend/modules/biz`:存放所有核心业务逻辑 (Meeting, Speaker, HotWord, Prompt, AiModel)。
|
||||||
|
- `frontend/src/pages/business`:存放对应的 React 业务组件。
|
||||||
|
- `frontend/src/api/business`:存放独立的 API 封装。
|
||||||
|
|
||||||
## 9. 迭代范围
|
---
|
||||||
- 仅实现用户、权限、设备基础 CRUD
|
**本规范自 2026-03-03 起生效,后续所有业务模块修改必须严格遵循此标准。**
|
||||||
- 租户仅预留模型与扩展点,不提供管理功能
|
|
||||||
- AI 转录接口仅预留调用层,不包含实现
|
|
||||||
- 知识库模块后续再接入
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue