feat: 添加旧版Android API支持和日志配置
- 添加 `ApiResponseSuccessCodeAdvice` 以处理旧版成功代码 - 添加 `LegacyMeetingCreateRequest`, `LegacyApiResponse` 和相关控制器 - 添加 `GrpcExceptionLoggingInterceptor` 以增强gRPC异常日志记录 - 更新 `application.yml`,移除 `/api/android/**` 的安全配置 - 更新前端API和组件,修复字段名称和代理配置 - 添加日志配置文件 `logback-spring.xml` 以支持日志滚动和格式化dev_na
parent
5f895bfe26
commit
dffd33206a
|
|
@ -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<Object> {
|
||||||
|
|
||||||
|
private static final String LEGACY_SUCCESS_CODE = "0";
|
||||||
|
private static final String SUCCESS_CODE = "200";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Object beforeBodyWrite(Object body,
|
||||||
|
MethodParameter returnType,
|
||||||
|
MediaType selectedContentType,
|
||||||
|
Class<? extends HttpMessageConverter<?>> selectedConverterType,
|
||||||
|
ServerHttpRequest request,
|
||||||
|
ServerHttpResponse response) {
|
||||||
|
if (body instanceof ApiResponse<?> apiResponse && LEGACY_SUCCESS_CODE.equals(apiResponse.getCode())) {
|
||||||
|
apiResponse.setCode(SUCCESS_CODE);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(ServerCall<ReqT, RespT> call,
|
||||||
|
Metadata headers,
|
||||||
|
ServerCallHandler<ReqT, RespT> next) {
|
||||||
|
String methodName = call.getMethodDescriptor().getFullMethodName();
|
||||||
|
AtomicBoolean closed = new AtomicBoolean(false);
|
||||||
|
ServerCall.Listener<ReqT> 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 <ReqT, RespT> void closeCall(ServerCall<ReqT, RespT> call, AtomicBoolean closed, RuntimeException ex) {
|
||||||
|
if (!closed.compareAndSet(false, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
call.close(Status.UNKNOWN.withDescription("Application error processing RPC").withCause(ex), new Metadata());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<List<LegacyLlmModelItemResponse>> activeModels() {
|
||||||
|
LoginUser loginUser = currentLoginUser();
|
||||||
|
PageResult<List<AiModelVO>> result = aiModelService.pageModels(1, 1000, null, "LLM", loginUser.getTenantId());
|
||||||
|
List<AiModelVO> 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<LegacyLlmModelItemResponse> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<LegacyMeetingCreateResponse> create(@RequestBody LegacyMeetingCreateRequest request) {
|
||||||
|
MeetingVO meeting = legacyMeetingAdapterService.createMeeting(request, currentLoginUser());
|
||||||
|
return LegacyApiResponse.ok(new LegacyMeetingCreateResponse(meeting.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/upload-audio")
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
public LegacyApiResponse<Void> 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<LegacyMeetingListResponse> 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<List<MeetingVO>> 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<Void> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<LegacyPromptListResponse> activePrompts(@PathVariable String scene) {
|
||||||
|
if (!LEGACY_MEETING_SCENE.equals(scene)) {
|
||||||
|
return LegacyApiResponse.error("400", "scene only supports MEETING_TASK");
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginUser loginUser = currentLoginUser();
|
||||||
|
PageResult<List<PromptTemplateVO>> result = promptTemplateService.pageTemplates(
|
||||||
|
1,
|
||||||
|
1000,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
loginUser.getTenantId(),
|
||||||
|
loginUser.getUserId(),
|
||||||
|
loginUser.getIsPlatformAdmin(),
|
||||||
|
loginUser.getIsTenantAdmin()
|
||||||
|
);
|
||||||
|
List<PromptTemplateVO> 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<LegacyPromptItemResponse> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<T> {
|
||||||
|
private String code;
|
||||||
|
private String message;
|
||||||
|
private T data;
|
||||||
|
|
||||||
|
public static <T> LegacyApiResponse<T> ok(T data) {
|
||||||
|
return new LegacyApiResponse<>("200", "success", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> LegacyApiResponse<T> ok(String message, T data) {
|
||||||
|
return new LegacyApiResponse<>("200", message, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> LegacyApiResponse<T> error(String code, String message) {
|
||||||
|
return new LegacyApiResponse<>(code, message, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<Long> attendeeIds;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<LegacyMeetingItemResponse> 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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<LegacyPromptItemResponse> prompts;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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<MeetingTranscript>()
|
||||||
|
.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<Long> 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<LlmModel>()
|
||||||
|
.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<String, Object> 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<String, Object> 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<AiTask>()
|
||||||
|
.eq(AiTask::getMeetingId, meetingId)
|
||||||
|
.eq(AiTask::getTaskType, taskType)
|
||||||
|
.orderByDesc(AiTask::getId)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void resetOrCreateTask(AiTask task, Long meetingId, String taskType, Map<String, Object> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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> T callAsTenantUser(Long tenantId, Long userId, Supplier<T> supplier) {
|
||||||
|
return runWithUser(buildTenantUser(tenantId, userId), supplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
public <T> T callAsPlatformAdmin(Supplier<T> supplier) {
|
||||||
|
return runWithUser(buildPlatformAdmin(), supplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> T runWithUser(LoginUser loginUser, Supplier<T> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -50,7 +50,6 @@ unisbase:
|
||||||
- /actuator/health
|
- /actuator/health
|
||||||
- /api/static/**
|
- /api/static/**
|
||||||
- /ws/**
|
- /ws/**
|
||||||
- /api/android/**
|
|
||||||
internal-auth:
|
internal-auth:
|
||||||
enabled: true
|
enabled: true
|
||||||
header-name: X-Internal-Secret
|
header-name: X-Internal-Secret
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<configuration scan="true" scanPeriod="60 seconds">
|
||||||
|
<springProperty scope="context" name="APP_NAME" source="spring.application.name" defaultValue="imeeting-backend"/>
|
||||||
|
<springProperty scope="context" name="APP_LOG_PATH" source="logging.file.path" defaultValue="./logs"/>
|
||||||
|
|
||||||
|
<property name="CONSOLE_LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n"/>
|
||||||
|
<property name="FILE_LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{64} - %msg%n"/>
|
||||||
|
|
||||||
|
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||||
|
<encoder>
|
||||||
|
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
|
||||||
|
<charset>UTF-8</charset>
|
||||||
|
</encoder>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||||
|
<file>${APP_LOG_PATH}/${APP_NAME}.log</file>
|
||||||
|
<encoder>
|
||||||
|
<pattern>${FILE_LOG_PATTERN}</pattern>
|
||||||
|
<charset>UTF-8</charset>
|
||||||
|
</encoder>
|
||||||
|
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||||
|
<fileNamePattern>${APP_LOG_PATH}/${APP_NAME}.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||||||
|
<maxFileSize>${LOG_MAX_FILE_SIZE:-100MB}</maxFileSize>
|
||||||
|
<maxHistory>${LOG_MAX_HISTORY:-30}</maxHistory>
|
||||||
|
<totalSizeCap>${LOG_TOTAL_SIZE_CAP:-3GB}</totalSizeCap>
|
||||||
|
</rollingPolicy>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<springProfile name="dev">
|
||||||
|
<logger name="io.grpc" level="DEBUG"/>
|
||||||
|
<logger name="io.grpc.netty.shaded.io.grpc.netty" level="DEBUG"/>
|
||||||
|
<logger name="com.imeeting.config.grpc" level="DEBUG"/>
|
||||||
|
<logger name="com.imeeting.grpc" level="DEBUG"/>
|
||||||
|
<logger name="com.imeeting.service.realtime.impl.RealtimeMeetingGrpcSessionServiceImpl" level="DEBUG"/>
|
||||||
|
<logger name="com.imeeting.service.realtime.impl.AsrUpstreamBridgeServiceImpl" level="DEBUG"/>
|
||||||
|
</springProfile>
|
||||||
|
|
||||||
|
<root level="INFO">
|
||||||
|
<appender-ref ref="CONSOLE"/>
|
||||||
|
<appender-ref ref="FILE"/>
|
||||||
|
</root>
|
||||||
|
</configuration>
|
||||||
|
|
@ -55,10 +55,10 @@ function resolveMenuIcon(icon?: string): ReactNode {
|
||||||
type PermissionMenuNode = SysPermission & {
|
type PermissionMenuNode = SysPermission & {
|
||||||
children?: PermissionMenuNode[];
|
children?: PermissionMenuNode[];
|
||||||
};
|
};
|
||||||
type CachedUserProfile = { displayName?: string; username?: string; avatar_url?: string };
|
type CachedUserProfile = { displayName?: string; username?: string; avatarUrl?: string };
|
||||||
|
|
||||||
function getAvatarUrl(profile?: CachedUserProfile | null) {
|
function getAvatarUrl(profile?: CachedUserProfile | null) {
|
||||||
return profile?.avatar_url?.trim() || "";
|
return profile?.avatarUrl?.trim() || "";
|
||||||
}
|
}
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
|
|
|
||||||
|
|
@ -234,7 +234,7 @@ export default function Users() {
|
||||||
try {
|
try {
|
||||||
setAvatarUploading(true);
|
setAvatarUploading(true);
|
||||||
const url = await uploadPlatformAsset(file);
|
const url = await uploadPlatformAsset(file);
|
||||||
form.setFieldValue("avatar_url", url);
|
form.setFieldValue("avatarUrl", url);
|
||||||
message.success(t("common.success"));
|
message.success(t("common.success"));
|
||||||
} finally {
|
} finally {
|
||||||
setAvatarUploading(false);
|
setAvatarUploading(false);
|
||||||
|
|
@ -251,7 +251,7 @@ export default function Users() {
|
||||||
displayName: values.displayName,
|
displayName: values.displayName,
|
||||||
email: values.email,
|
email: values.email,
|
||||||
phone: values.phone,
|
phone: values.phone,
|
||||||
avatar_url: values.avatar_url,
|
avatarUrl: values.avatarUrl,
|
||||||
status: values.status,
|
status: values.status,
|
||||||
isPlatformAdmin: values.isPlatformAdmin
|
isPlatformAdmin: values.isPlatformAdmin
|
||||||
};
|
};
|
||||||
|
|
@ -294,7 +294,7 @@ export default function Users() {
|
||||||
key: "user",
|
key: "user",
|
||||||
render: (_: any, record: SysUser) => (
|
render: (_: any, record: SysUser) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Avatar className="user-avatar-placeholder" size={40} src={record.avatar_url || undefined} icon={record.avatar_url ? undefined : <UserOutlined />} />
|
<Avatar className="user-avatar-placeholder" size={40} src={record.avatarUrl || undefined} icon={record.avatarUrl ? undefined : <UserOutlined />} />
|
||||||
<div>
|
<div>
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
<div className="user-display-name">{record.displayName}</div>
|
<div className="user-display-name">{record.displayName}</div>
|
||||||
|
|
@ -415,7 +415,7 @@ export default function Users() {
|
||||||
<Col span={12}><Form.Item label={t("users.email")} name="email"><Input placeholder={t("usersExt.emailPlaceholder")} className="tabular-nums" /></Form.Item></Col>
|
<Col span={12}><Form.Item label={t("users.email")} name="email"><Input placeholder={t("usersExt.emailPlaceholder")} className="tabular-nums" /></Form.Item></Col>
|
||||||
<Col span={12}><Form.Item label={t("users.phone")} name="phone"><Input placeholder={t("users.phone")} className="tabular-nums" /></Form.Item></Col>
|
<Col span={12}><Form.Item label={t("users.phone")} name="phone"><Input placeholder={t("users.phone")} className="tabular-nums" /></Form.Item></Col>
|
||||||
</Row>
|
</Row>
|
||||||
<Form.Item label={t("profile.avatarUrl")} name="avatar_url"><Input placeholder={t("profile.avatarUrlPlaceholder")} /></Form.Item>
|
<Form.Item label={t("profile.avatarUrl")} name="avatarUrl"><Input placeholder={t("profile.avatarUrlPlaceholder")} /></Form.Item>
|
||||||
<Upload accept="image/*" showUploadList={false} beforeUpload={handleAvatarUpload}>
|
<Upload accept="image/*" showUploadList={false} beforeUpload={handleAvatarUpload}>
|
||||||
<Button icon={<UploadOutlined />} loading={avatarUploading} style={{ marginBottom: 16 }}>{t("profile.uploadAvatar")}</Button>
|
<Button icon={<UploadOutlined />} loading={avatarUploading} style={{ marginBottom: 16 }}>{t("profile.uploadAvatar")}</Button>
|
||||||
</Upload>
|
</Upload>
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export default function Profile() {
|
||||||
try {
|
try {
|
||||||
setAvatarUploading(true);
|
setAvatarUploading(true);
|
||||||
const url = await uploadPlatformAsset(file);
|
const url = await uploadPlatformAsset(file);
|
||||||
profileForm.setFieldValue("avatar_url", url);
|
profileForm.setFieldValue("avatarUrl", url);
|
||||||
message.success(t("common.success"));
|
message.success(t("common.success"));
|
||||||
} finally {
|
} finally {
|
||||||
setAvatarUploading(false);
|
setAvatarUploading(false);
|
||||||
|
|
@ -96,7 +96,7 @@ export default function Profile() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderValue = (value?: string) => value || "-";
|
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;
|
const avatarUrl = avatarUrlValue?.trim() || undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -137,7 +137,7 @@ export default function Profile() {
|
||||||
<Form.Item label={t("users.phone")} name="phone">
|
<Form.Item label={t("users.phone")} name="phone">
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t("profile.avatarUrl")} name="avatar_url">
|
<Form.Item label={t("profile.avatarUrl")} name="avatarUrl">
|
||||||
<Input placeholder={t("profile.avatarUrlPlaceholder")} />
|
<Input placeholder={t("profile.avatarUrlPlaceholder")} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Upload accept="image/*" showUploadList={false} beforeUpload={handleAvatarUpload}>
|
<Upload accept="image/*" showUploadList={false} beforeUpload={handleAvatarUpload}>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export interface SysUser extends BaseEntity {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
passwordHash?: string;
|
passwordHash?: string;
|
||||||
tenantId: number;
|
tenantId: number;
|
||||||
|
|
@ -28,6 +29,7 @@ export interface UserProfile {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
status?: number;
|
status?: number;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
isPlatformAdmin?: boolean;
|
isPlatformAdmin?: boolean;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue