feat: 添加旧版Android API支持和日志配置

- 添加 `ApiResponseSuccessCodeAdvice` 以处理旧版成功代码
- 添加 `LegacyMeetingCreateRequest`, `LegacyApiResponse` 和相关控制器
- 添加 `GrpcExceptionLoggingInterceptor` 以增强gRPC异常日志记录
- 更新 `application.yml`,移除 `/api/android/**` 的安全配置
- 更新前端API和组件,修复字段名称和代理配置
- 添加日志配置文件 `logback-spring.xml` 以支持日志滚动和格式化
dev_na
chenhao 2026-04-13 10:32:56 +08:00
parent 5f895bfe26
commit dffd33206a
23 changed files with 1001 additions and 10 deletions

View File

@ -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;
}
}

View File

@ -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());
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -50,7 +50,6 @@ unisbase:
- /actuator/health
- /api/static/**
- /ws/**
- /api/android/**
internal-auth:
enabled: true
header-name: X-Internal-Secret

View File

@ -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>

View File

@ -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();

View File

@ -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) => (
<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>
<Space size={4}>
<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.phone")} name="phone"><Input placeholder={t("users.phone")} className="tabular-nums" /></Form.Item></Col>
</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}>
<Button icon={<UploadOutlined />} loading={avatarUploading} style={{ marginBottom: 16 }}>{t("profile.uploadAvatar")}</Button>
</Upload>

View File

@ -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() {
<Form.Item label={t("users.phone")} name="phone">
<Input />
</Form.Item>
<Form.Item label={t("profile.avatarUrl")} name="avatar_url">
<Form.Item label={t("profile.avatarUrl")} name="avatarUrl">
<Input placeholder={t("profile.avatarUrlPlaceholder")} />
</Form.Item>
<Upload accept="image/*" showUploadList={false} beforeUpload={handleAvatarUpload}>

View File

@ -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;