diff --git a/backend/src/main/java/com/imeeting/config/WebConfig.java b/backend/src/main/java/com/imeeting/config/WebConfig.java index a304f05..4e16405 100644 --- a/backend/src/main/java/com/imeeting/config/WebConfig.java +++ b/backend/src/main/java/com/imeeting/config/WebConfig.java @@ -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); } } diff --git a/backend/src/main/java/com/imeeting/controller/biz/DashboardController.java b/backend/src/main/java/com/imeeting/controller/biz/DashboardController.java new file mode 100644 index 0000000..42645f8 --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/biz/DashboardController.java @@ -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> 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> 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)); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java index cd4c653..3696874 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java @@ -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 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(); diff --git a/backend/src/main/java/com/imeeting/controller/biz/SpeakerController.java b/backend/src/main/java/com/imeeting/controller/biz/SpeakerController.java index 455b1e0..d7ca022 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/SpeakerController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/SpeakerController.java @@ -54,7 +54,7 @@ public class SpeakerController { List list = speakerService.list(new LambdaQueryWrapper() .eq(Speaker::getUserId, loginUser.getUserId()) - .orderByDesc(Speaker::getCreatedAt)); + .orderByDesc(Speaker::getUpdatedAt)); List 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; } } diff --git a/backend/src/main/java/com/imeeting/dto/biz/SpeakerVO.java b/backend/src/main/java/com/imeeting/dto/biz/SpeakerVO.java index 5990d0b..8d8c438 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/SpeakerVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/SpeakerVO.java @@ -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; } diff --git a/backend/src/main/java/com/imeeting/event/MeetingCreatedEvent.java b/backend/src/main/java/com/imeeting/event/MeetingCreatedEvent.java new file mode 100644 index 0000000..3da27bb --- /dev/null +++ b/backend/src/main/java/com/imeeting/event/MeetingCreatedEvent.java @@ -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; + } +} diff --git a/backend/src/main/java/com/imeeting/listener/MeetingTaskDispatchListener.java b/backend/src/main/java/com/imeeting/listener/MeetingTaskDispatchListener.java new file mode 100644 index 0000000..217e483 --- /dev/null +++ b/backend/src/main/java/com/imeeting/listener/MeetingTaskDispatchListener.java @@ -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()); + } +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java index 1dc5f45..4aada93 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java @@ -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 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 impl meeting.setStatus(0); this.save(meeting); - aiTaskService.dispatchTasks(meeting.getId()); + eventPublisher.publishEvent(new MeetingCreatedEvent(meeting.getId())); return toVO(meeting); } diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/SpeakerServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/SpeakerServiceImpl.java index ed28196..e963dea 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/SpeakerServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/SpeakerServiceImpl.java @@ -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 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 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 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 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 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 impl vo.setStatus(speaker.getStatus()); vo.setRemark(speaker.getRemark()); vo.setCreatedAt(speaker.getCreatedAt()); + vo.setUpdatedAt(speaker.getUpdatedAt()); return vo; } } diff --git a/backend/src/main/resources/application-test.yml b/backend/src/main/resources/application-test.yml new file mode 100644 index 0000000..3c21ddc --- /dev/null +++ b/backend/src/main/resources/application-test.yml @@ -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 diff --git a/frontend/src/api/business/dashboard.ts b/frontend/src/api/business/dashboard.ts new file mode 100644 index 0000000..12edf8e --- /dev/null +++ b/frontend/src/api/business/dashboard.ts @@ -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( + "/api/biz/dashboard/stats" + ); +}; + +export const getRecentTasks = () => { + return http.get( + "/api/biz/dashboard/recent" + ); +}; diff --git a/frontend/src/pages/business/SpeakerReg.tsx b/frontend/src/pages/business/SpeakerReg.tsx index 4dbbe0b..ad116df 100644 --- a/frontend/src/pages/business/SpeakerReg.tsx +++ b/frontend/src/pages/business/SpeakerReg.tsx @@ -239,7 +239,8 @@ const SpeakerReg: React.FC = () => { 已完成注册
最近一次更新: - {dayjs(existingSpeaker.createdAt).format('YYYY-MM-DD HH:mm')} + {dayjs(existingSpeaker.updatedAt).format('YYYY-MM-DD HH:mm')} +