diff --git a/backend/src/main/java/com/imeeting/config/ApiResponseSuccessCodeAdvice.java b/backend/src/main/java/com/imeeting/config/ApiResponseSuccessCodeAdvice.java new file mode 100644 index 0000000..b496d04 --- /dev/null +++ b/backend/src/main/java/com/imeeting/config/ApiResponseSuccessCodeAdvice.java @@ -0,0 +1,35 @@ +package com.imeeting.config; + +import com.unisbase.common.ApiResponse; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; + +@RestControllerAdvice +public class ApiResponseSuccessCodeAdvice implements ResponseBodyAdvice { + + private static final String LEGACY_SUCCESS_CODE = "0"; + private static final String SUCCESS_CODE = "200"; + + @Override + public boolean supports(MethodParameter returnType, Class> converterType) { + return true; + } + + @Override + public Object beforeBodyWrite(Object body, + MethodParameter returnType, + MediaType selectedContentType, + Class> selectedConverterType, + ServerHttpRequest request, + ServerHttpResponse response) { + if (body instanceof ApiResponse apiResponse && LEGACY_SUCCESS_CODE.equals(apiResponse.getCode())) { + apiResponse.setCode(SUCCESS_CODE); + } + return body; + } +} diff --git a/backend/src/main/java/com/imeeting/config/grpc/GrpcExceptionLoggingInterceptor.java b/backend/src/main/java/com/imeeting/config/grpc/GrpcExceptionLoggingInterceptor.java new file mode 100644 index 0000000..f0d68be --- /dev/null +++ b/backend/src/main/java/com/imeeting/config/grpc/GrpcExceptionLoggingInterceptor.java @@ -0,0 +1,77 @@ +package com.imeeting.config.grpc; + +import io.grpc.ForwardingServerCallListener; +import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.Status; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.concurrent.atomic.AtomicBoolean; + +@Slf4j +@Component +public class GrpcExceptionLoggingInterceptor implements ServerInterceptor { + + @Override + public ServerCall.Listener interceptCall(ServerCall call, + Metadata headers, + ServerCallHandler next) { + String methodName = call.getMethodDescriptor().getFullMethodName(); + AtomicBoolean closed = new AtomicBoolean(false); + ServerCall.Listener delegate; + try { + delegate = next.startCall(call, headers); + } catch (RuntimeException ex) { + log.error("gRPC startCall failed, method={}", methodName, ex); + closeCall(call, closed, ex); + return new ServerCall.Listener<>() { + }; + } + + return new ForwardingServerCallListener.SimpleForwardingServerCallListener<>(delegate) { + @Override + public void onMessage(ReqT message) { + runSafely("onMessage", () -> super.onMessage(message)); + } + + @Override + public void onHalfClose() { + runSafely("onHalfClose", super::onHalfClose); + } + + @Override + public void onCancel() { + runSafely("onCancel", super::onCancel); + } + + @Override + public void onComplete() { + runSafely("onComplete", super::onComplete); + } + + @Override + public void onReady() { + runSafely("onReady", super::onReady); + } + + private void runSafely(String phase, Runnable action) { + try { + action.run(); + } catch (RuntimeException ex) { + log.error("gRPC request handling failed, method={}, phase={}", methodName, phase, ex); + closeCall(call, closed, ex); + } + } + }; + } + + private void closeCall(ServerCall call, AtomicBoolean closed, RuntimeException ex) { + if (!closed.compareAndSet(false, true)) { + return; + } + call.close(Status.UNKNOWN.withDescription("Application error processing RPC").withCause(ex), new Metadata()); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyLlmModelController.java b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyLlmModelController.java new file mode 100644 index 0000000..a19011f --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyLlmModelController.java @@ -0,0 +1,51 @@ +package com.imeeting.controller.android.legacy; + +import com.imeeting.dto.android.legacy.LegacyApiResponse; +import com.imeeting.dto.android.legacy.LegacyLlmModelItemResponse; +import com.imeeting.dto.biz.AiModelVO; +import com.imeeting.service.biz.AiModelService; +import com.unisbase.dto.PageResult; +import com.unisbase.security.LoginUser; +import lombok.RequiredArgsConstructor; +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.Objects; + +@RestController +@RequestMapping("/api/llm-models") +@RequiredArgsConstructor +public class LegacyLlmModelController { + + private final AiModelService aiModelService; + + @GetMapping("/active") + @PreAuthorize("isAuthenticated()") + public LegacyApiResponse> activeModels() { + LoginUser loginUser = currentLoginUser(); + PageResult> result = aiModelService.pageModels(1, 1000, null, "LLM", loginUser.getTenantId()); + List enabledModels = result.getRecords() == null + ? List.of() + : result.getRecords().stream() + .filter(item -> Integer.valueOf(1).equals(item.getStatus())) + .toList(); + boolean hasExplicitDefault = enabledModels.stream().anyMatch(item -> Integer.valueOf(1).equals(item.getIsDefault())); + Long fallbackDefaultId = enabledModels.isEmpty() ? null : enabledModels.get(0).getId(); + List models = enabledModels.stream() + .map(item -> LegacyLlmModelItemResponse.from( + item, + Integer.valueOf(1).equals(item.getIsDefault()) + || (!hasExplicitDefault && Objects.equals(item.getId(), fallbackDefaultId)) + )) + .toList(); + return LegacyApiResponse.ok(models); + } + + private LoginUser currentLoginUser() { + return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyMeetingController.java b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyMeetingController.java new file mode 100644 index 0000000..8679465 --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyMeetingController.java @@ -0,0 +1,115 @@ +package com.imeeting.controller.android.legacy; + +import com.imeeting.dto.android.legacy.LegacyApiResponse; +import com.imeeting.dto.android.legacy.LegacyMeetingCreateRequest; +import com.imeeting.dto.android.legacy.LegacyMeetingCreateResponse; +import com.imeeting.dto.android.legacy.LegacyMeetingItemResponse; +import com.imeeting.dto.android.legacy.LegacyMeetingListResponse; +import com.imeeting.dto.biz.MeetingVO; +import com.imeeting.entity.biz.Meeting; +import com.imeeting.service.android.legacy.LegacyMeetingAdapterService; +import com.imeeting.service.biz.MeetingAccessService; +import com.imeeting.service.biz.MeetingCommandService; +import com.imeeting.service.biz.MeetingQueryService; +import com.unisbase.dto.PageResult; +import com.unisbase.security.LoginUser; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; + +@RestController +@RequestMapping("/api/meetings") +@RequiredArgsConstructor +public class LegacyMeetingController { + + private final LegacyMeetingAdapterService legacyMeetingAdapterService; + private final MeetingQueryService meetingQueryService; + private final MeetingAccessService meetingAccessService; + private final MeetingCommandService meetingCommandService; + + @PostMapping + @PreAuthorize("isAuthenticated()") + public LegacyApiResponse create(@RequestBody LegacyMeetingCreateRequest request) { + MeetingVO meeting = legacyMeetingAdapterService.createMeeting(request, currentLoginUser()); + return LegacyApiResponse.ok(new LegacyMeetingCreateResponse(meeting.getId())); + } + + @PostMapping("/upload-audio") + @PreAuthorize("isAuthenticated()") + public LegacyApiResponse uploadAudio(@RequestParam("meeting_id") Long meetingId, + @RequestParam(value = "prompt_id", required = false) Long promptId, + @RequestParam(value = "model_code", required = false) String modelCode, + @RequestParam(value = "force_replace", defaultValue = "false") boolean forceReplace, + @RequestParam("audio_file") MultipartFile audioFile) throws IOException { + legacyMeetingAdapterService.uploadAndTriggerOfflineProcess( + meetingId, + promptId, + modelCode, + forceReplace, + audioFile, + currentLoginUser() + ); + return LegacyApiResponse.ok("上传成功", null); + } + + @GetMapping + @PreAuthorize("isAuthenticated()") + public LegacyApiResponse list(@RequestParam(value = "user_id", required = false) Long ignoredUserId, + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(value = "page_size", defaultValue = "10") Integer pageSize, + @RequestParam(required = false) String title) { + LoginUser loginUser = currentLoginUser(); + boolean isAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin()); + PageResult> result = meetingQueryService.pageMeetings( + page, + pageSize, + title, + loginUser.getTenantId(), + loginUser.getUserId(), + resolveCreatorName(loginUser), + "all", + isAdmin + ); + + LegacyMeetingListResponse data = new LegacyMeetingListResponse(); + data.setPage(page); + data.setPageSize(pageSize); + data.setTotal(result.getTotal()); + data.setTotalPages(pageSize == null || pageSize <= 0 ? 0 : (result.getTotal() + pageSize - 1) / pageSize); + data.setHasMore(page != null && page < data.getTotalPages()); + data.setMeetings(result.getRecords() == null + ? List.of() + : result.getRecords().stream().map(LegacyMeetingItemResponse::from).toList()); + return LegacyApiResponse.ok(data); + } + + @DeleteMapping("/{meetingId}") + @PreAuthorize("isAuthenticated()") + public LegacyApiResponse delete(@PathVariable Long meetingId) { + LoginUser loginUser = currentLoginUser(); + Meeting meeting = meetingAccessService.requireMeeting(meetingId); + meetingAccessService.assertCanEditMeeting(meeting, loginUser); + meetingCommandService.deleteMeeting(meetingId); + return LegacyApiResponse.ok("删除成功", null); + } + + private LoginUser currentLoginUser() { + return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + } + + private String resolveCreatorName(LoginUser loginUser) { + return loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername(); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyPromptController.java b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyPromptController.java new file mode 100644 index 0000000..a5662e9 --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyPromptController.java @@ -0,0 +1,63 @@ +package com.imeeting.controller.android.legacy; + +import com.imeeting.dto.android.legacy.LegacyApiResponse; +import com.imeeting.dto.android.legacy.LegacyPromptItemResponse; +import com.imeeting.dto.android.legacy.LegacyPromptListResponse; +import com.imeeting.dto.biz.PromptTemplateVO; +import com.imeeting.service.biz.PromptTemplateService; +import com.unisbase.dto.PageResult; +import com.unisbase.security.LoginUser; +import lombok.RequiredArgsConstructor; +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.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Objects; + +@RestController +@RequestMapping("/api/prompts") +@RequiredArgsConstructor +public class LegacyPromptController { + + private static final String LEGACY_MEETING_SCENE = "MEETING_TASK"; + + private final PromptTemplateService promptTemplateService; + + @GetMapping("/active/{scene}") + @PreAuthorize("isAuthenticated()") + public LegacyApiResponse activePrompts(@PathVariable String scene) { + if (!LEGACY_MEETING_SCENE.equals(scene)) { + return LegacyApiResponse.error("400", "scene only supports MEETING_TASK"); + } + + LoginUser loginUser = currentLoginUser(); + PageResult> result = promptTemplateService.pageTemplates( + 1, + 1000, + null, + null, + loginUser.getTenantId(), + loginUser.getUserId(), + loginUser.getIsPlatformAdmin(), + loginUser.getIsTenantAdmin() + ); + List enabledTemplates = result.getRecords() == null + ? List.of() + : result.getRecords().stream() + .filter(item -> Integer.valueOf(1).equals(item.getStatus())) + .toList(); + Long defaultTemplateId = enabledTemplates.isEmpty() ? null : enabledTemplates.get(0).getId(); + List prompts = enabledTemplates.stream() + .map(item -> LegacyPromptItemResponse.from(item, Objects.equals(item.getId(), defaultTemplateId))) + .toList(); + return LegacyApiResponse.ok(new LegacyPromptListResponse(prompts)); + } + + private LoginUser currentLoginUser() { + return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + } +} diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyApiResponse.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyApiResponse.java new file mode 100644 index 0000000..27bd78e --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyApiResponse.java @@ -0,0 +1,26 @@ +package com.imeeting.dto.android.legacy; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LegacyApiResponse { + private String code; + private String message; + private T data; + + public static LegacyApiResponse ok(T data) { + return new LegacyApiResponse<>("200", "success", data); + } + + public static LegacyApiResponse ok(String message, T data) { + return new LegacyApiResponse<>("200", message, data); + } + + public static LegacyApiResponse error(String code, String message) { + return new LegacyApiResponse<>(code, message, null); + } +} diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyLlmModelItemResponse.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyLlmModelItemResponse.java new file mode 100644 index 0000000..06d6cdd --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyLlmModelItemResponse.java @@ -0,0 +1,28 @@ +package com.imeeting.dto.android.legacy; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.imeeting.dto.biz.AiModelVO; +import lombok.Data; + +@Data +public class LegacyLlmModelItemResponse { + @JsonProperty("model_code") + private String modelCode; + + @JsonProperty("model_name") + private String modelName; + + private String provider; + + @JsonProperty("is_default") + private Integer isDefault; + + public static LegacyLlmModelItemResponse from(AiModelVO source, boolean defaultItem) { + LegacyLlmModelItemResponse response = new LegacyLlmModelItemResponse(); + response.setModelCode(source.getModelCode()); + response.setModelName(source.getModelName()); + response.setProvider(source.getProvider()); + response.setIsDefault(defaultItem ? 1 : 0); + return response; + } +} diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingCreateRequest.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingCreateRequest.java new file mode 100644 index 0000000..002d16b --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingCreateRequest.java @@ -0,0 +1,22 @@ +package com.imeeting.dto.android.legacy; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +@Data +public class LegacyMeetingCreateRequest { + @JsonProperty("user_id") + private Long userId; + + private String title; + + @JsonProperty("meeting_time") + private String meetingTime; + + private Object tags; + + @JsonProperty("attendee_ids") + private List attendeeIds; +} diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingCreateResponse.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingCreateResponse.java new file mode 100644 index 0000000..4611c59 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingCreateResponse.java @@ -0,0 +1,14 @@ +package com.imeeting.dto.android.legacy; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LegacyMeetingCreateResponse { + @JsonProperty("meeting_id") + private Long meetingId; +} diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingItemResponse.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingItemResponse.java new file mode 100644 index 0000000..0e53c7d --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingItemResponse.java @@ -0,0 +1,24 @@ +package com.imeeting.dto.android.legacy; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.imeeting.dto.biz.MeetingVO; +import lombok.Data; + +@Data +public class LegacyMeetingItemResponse { + @JsonProperty("meeting_id") + private Long meetingId; + + private String title; + + @JsonProperty("meeting_time") + private String meetingTime; + + public static LegacyMeetingItemResponse from(MeetingVO source) { + LegacyMeetingItemResponse response = new LegacyMeetingItemResponse(); + response.setMeetingId(source.getId()); + response.setTitle(source.getTitle()); + response.setMeetingTime(source.getMeetingTime() == null ? null : source.getMeetingTime().toString()); + return response; + } +} diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingListResponse.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingListResponse.java new file mode 100644 index 0000000..16fae13 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingListResponse.java @@ -0,0 +1,22 @@ +package com.imeeting.dto.android.legacy; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +@Data +public class LegacyMeetingListResponse { + private List meetings; + private long total; + private int page; + + @JsonProperty("page_size") + private int pageSize; + + @JsonProperty("total_pages") + private long totalPages; + + @JsonProperty("has_more") + private boolean hasMore; +} diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyPromptItemResponse.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyPromptItemResponse.java new file mode 100644 index 0000000..0fc0782 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyPromptItemResponse.java @@ -0,0 +1,22 @@ +package com.imeeting.dto.android.legacy; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.imeeting.dto.biz.PromptTemplateVO; +import lombok.Data; + +@Data +public class LegacyPromptItemResponse { + private Long id; + private String name; + + @JsonProperty("is_default") + private Integer isDefault; + + public static LegacyPromptItemResponse from(PromptTemplateVO source, boolean defaultItem) { + LegacyPromptItemResponse response = new LegacyPromptItemResponse(); + response.setId(source.getId()); + response.setName(source.getTemplateName()); + response.setIsDefault(defaultItem ? 1 : 0); + return response; + } +} diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyPromptListResponse.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyPromptListResponse.java new file mode 100644 index 0000000..72d3ae2 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyPromptListResponse.java @@ -0,0 +1,14 @@ +package com.imeeting.dto.android.legacy; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LegacyPromptListResponse { + private List prompts; +} diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyUploadAudioResponse.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyUploadAudioResponse.java new file mode 100644 index 0000000..fe496b9 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyUploadAudioResponse.java @@ -0,0 +1,17 @@ +package com.imeeting.dto.android.legacy; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LegacyUploadAudioResponse { + @JsonProperty("meeting_id") + private Long meetingId; + + @JsonProperty("audio_url") + private String audioUrl; +} diff --git a/backend/src/main/java/com/imeeting/service/android/legacy/LegacyMeetingAdapterService.java b/backend/src/main/java/com/imeeting/service/android/legacy/LegacyMeetingAdapterService.java new file mode 100644 index 0000000..b386034 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/android/legacy/LegacyMeetingAdapterService.java @@ -0,0 +1,20 @@ +package com.imeeting.service.android.legacy; + +import com.imeeting.dto.android.legacy.LegacyMeetingCreateRequest; +import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse; +import com.imeeting.dto.biz.MeetingVO; +import com.unisbase.security.LoginUser; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +public interface LegacyMeetingAdapterService { + MeetingVO createMeeting(LegacyMeetingCreateRequest request, LoginUser loginUser); + + LegacyUploadAudioResponse uploadAndTriggerOfflineProcess(Long meetingId, + Long promptId, + String modelCode, + boolean forceReplace, + MultipartFile audioFile, + LoginUser loginUser) throws IOException; +} diff --git a/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java new file mode 100644 index 0000000..f550553 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java @@ -0,0 +1,329 @@ +package com.imeeting.service.android.legacy.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.imeeting.dto.android.legacy.LegacyMeetingCreateRequest; +import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse; +import com.imeeting.dto.biz.MeetingVO; +import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile; +import com.imeeting.entity.biz.AiTask; +import com.imeeting.entity.biz.LlmModel; +import com.imeeting.entity.biz.Meeting; +import com.imeeting.entity.biz.MeetingTranscript; +import com.imeeting.entity.biz.PromptTemplate; +import com.imeeting.mapper.biz.LlmModelMapper; +import com.imeeting.mapper.biz.MeetingTranscriptMapper; +import com.imeeting.service.android.legacy.LegacyMeetingAdapterService; +import com.imeeting.service.biz.AiTaskService; +import com.imeeting.service.biz.MeetingAccessService; +import com.imeeting.service.biz.MeetingRuntimeProfileResolver; +import com.imeeting.service.biz.MeetingService; +import com.imeeting.service.biz.PromptTemplateService; +import com.imeeting.service.biz.impl.MeetingDomainSupport; +import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService; +import com.unisbase.security.LoginUser; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterService { + + private final MeetingService meetingService; + private final MeetingAccessService meetingAccessService; + private final MeetingDomainSupport meetingDomainSupport; + private final MeetingRuntimeProfileResolver runtimeProfileResolver; + private final PromptTemplateService promptTemplateService; + private final AiTaskService aiTaskService; + private final MeetingTranscriptMapper transcriptMapper; + private final LlmModelMapper llmModelMapper; + + @Value("${unisbase.app.upload-path}") + private String uploadPath; + + @Value("${unisbase.app.resource-prefix:/api/static/}") + private String resourcePrefix; + + @Override + @Transactional(rollbackFor = Exception.class) + public MeetingVO createMeeting(LegacyMeetingCreateRequest request, LoginUser loginUser) { + if (request == null || request.getTitle() == null || request.getTitle().isBlank()) { + throw new RuntimeException("会议标题不能为空"); + } + LocalDateTime meetingTime = parseMeetingTime(request.getMeetingTime()); + if (meetingTime == null) { + throw new RuntimeException("会议时间不能为空"); + } + + Meeting meeting = meetingDomainSupport.initMeeting( + request.getTitle().trim(), + meetingTime, + joinIds(request.getAttendeeIds()), + normalizeTags(request.getTags()), + null, + loginUser.getTenantId(), + loginUser.getUserId(), + resolveCreatorName(loginUser), + loginUser.getUserId(), + resolveCreatorName(loginUser), + 0 + ); + meetingService.save(meeting); + + MeetingVO vo = new MeetingVO(); + meetingDomainSupport.fillMeetingVO(meeting, vo, false); + return vo; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public LegacyUploadAudioResponse uploadAndTriggerOfflineProcess(Long meetingId, + Long promptId, + String modelCode, + boolean forceReplace, + MultipartFile audioFile, + LoginUser loginUser) throws IOException { + if (meetingId == null) { + throw new RuntimeException("meeting_id 不能为空"); + } + if (audioFile == null || audioFile.isEmpty()) { + throw new RuntimeException("audio_file 不能为空"); + } + + Meeting meeting = meetingAccessService.requireMeeting(meetingId); + meetingAccessService.assertCanEditMeeting(meeting, loginUser); + + if (!forceReplace && meeting.getAudioUrl() != null && !meeting.getAudioUrl().isBlank()) { + throw new RuntimeException("会议已有音频,如需替换请设置 force_replace=true"); + } + long transcriptCount = transcriptMapper.selectCount(new LambdaQueryWrapper() + .eq(MeetingTranscript::getMeetingId, meetingId)); + if (transcriptCount > 0) { + throw new RuntimeException("当前会议已有转录内容,旧安卓接口暂不支持替换已生成转录"); + } + if (promptId != null && !promptTemplateService.isTemplateEnabledForUser( + promptId, + loginUser.getTenantId(), + loginUser.getUserId(), + loginUser.getIsPlatformAdmin(), + loginUser.getIsTenantAdmin())) { + throw new RuntimeException("Summary template unavailable"); + } + + RealtimeMeetingRuntimeProfile profile = runtimeProfileResolver.resolve( + loginUser.getTenantId(), + null, + resolveSummaryModelId(modelCode, loginUser.getTenantId()), + promptId, + null, + null, + null, + null, + null, + null, + null, + List.of() + ); + + String stagingUrl = storeStagingAudio(audioFile); + String relocatedUrl = meetingDomainSupport.relocateAudioUrl(meetingId, stagingUrl); + meeting.setAudioUrl(relocatedUrl); + meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS); + meeting.setAudioSaveMessage(null); + meeting.setStatus(1); + meetingService.updateById(meeting); + + resetOrCreateAsrTask(meetingId, profile); + resetOrCreateSummaryTask(meetingId, profile); + dispatchTasksAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId()); + + return new LegacyUploadAudioResponse(meetingId, relocatedUrl); + } + + private String joinIds(List ids) { + if (ids == null || ids.isEmpty()) { + return ""; + } + return ids.stream() + .filter(Objects::nonNull) + .map(String::valueOf) + .collect(Collectors.joining(",")); + } + + private LocalDateTime parseMeetingTime(String rawValue) { + if (rawValue == null || rawValue.isBlank()) { + return null; + } + String value = rawValue.trim(); + try { + return OffsetDateTime.parse(value).toLocalDateTime(); + } catch (DateTimeParseException ignored) { + // 旧安卓可能发送带时区 ISO,也可能发送当前后端本地时间格式。 + } + try { + return LocalDateTime.parse(value); + } catch (DateTimeParseException ignored) { + // 继续兼容 yyyy-MM-dd HH:mm:ss。 + } + try { + return LocalDateTime.parse(value, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + } catch (DateTimeParseException ex) { + throw new RuntimeException("会议时间格式不正确"); + } + } + + private String normalizeTags(Object rawTags) { + if (rawTags == null) { + return null; + } + if (rawTags instanceof Iterable items) { + return joinValues(items); + } + return String.valueOf(rawTags).trim(); + } + + private String joinValues(Iterable items) { + StringBuilder builder = new StringBuilder(); + for (Object item : items) { + if (item == null) { + continue; + } + String value = String.valueOf(item).trim(); + if (value.isEmpty()) { + continue; + } + if (builder.length() > 0) { + builder.append(','); + } + builder.append(value); + } + return builder.toString(); + } + + private Long resolveSummaryModelId(String modelCode, Long tenantId) { + if (modelCode == null || modelCode.isBlank()) { + return null; + } + LlmModel model = llmModelMapper.selectOne(new LambdaQueryWrapper() + .eq(LlmModel::getModelCode, modelCode.trim()) + .eq(LlmModel::getStatus, 1) + .and(wrapper -> wrapper.eq(LlmModel::getTenantId, tenantId).or().eq(LlmModel::getTenantId, 0L)) + .orderByDesc(LlmModel::getTenantId) + .last("LIMIT 1")); + if (model == null) { + throw new RuntimeException("LLM 模型不存在或未启用: " + modelCode); + } + return model.getId(); + } + + private String storeStagingAudio(MultipartFile audioFile) throws IOException { + String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/"; + Path uploadDir = Paths.get(basePath, "audio"); + Files.createDirectories(uploadDir); + + String originalName = sanitizeFileName(audioFile.getOriginalFilename()); + Path target = uploadDir.resolve(UUID.randomUUID() + "_" + originalName); + Files.copy(audioFile.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING); + + String baseResourcePrefix = resourcePrefix.endsWith("/") ? resourcePrefix : resourcePrefix + "/"; + return baseResourcePrefix + "audio/" + target.getFileName(); + } + + private String sanitizeFileName(String fileName) { + String value = fileName == null || fileName.isBlank() ? "audio" : fileName; + value = value.replace('\\', '/'); + int slashIndex = value.lastIndexOf('/'); + if (slashIndex >= 0) { + value = value.substring(slashIndex + 1); + } + value = value.replaceAll("[^A-Za-z0-9._-]", "_"); + return value.isBlank() ? "audio" : value; + } + + private void resetOrCreateAsrTask(Long meetingId, RealtimeMeetingRuntimeProfile profile) { + AiTask task = findLatestTask(meetingId, "ASR"); + Map taskConfig = new HashMap<>(); + taskConfig.put("asrModelId", profile.getResolvedAsrModelId()); + taskConfig.put("useSpkId", profile.getResolvedUseSpkId()); + taskConfig.put("enableTextRefine", profile.getResolvedEnableTextRefine()); + taskConfig.put("hotWords", profile.getResolvedHotWords()); + resetOrCreateTask(task, meetingId, "ASR", taskConfig); + } + + private void resetOrCreateSummaryTask(Long meetingId, RealtimeMeetingRuntimeProfile profile) { + AiTask task = findLatestTask(meetingId, "SUMMARY"); + Map taskConfig = new HashMap<>(); + taskConfig.put("summaryModelId", profile.getResolvedSummaryModelId()); + PromptTemplate template = promptTemplateService.getById(profile.getResolvedPromptId()); + if (template != null) { + taskConfig.put("promptContent", template.getPromptContent()); + } + resetOrCreateTask(task, meetingId, "SUMMARY", taskConfig); + } + + private AiTask findLatestTask(Long meetingId, String taskType) { + return aiTaskService.getOne(new LambdaQueryWrapper() + .eq(AiTask::getMeetingId, meetingId) + .eq(AiTask::getTaskType, taskType) + .orderByDesc(AiTask::getId) + .last("LIMIT 1")); + } + + private void resetOrCreateTask(AiTask task, Long meetingId, String taskType, Map taskConfig) { + if (task == null) { + task = new AiTask(); + task.setMeetingId(meetingId); + task.setTaskType(taskType); + } + task.setStatus(0); + task.setTaskConfig(taskConfig); + task.setRequestData(null); + task.setResponseData(null); + task.setResultFilePath(null); + task.setErrorMsg(null); + task.setStartedAt(null); + task.setCompletedAt(null); + if (task.getId() == null) { + aiTaskService.save(task); + } else { + aiTaskService.updateById(task); + } + } + + private void dispatchTasksAfterCommit(Long meetingId, Long tenantId, Long userId) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + aiTaskService.dispatchTasks(meetingId, tenantId, userId); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + aiTaskService.dispatchTasks(meetingId, tenantId, userId); + } + }); + } + + private String resolveCreatorName(LoginUser loginUser) { + return loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername(); + } +} diff --git a/backend/src/main/java/com/imeeting/support/TaskSecurityContextRunner.java b/backend/src/main/java/com/imeeting/support/TaskSecurityContextRunner.java new file mode 100644 index 0000000..b237017 --- /dev/null +++ b/backend/src/main/java/com/imeeting/support/TaskSecurityContextRunner.java @@ -0,0 +1,68 @@ +package com.imeeting.support; + +import com.unisbase.security.LoginUser; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.function.Supplier; + +@Component +public class TaskSecurityContextRunner { + + public void runAsTenantUser(Long tenantId, Long userId, Runnable action) { + runWithUser(buildTenantUser(tenantId, userId), () -> { + action.run(); + return null; + }); + } + + public T callAsTenantUser(Long tenantId, Long userId, Supplier supplier) { + return runWithUser(buildTenantUser(tenantId, userId), supplier); + } + + public T callAsPlatformAdmin(Supplier supplier) { + return runWithUser(buildPlatformAdmin(), supplier); + } + + private T runWithUser(LoginUser loginUser, Supplier supplier) { + SecurityContext previousContext = SecurityContextHolder.getContext(); + try { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities())); + SecurityContextHolder.setContext(context); + return supplier.get(); + } finally { + SecurityContextHolder.clearContext(); + SecurityContextHolder.setContext(previousContext); + } + } + + private LoginUser buildTenantUser(Long tenantId, Long userId) { + LoginUser loginUser = new LoginUser( + userId, + tenantId, + userId == null ? "async-user" : "async-user-" + userId, + false, + false, + Collections.emptySet() + ); + loginUser.setDisplayName(loginUser.getUsername()); + return loginUser; + } + + private LoginUser buildPlatformAdmin() { + LoginUser loginUser = new LoginUser( + 0L, + 0L, + "async-platform-admin", + true, + false, + Collections.emptySet() + ); + loginUser.setDisplayName("async-platform-admin"); + return loginUser; + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index f59e705..9c7a3fa 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -50,7 +50,6 @@ unisbase: - /actuator/health - /api/static/** - /ws/** - - /api/android/** internal-auth: enabled: true header-name: X-Internal-Secret diff --git a/backend/src/main/resources/logback-spring.xml b/backend/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..06eb866 --- /dev/null +++ b/backend/src/main/resources/logback-spring.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + ${CONSOLE_LOG_PATTERN} + UTF-8 + + + + + ${APP_LOG_PATH}/${APP_NAME}.log + + ${FILE_LOG_PATTERN} + UTF-8 + + + ${APP_LOG_PATH}/${APP_NAME}.%d{yyyy-MM-dd}.%i.log + ${LOG_MAX_FILE_SIZE:-100MB} + ${LOG_MAX_HISTORY:-30} + ${LOG_TOTAL_SIZE_CAP:-3GB} + + + + + + + + + + + + + + + + + diff --git a/frontend/src/layouts/AppLayout.tsx b/frontend/src/layouts/AppLayout.tsx index 9f520e1..26cebfb 100644 --- a/frontend/src/layouts/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout.tsx @@ -55,10 +55,10 @@ function resolveMenuIcon(icon?: string): ReactNode { type PermissionMenuNode = SysPermission & { children?: PermissionMenuNode[]; }; -type CachedUserProfile = { displayName?: string; username?: string; avatar_url?: string }; +type CachedUserProfile = { displayName?: string; username?: string; avatarUrl?: string }; function getAvatarUrl(profile?: CachedUserProfile | null) { - return profile?.avatar_url?.trim() || ""; + return profile?.avatarUrl?.trim() || ""; } export default function AppLayout() { const { message } = App.useApp(); diff --git a/frontend/src/pages/access/users/index.tsx b/frontend/src/pages/access/users/index.tsx index e67a789..216f420 100644 --- a/frontend/src/pages/access/users/index.tsx +++ b/frontend/src/pages/access/users/index.tsx @@ -234,7 +234,7 @@ export default function Users() { try { setAvatarUploading(true); const url = await uploadPlatformAsset(file); - form.setFieldValue("avatar_url", url); + form.setFieldValue("avatarUrl", url); message.success(t("common.success")); } finally { setAvatarUploading(false); @@ -251,7 +251,7 @@ export default function Users() { displayName: values.displayName, email: values.email, phone: values.phone, - avatar_url: values.avatar_url, + avatarUrl: values.avatarUrl, status: values.status, isPlatformAdmin: values.isPlatformAdmin }; @@ -294,7 +294,7 @@ export default function Users() { key: "user", render: (_: any, record: SysUser) => ( - } /> + } />
{record.displayName}
@@ -415,7 +415,7 @@ export default function Users() { - + diff --git a/frontend/src/pages/profile/index.tsx b/frontend/src/pages/profile/index.tsx index 8b83668..329bc6c 100644 --- a/frontend/src/pages/profile/index.tsx +++ b/frontend/src/pages/profile/index.tsx @@ -64,7 +64,7 @@ export default function Profile() { try { setAvatarUploading(true); const url = await uploadPlatformAsset(file); - profileForm.setFieldValue("avatar_url", url); + profileForm.setFieldValue("avatarUrl", url); message.success(t("common.success")); } finally { setAvatarUploading(false); @@ -96,7 +96,7 @@ export default function Profile() { }; const renderValue = (value?: string) => value || "-"; - const avatarUrlValue = Form.useWatch("avatar_url", profileForm) as string | undefined; + const avatarUrlValue = Form.useWatch("avatarUrl", profileForm) as string | undefined; const avatarUrl = avatarUrlValue?.trim() || undefined; return ( @@ -137,7 +137,7 @@ export default function Profile() { - + diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7492654..0c3be24 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -12,6 +12,7 @@ export interface SysUser extends BaseEntity { displayName: string; email?: string; phone?: string; + avatarUrl?: string; password?: string; passwordHash?: string; tenantId: number; @@ -28,6 +29,7 @@ export interface UserProfile { displayName: string; email?: string; phone?: string; + avatarUrl?: string; status?: number; isAdmin: boolean; isPlatformAdmin?: boolean;