refactor: 优化 Android gRPC 推送服务日志记录和错误处理

- 在 `AndroidPushGrpcService` 中添加详细的日志记录,包括连接、心跳、ACK 和错误处理
- 更新 `sendError` 方法以包含设备信息,并在日志中记录错误响应
- 在 `LegacyAuthController` 中添加租户 ID 设置逻辑
- 更新 `MeetingSummaryPromptAssembler` 中的提示词,明确关键词基于会议转写生成
- 移除 `AndroidAuthServiceImplTest` 测试类
dev_na
chenhao 2026-05-06 16:38:05 +08:00
parent 5b4304a4b2
commit c0e973e5a9
7 changed files with 162 additions and 100 deletions

View File

@ -1,5 +1,6 @@
package com.imeeting.controller.android.legacy;
import com.google.protobuf.ServiceException;
import com.imeeting.dto.android.legacy.LegacyApiResponse;
import com.imeeting.dto.android.legacy.LegacyLoginResponse;
import com.imeeting.dto.android.legacy.LegacyLoginUserResponse;
@ -40,6 +41,11 @@ public class LegacyAuthController {
} catch (Exception e) {
return LegacyApiResponse.error("400",e.getMessage());
}
try {
tokenResponse.getUser().setTenantId(tokenResponse.getAvailableTenants().stream().findFirst().orElseThrow(()-> new ServiceException("未绑定租户")).getTenantId());
} catch (ServiceException e) {
return LegacyApiResponse.error("400",e.getMessage());
}
return LegacyApiResponse.ok(new LegacyLoginResponse(
tokenResponse.getAccessToken(),
tokenResponse.getRefreshToken(),
@ -68,6 +74,7 @@ public class LegacyAuthController {
SysRoleDTO primaryRole = resolvePrimaryRole(user);
return new LegacyLoginUserResponse(
user.getUserId(),
user.getTenantId(),
user.getUsername(),
user.getDisplayName(),
user.getAvatarUrl(),

View File

@ -11,6 +11,7 @@ import java.time.LocalDateTime;
@AllArgsConstructor
public class LegacyLoginUserResponse {
private Long user_id;
private Long tenant_id;
private String username;
private String caption;
private String avatar_url;

View File

@ -13,11 +13,16 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@Slf4j
@Service
@RequiredArgsConstructor
public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase implements BindableService {
private static final DateTimeFormatter LOG_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final AndroidAuthService androidAuthService;
private final AndroidDeviceSessionService androidDeviceSessionService;
private final AndroidGatewayPushService androidGatewayPushService;
@ -28,6 +33,8 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase
return new StreamObserver<>() {
private String connectionId;
private String deviceId;
private String appVersion;
private String platform;
private boolean connected;
@Override
@ -37,32 +44,69 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase
case CONNECT -> handleConnect(message.getConnect());
case HEARTBEAT -> handleHeartbeat(message.getHeartbeat());
case ACK -> handleAck(message.getAck());
case PAYLOAD_NOT_SET -> sendError(responseObserver, "PUSH_BAD_REQUEST", "Missing push payload", false);
case PAYLOAD_NOT_SET -> {
log.info(buildLog("gRPC请求", "收到空的客户端消息体", deviceId, appVersion, platform));
sendError(responseObserver, "PUSH_BAD_REQUEST", "Missing push payload", false, deviceId, appVersion, platform);
}
}
} catch (BusinessException ex) {
log.info(buildLog("gRPC业务拒绝",
"gRPC推送请求被业务规则拒绝连接ID=" + safe(connectionId) + ",原因=" + safe(ex.getMessage()),
deviceId,
appVersion,
platform));
log.warn("Android push gRPC business rejection, connectionId={}", connectionId, ex);
sendError(responseObserver, ex.getCode(), ex.getMessage(), false);
sendError(responseObserver, ex.getCode(), ex.getMessage(), false, deviceId, appVersion, platform);
} catch (Exception ex) {
log.info(buildLog("gRPC处理异常",
"gRPC推送请求处理失败连接ID=" + safe(connectionId) + ",异常=" + ex.getClass().getSimpleName(),
deviceId,
appVersion,
platform));
log.warn("Android push gRPC request handling failed, connectionId={}", connectionId, ex);
sendError(responseObserver, "PUSH_PROCESSING_ERROR", ex.getMessage(), false);
sendError(responseObserver, "PUSH_PROCESSING_ERROR", ex.getMessage(), false, deviceId, appVersion, platform);
}
}
@Override
public void onError(Throwable throwable) {
log.info(buildLog("gRPC异常断开",
"gRPC推送流异常断开连接ID=" + safe(connectionId) + ",异常=" + throwable.getClass().getSimpleName(),
deviceId,
appVersion,
platform));
log.warn("Android push gRPC stream failed, connectionId={}", connectionId, throwable);
cleanup();
}
@Override
public void onCompleted() {
log.info(buildLog("gRPC主动完成",
"客户端正常关闭gRPC推送流连接ID=" + safe(connectionId),
deviceId,
appVersion,
platform));
cleanup();
responseObserver.onCompleted();
}
private void handleConnect(ConnectRequest request) {
String requestDeviceId = request.getDeviceId();
String requestAppVersion = request.getAppVersion();
String requestPlatform = resolvePlatform(request.getPlatform());
log.info(buildLog("gRPC连接请求",
"收到Android推送连接请求请求连接ID=" + safe(request.getConnectionId()),
requestDeviceId,
requestAppVersion,
requestPlatform));
if (connected) {
sendError(responseObserver, "PUSH_ALREADY_CONNECTED", "Push connection already established", false);
log.info(buildLog("gRPC连接拒绝",
"重复发起连接当前连接已建立连接ID=" + safe(connectionId),
deviceId,
appVersion,
platform));
sendError(responseObserver, "PUSH_ALREADY_CONNECTED", "Push connection already established", false,
deviceId, appVersion, platform);
return;
}
AndroidAuthContext authContext = androidAuthService.authenticateGrpc(
@ -75,12 +119,24 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase
AndroidDeviceSessionState sessionState = androidDeviceSessionService.openSession(authContext, request.getConnectionId());
connectionId = sessionState.getConnectionId();
deviceId = sessionState.getDeviceId();
appVersion = authContext.getAppVersion();
platform = authContext.getPlatform();
deviceOnlineManagementService.recordConnected(authContext);
connected = true;
String replacedConnectionId = androidGatewayPushService.register(connectionId, deviceId, responseObserver);
if (replacedConnectionId != null && !replacedConnectionId.equals(connectionId)) {
log.info(buildLog("gRPC连接替换",
"同设备旧连接被新连接替换旧连接ID=" + replacedConnectionId + "新连接ID=" + connectionId,
deviceId,
appVersion,
platform));
androidDeviceSessionService.closeSession(replacedConnectionId);
}
log.info(buildLog("gRPC连接成功",
"Android推送连接建立成功连接ID=" + connectionId,
deviceId,
appVersion,
platform));
responseObserver.onNext(ServerMessage.newBuilder()
.setConnectAck(ConnectResponse.newBuilder()
.setSuccess(true)
@ -94,11 +150,13 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase
return;
}
if (!request.getConnectionId().isBlank() && !request.getConnectionId().equals(connectionId)) {
sendError(responseObserver, "PUSH_CONNECTION_MISMATCH", "Connection id does not match active session", false);
sendError(responseObserver, "PUSH_CONNECTION_MISMATCH", "Connection id does not match active session", false,
deviceId, appVersion, platform);
return;
}
if (!request.getDeviceId().isBlank() && !request.getDeviceId().equals(deviceId)) {
sendError(responseObserver, "PUSH_DEVICE_MISMATCH", "Device id does not match active session", false);
sendError(responseObserver, "PUSH_DEVICE_MISMATCH", "Device id does not match active session", false,
deviceId, appVersion, platform);
return;
}
AndroidDeviceSessionState state = androidDeviceSessionService.refreshHeartbeat(connectionId, request.getTimestamp());
@ -115,19 +173,43 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase
return;
}
if (!request.getConnectionId().isBlank() && !request.getConnectionId().equals(connectionId)) {
sendError(responseObserver, "PUSH_CONNECTION_MISMATCH", "Connection id does not match active session", false);
log.info(buildLog("gRPC确认拒绝",
"ACK连接ID与当前活动连接不一致请求连接ID=" + request.getConnectionId() + "当前连接ID=" + connectionId,
deviceId,
appVersion,
platform));
sendError(responseObserver, "PUSH_CONNECTION_MISMATCH", "Connection id does not match active session", false,
deviceId, appVersion, platform);
return;
}
if (!request.getDeviceId().isBlank() && !request.getDeviceId().equals(deviceId)) {
sendError(responseObserver, "PUSH_DEVICE_MISMATCH", "Device id does not match active session", false);
log.info(buildLog("gRPC确认拒绝",
"ACK设备ID与当前活动设备不一致请求设备ID=" + request.getDeviceId() + "当前设备ID=" + deviceId,
deviceId,
appVersion,
platform));
sendError(responseObserver, "PUSH_DEVICE_MISMATCH", "Device id does not match active session", false,
deviceId, appVersion, platform);
return;
}
log.info(buildLog("gRPC消息确认",
"收到客户端ACK确认消息ID=" + safe(request.getMessageId()) + "连接ID=" + connectionId,
deviceId,
appVersion,
platform));
}
private boolean validateConnected() {
if (connected) {
return true;
}
sendError(responseObserver, "PUSH_NOT_CONNECTED", "Push connection has not been established", false);
log.info(buildLog("gRPC请求拒绝",
"连接尚未建立即发送后续消息",
deviceId,
appVersion,
platform));
sendError(responseObserver, "PUSH_NOT_CONNECTED", "Push connection has not been established", false,
deviceId, appVersion, platform);
return false;
}
@ -139,14 +221,32 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase
androidGatewayPushService.unregister(connectionId);
androidDeviceSessionService.closeSession(connectionId);
deviceOnlineManagementService.recordDisconnected(deviceId, state == null ? null : state.getLastSeenAt());
log.info(buildLog("gRPC连接关闭",
"Android推送连接已关闭连接ID=" + connectionId,
deviceId,
state == null ? appVersion : state.getAppVersion(),
state == null ? platform : state.getPlatform()));
connectionId = null;
deviceId = null;
appVersion = null;
platform = null;
connected = false;
}
};
}
private void sendError(StreamObserver<ServerMessage> responseObserver, String code, String message, boolean retryable) {
private void sendError(StreamObserver<ServerMessage> responseObserver,
String code,
String message,
boolean retryable,
String deviceId,
String appVersion,
String platform) {
log.info(buildLog("gRPC错误响应",
"向客户端返回错误,错误码=" + safe(code) + ",原因=" + safe(message),
deviceId,
appVersion,
platform));
responseObserver.onNext(ServerMessage.newBuilder()
.setError(ErrorEvent.newBuilder()
.setCode(code)
@ -156,6 +256,20 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase
.build());
}
private String buildLog(String actionType, String action, String deviceId, String appVersion, String platform) {
return String.format("[%s] %s %s 设备=%s 版本=%s 平台=%s",
safe(actionType),
LocalDateTime.now().format(LOG_TIME_FORMATTER),
safe(action),
safe(deviceId),
safe(appVersion),
safe(platform));
}
private String safe(String value) {
return value == null || value.isBlank() ? "-" : value.trim();
}
private String resolvePlatform(Platform platform) {
return switch (platform) {
case IOS -> "ios";

View File

@ -12,6 +12,8 @@ import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;
@ -20,6 +22,8 @@ import java.util.UUID;
@RequiredArgsConstructor
public class AndroidDeviceSessionServiceImpl implements AndroidDeviceSessionService {
private static final DateTimeFormatter LOG_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
private final GrpcServerProperties grpcServerProperties;
@ -35,6 +39,11 @@ public class AndroidDeviceSessionServiceImpl implements AndroidDeviceSessionServ
state.setPlatform(nonBlank(authContext.getPlatform(), "android"));
state.setTenantCode(authContext.getTenantCode());
writeState(state);
log.info(buildLog("gRPC会话创建",
"创建Android设备会话连接ID=" + state.getConnectionId(),
state.getDeviceId(),
state.getAppVersion(),
state.getPlatform()));
return state;
}
@ -109,6 +118,11 @@ public class AndroidDeviceSessionServiceImpl implements AndroidDeviceSessionServ
redisTemplate.delete(RedisKeys.androidDeviceOnlineKey(state.getDeviceId()));
}
redisTemplate.delete(RedisKeys.androidDeviceConnectionKey(connectionId));
log.info(buildLog("gRPC会话关闭",
"关闭Android设备会话连接ID=" + connectionId,
state.getDeviceId(),
state.getAppVersion(),
state.getPlatform()));
}
private void writeState(AndroidDeviceSessionState state) {
@ -126,4 +140,18 @@ public class AndroidDeviceSessionServiceImpl implements AndroidDeviceSessionServ
private String nonBlank(String value, String defaultValue) {
return value != null && !value.isBlank() ? value : defaultValue;
}
private String buildLog(String actionType, String action, String deviceId, String appVersion, String platform) {
return String.format("[%s] %s %s 设备=%s 版本=%s 平台=%s",
safe(actionType),
LocalDateTime.now().format(LOG_TIME_FORMATTER),
safe(action),
safe(deviceId),
safe(appVersion),
safe(platform));
}
private String safe(String value) {
return value == null || value.isBlank() ? "-" : value.trim();
}
}

View File

@ -102,7 +102,7 @@ public class MeetingSummaryPromptAssembler {
.append("}\n")
.append("要求:\n")
.append("1. `summaryContent` 必须优先遵循模板提示词中的结构、标题层级、章节顺序和写作风格。\n")
.append("2. `analysis.keywords` 必须基于完整转写内容生成,不得脱离上下文。并且在转录中能找到对应的原文\n")
.append("2. `analysis.keywords` 必须基于完整转写内容生成,不得脱离上下文。并且在会议转写中能找到对应的原文\n")
// .append("3. 若无待办事项,`todos` 返回空数组。\n")
.append("3. 仅输出 JSON。\n")
.append("\n")

View File

@ -1,88 +0,0 @@
package com.imeeting.service.android.impl;
import com.imeeting.config.grpc.AndroidGrpcAuthProperties;
import com.imeeting.dto.android.AndroidAuthContext;
import com.unisbase.dto.InternalAuthCheckResponse;
import com.unisbase.service.TokenValidationService;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class AndroidAuthServiceImplTest {
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test
void authenticateHttpShouldResolveBearerTokenAndIdentity() {
AndroidGrpcAuthProperties properties = new AndroidGrpcAuthProperties();
TokenValidationService tokenValidationService = mock(TokenValidationService.class);
AndroidAuthServiceImpl service = new AndroidAuthServiceImpl(properties, tokenValidationService);
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getHeader("Authorization")).thenReturn("Bearer access-token");
when(request.getHeader("X-Android-Device-Id")).thenReturn("device-01");
when(request.getHeader("X-Android-App-Id")).thenReturn("imeeting");
when(request.getHeader("X-Android-App-Version")).thenReturn("1.0.0");
when(request.getHeader("X-Android-Platform")).thenReturn("android");
InternalAuthCheckResponse authResult = new InternalAuthCheckResponse();
authResult.setValid(true);
authResult.setUserId(11L);
authResult.setTenantId(22L);
authResult.setUsername("alice");
authResult.setPlatformAdmin(false);
authResult.setTenantAdmin(true);
authResult.setPermissions(Set.of("meeting:create"));
when(tokenValidationService.validateAccessToken("access-token")).thenReturn(authResult);
AndroidAuthContext context = service.authenticateHttp(request);
assertFalse(context.isAnonymous());
assertEquals("USER_JWT", context.getAuthMode());
assertEquals("device-01", context.getDeviceId());
assertEquals(11L, context.getUserId());
assertEquals(22L, context.getTenantId());
assertEquals("alice", context.getUsername());
assertEquals("alice", context.getDisplayName());
assertTrue(context.getTenantAdmin());
assertEquals(Set.of("meeting:create"), context.getPermissions());
assertEquals("access-token", context.getAccessToken());
}
@Test
void authenticateHttpShouldAllowAnonymousWhenConfigured() {
AndroidGrpcAuthProperties properties = new AndroidGrpcAuthProperties();
properties.setAllowAnonymous(true);
TokenValidationService tokenValidationService = mock(TokenValidationService.class);
AndroidAuthServiceImpl service = new AndroidAuthServiceImpl(properties, tokenValidationService);
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getHeader("Authorization")).thenReturn(null);
when(request.getHeader("X-Android-Device-Id")).thenReturn("device-anon");
when(request.getHeader("X-Android-App-Id")).thenReturn("imeeting");
when(request.getHeader("X-Android-App-Version")).thenReturn("1.0.0");
when(request.getHeader("X-Android-Platform")).thenReturn("android");
AndroidAuthContext context = service.authenticateHttp(request);
assertTrue(context.isAnonymous());
assertEquals("NONE", context.getAuthMode());
assertEquals("device-anon", context.getDeviceId());
assertNull(context.getUserId());
assertNull(context.getTenantId());
assertNull(context.getAccessToken());
}
}

View File

@ -327,7 +327,7 @@ export default function Logs() {
{selectedLog && (
<Descriptions bordered column={1} size="small">
{isPlatformAdmin && <Descriptions.Item label={t("users.tenant")}><Text>{selectedLog.tenantName || t("logsExt.platform")}</Text></Descriptions.Item>}
{selectedLog.logType === "OPERATION" && <Descriptions.Item label={t("logsExt.module")}>{selectedLog.moduleName || t("logsExt.uncategorized")}</Descriptions.Item>}
{selectedLog.logType === "OPERATION" && <Descriptions.Item style={{ width: 50 }} label={t("logsExt.module")}>{selectedLog.moduleName || t("logsExt.uncategorized")}</Descriptions.Item>}
{selectedLog.logType === "OPERATION" && <Descriptions.Item label={t("logsExt.actionLabel")}>{selectedLog.actionName || selectedLog.operation}</Descriptions.Item>}
<Descriptions.Item label={t("logs.opDetail")}>{selectedLog.operation}</Descriptions.Item>
<Descriptions.Item label={t("logs.method")}><Tag color="blue">{selectedLog.method || "N/A"}</Tag></Descriptions.Item>