提交一次代码
This commit is contained in:
@@ -6,7 +6,13 @@ package cn.nianxx.thhotel.platform.debug.common.enums;
|
||||
public enum DebugEmlSuperAgentRunStatus {
|
||||
|
||||
CREATED,
|
||||
PARSING_EML,
|
||||
UPLOADING_ORIGINAL_EML,
|
||||
UPLOADING_MEDIA,
|
||||
BUILDING_SOURCE_MESSAGE,
|
||||
CAPTURING_SOURCE_MESSAGE,
|
||||
SOURCE_CAPTURED,
|
||||
CALLING_SUPERAGENT,
|
||||
SUPERAGENT_SUCCEEDED,
|
||||
SUPERAGENT_FAILED,
|
||||
FAILED
|
||||
|
||||
@@ -45,29 +45,29 @@ public class MybatisDebugEmlSuperAgentRunRepository implements DebugEmlSuperAgen
|
||||
*/
|
||||
@Override
|
||||
public void updateResult(DebugEmlSuperAgentRunUpdate update) {
|
||||
DebugEmlSuperAgentRunEntity entity = new DebugEmlSuperAgentRunEntity();
|
||||
entity.setId(update.id());
|
||||
entity.setSourceMessageId(update.sourceMessageId());
|
||||
entity.setExternalMessageId(update.externalMessageId());
|
||||
entity.setExternalConversationId(update.externalConversationId());
|
||||
entity.setOriginalFileName(update.originalFileName());
|
||||
entity.setOriginalEmlOssUrl(update.originalEmlOssUrl());
|
||||
entity.setOriginalEmlSha256(update.originalEmlSha256());
|
||||
entity.setPayloadJson(update.payloadJson());
|
||||
entity.setSuperagentSessionId(update.superagentSessionId());
|
||||
entity.setSuperagentRunId(update.superagentRunId());
|
||||
entity.setSuperagentProfileId(update.superagentProfileId());
|
||||
entity.setSuperagentProfileVersionId(update.superagentProfileVersionId());
|
||||
entity.setSuperagentModelName(update.superagentModelName());
|
||||
entity.setSuperagentRawAnswer(update.superagentRawAnswer());
|
||||
entity.setSuperagentParsedJson(update.superagentParsedJson());
|
||||
entity.setSuperagentInputTokens(update.superagentInputTokens());
|
||||
entity.setSuperagentOutputTokens(update.superagentOutputTokens());
|
||||
entity.setSuperagentTotalTokens(update.superagentTotalTokens());
|
||||
entity.setRunStatus(update.runStatus());
|
||||
entity.setSafeErrorSummary(update.safeErrorSummary());
|
||||
entity.setUpdatedAt(update.updatedAt());
|
||||
runMapper.updateById(entity);
|
||||
LambdaUpdateWrapper<DebugEmlSuperAgentRunEntity> wrapper = new LambdaUpdateWrapper<>();
|
||||
wrapper.eq(DebugEmlSuperAgentRunEntity::getId, update.id())
|
||||
.set(DebugEmlSuperAgentRunEntity::getSourceMessageId, update.sourceMessageId())
|
||||
.set(DebugEmlSuperAgentRunEntity::getExternalMessageId, update.externalMessageId())
|
||||
.set(DebugEmlSuperAgentRunEntity::getExternalConversationId, update.externalConversationId())
|
||||
.set(DebugEmlSuperAgentRunEntity::getOriginalFileName, update.originalFileName())
|
||||
.set(DebugEmlSuperAgentRunEntity::getOriginalEmlOssUrl, update.originalEmlOssUrl())
|
||||
.set(DebugEmlSuperAgentRunEntity::getOriginalEmlSha256, update.originalEmlSha256())
|
||||
.set(DebugEmlSuperAgentRunEntity::getPayloadJson, update.payloadJson())
|
||||
.set(DebugEmlSuperAgentRunEntity::getSuperagentSessionId, update.superagentSessionId())
|
||||
.set(DebugEmlSuperAgentRunEntity::getSuperagentRunId, update.superagentRunId())
|
||||
.set(DebugEmlSuperAgentRunEntity::getSuperagentProfileId, update.superagentProfileId())
|
||||
.set(DebugEmlSuperAgentRunEntity::getSuperagentProfileVersionId, update.superagentProfileVersionId())
|
||||
.set(DebugEmlSuperAgentRunEntity::getSuperagentModelName, update.superagentModelName())
|
||||
.set(DebugEmlSuperAgentRunEntity::getSuperagentRawAnswer, update.superagentRawAnswer())
|
||||
.set(DebugEmlSuperAgentRunEntity::getSuperagentParsedJson, update.superagentParsedJson())
|
||||
.set(DebugEmlSuperAgentRunEntity::getSuperagentInputTokens, update.superagentInputTokens())
|
||||
.set(DebugEmlSuperAgentRunEntity::getSuperagentOutputTokens, update.superagentOutputTokens())
|
||||
.set(DebugEmlSuperAgentRunEntity::getSuperagentTotalTokens, update.superagentTotalTokens())
|
||||
.set(DebugEmlSuperAgentRunEntity::getRunStatus, update.runStatus())
|
||||
.set(DebugEmlSuperAgentRunEntity::getSafeErrorSummary, update.safeErrorSummary())
|
||||
.set(DebugEmlSuperAgentRunEntity::getUpdatedAt, update.updatedAt());
|
||||
runMapper.update(null, wrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -252,6 +252,10 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
|
||||
byte[] emlBytes,
|
||||
LocalDateTime createdAt) throws Exception {
|
||||
String sha256 = sha256(emlBytes);
|
||||
markStage(
|
||||
runId,
|
||||
DebugEmlSuperAgentRunStatus.PARSING_EML,
|
||||
"阶段:解析 EML 邮件,文件名:" + safeFileName + ",大小:" + emlBytes.length + " bytes。");
|
||||
ParsedEmlMessage parsed = parseService.parse(emlBytes, safeFileName);
|
||||
String originalMessageId = parsed.messageId();
|
||||
String originalConversationId = parsed.conversationId();
|
||||
@@ -260,17 +264,33 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
|
||||
List<String> warnings = new ArrayList<>();
|
||||
List<UploadedMedia> uploadedMedia = new ArrayList<>();
|
||||
|
||||
markStage(
|
||||
runId,
|
||||
DebugEmlSuperAgentRunStatus.UPLOADING_ORIGINAL_EML,
|
||||
"阶段:上传原始 EML 到 OSS,文件名:" + safeFileName + ",大小:" + emlBytes.length + " bytes。");
|
||||
uploadedMedia.add(uploadOriginalEml(runId, safeFileName, emlBytes, createdAt));
|
||||
int inlineIndex = 1;
|
||||
int attachmentIndex = 1;
|
||||
for (ParsedEmlMediaItem mediaItem : parsed.mediaItems()) {
|
||||
if (SourceMessageMediaType.INLINE_IMAGE.code().equals(mediaItem.mediaType())) {
|
||||
markStage(
|
||||
runId,
|
||||
DebugEmlSuperAgentRunStatus.UPLOADING_MEDIA,
|
||||
mediaUploadStageSummary(mediaItem, "inline", inlineIndex));
|
||||
uploadedMedia.add(uploadParsedMedia(runId, createdAt, mediaItem, "inline", inlineIndex++));
|
||||
} else {
|
||||
markStage(
|
||||
runId,
|
||||
DebugEmlSuperAgentRunStatus.UPLOADING_MEDIA,
|
||||
mediaUploadStageSummary(mediaItem, "attachments", attachmentIndex));
|
||||
uploadedMedia.add(uploadParsedMedia(runId, createdAt, mediaItem, "attachments", attachmentIndex++));
|
||||
}
|
||||
}
|
||||
|
||||
markStage(
|
||||
runId,
|
||||
DebugEmlSuperAgentRunStatus.BUILDING_SOURCE_MESSAGE,
|
||||
"阶段:构造 SourceMessage payload。");
|
||||
String htmlWithOssUrls = replaceCidReferences(parsed.htmlBody(), uploadedMedia, warnings);
|
||||
String htmlBodySanitized = htmlSanitizerService.sanitizeHtml(htmlWithOssUrls);
|
||||
String htmlRenderMode = htmlSanitizerService.htmlRenderMode(htmlWithOssUrls);
|
||||
@@ -285,6 +305,10 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
|
||||
htmlWithOssUrls,
|
||||
uploadedMedia);
|
||||
String payloadJson = objectMapper.writeValueAsString(payload);
|
||||
markStage(
|
||||
runId,
|
||||
DebugEmlSuperAgentRunStatus.CAPTURING_SOURCE_MESSAGE,
|
||||
"阶段:写入 SourceMessage Inbox。");
|
||||
SourceMessageCaptureResult captureResult = sourceMessageCaptureService.capture(new CaptureSourceMessageCommand(
|
||||
hotelId,
|
||||
SOURCE_PROVIDER,
|
||||
@@ -311,6 +335,10 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
|
||||
sha256,
|
||||
payloadJson);
|
||||
|
||||
markStage(
|
||||
runId,
|
||||
DebugEmlSuperAgentRunStatus.CALLING_SUPERAGENT,
|
||||
"阶段:调用 SuperAgent Open API,source_message_id=" + captureResult.inboxId() + "。");
|
||||
SuperAgentOpenApiResult superAgentResult = superAgentOpenApiClient.invokeMailDebug(new SuperAgentMailDebugRequest(
|
||||
buildSuperAgentMessage(payloadJson),
|
||||
"debug-eml-" + runId,
|
||||
@@ -658,6 +686,23 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记 Debug 运行中的当前阶段。这里复用安全摘要字段保存短文本面包屑,便于测试环境定位卡点。
|
||||
*/
|
||||
private void markStage(
|
||||
Long runId,
|
||||
DebugEmlSuperAgentRunStatus status,
|
||||
String safeSummary) {
|
||||
if (runId == null) {
|
||||
return;
|
||||
}
|
||||
runRepository.updateStatus(new DebugEmlSuperAgentRunStatusUpdate(
|
||||
runId,
|
||||
status.name(),
|
||||
truncate(safeSummary, 512),
|
||||
nowUtc()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记 Debug 运行失败。
|
||||
*/
|
||||
@@ -676,6 +721,18 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
|
||||
updatedAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造媒体上传阶段摘要,只包含安全元数据,不包含正文、公开 URL 或二进制内容。
|
||||
*/
|
||||
private String mediaUploadStageSummary(ParsedEmlMediaItem mediaItem, String folder, int index) {
|
||||
String fileName = safeFileName(mediaItem.fileName(), folder + "-" + index);
|
||||
return "阶段:上传邮件媒体到 OSS,目录:" + folder
|
||||
+ ",序号:" + index
|
||||
+ ",类型:" + mediaItem.mediaType()
|
||||
+ ",文件名:" + fileName
|
||||
+ ",大小:" + mediaItem.sizeBytes() + " bytes。";
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据异常选择 Debug run 状态。
|
||||
*/
|
||||
|
||||
@@ -71,6 +71,9 @@ public class HotelAccessServiceImpl implements HotelAccessService {
|
||||
.orElseGet(() -> hotels.isEmpty() ? null : hotels.get(0).hotelId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析当前用户可访问酒店;超级管理员取全部启用酒店,普通用户取授权关系。
|
||||
*/
|
||||
private Set<String> resolveAccessibleHotelIds(PlatformUserEntity user, List<PlatformHotelEntity> activeHotels) {
|
||||
if (Boolean.TRUE.equals(user.getSuperAdmin())) {
|
||||
return new LinkedHashSet<>(activeHotels.stream()
|
||||
@@ -80,6 +83,9 @@ public class HotelAccessServiceImpl implements HotelAccessService {
|
||||
return new LinkedHashSet<>(hotelRepository.listUserHotelIds(user.getId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按前端偏好、用户默认酒店、系统默认酒店的优先级解析当前默认酒店。
|
||||
*/
|
||||
private String resolveDefaultHotelId(
|
||||
PlatformUserEntity user,
|
||||
Set<String> accessibleHotelIds,
|
||||
|
||||
@@ -161,6 +161,9 @@ public class AuthServiceImpl implements AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装当前用户上下文响应,登录成功和 /me 接口共用同一套权限、酒店和菜单逻辑。
|
||||
*/
|
||||
private AuthMeResult buildMeResult(PlatformUserEntity user, String preferredHotelId) {
|
||||
List<AuthHotelResult> hotels = hotelAccessService.listAccessibleHotels(user, preferredHotelId);
|
||||
String defaultHotelId = hotelAccessService.resolveDefaultHotelId(hotels);
|
||||
@@ -180,6 +183,9 @@ public class AuthServiceImpl implements AuthService {
|
||||
menus);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建请求线程内的用户上下文,供后续业务接口审计和权限收口复用。
|
||||
*/
|
||||
private AuthenticatedUserContext buildSecurityContext(PlatformUserEntity user) {
|
||||
List<AuthHotelResult> hotels = hotelAccessService.listAccessibleHotels(user, null);
|
||||
List<String> permissions = accessControlService.listPermissionCodes(
|
||||
@@ -195,11 +201,17 @@ public class AuthServiceImpl implements AuthService {
|
||||
permissions);
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制解析当前 token 对应 session;登录态恢复接口必须拿到有效 session。
|
||||
*/
|
||||
private AuthSessionSnapshot resolveRequiredSession(String authorizationHeader) {
|
||||
String token = extractRequiredBearerToken(authorizationHeader);
|
||||
return resolveSessionByToken(token, true).orElseThrow(this::invalidSession);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据明文 token 查找有效 session,并按调用场景决定是否抛出无效登录态异常。
|
||||
*/
|
||||
private Optional<AuthSessionSnapshot> resolveSessionByToken(String token, boolean strict) {
|
||||
String tokenHash = tokenService.hashToken(token);
|
||||
Optional<PlatformUserSessionEntity> session = identityRepository.findSessionByTokenHash(tokenHash);
|
||||
@@ -222,6 +234,9 @@ public class AuthServiceImpl implements AuthService {
|
||||
return user.map(value -> new AuthSessionSnapshot(existingSession.getId(), existingSession.getExpiresAt(), value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 session 最近访问时间;只更新访问时间字段,避免覆盖 token 安全字段。
|
||||
*/
|
||||
private void touchSession(Long sessionId) {
|
||||
if (sessionId == null) {
|
||||
return;
|
||||
@@ -233,16 +248,25 @@ public class AuthServiceImpl implements AuthService {
|
||||
identityRepository.updateSession(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将过期 session 标记为 EXPIRED,避免后续重复按 ACTIVE 处理。
|
||||
*/
|
||||
private void markExpired(PlatformUserSessionEntity session) {
|
||||
session.setSessionStatus(PlatformUserSessionStatus.EXPIRED.name());
|
||||
session.setUpdatedAt(nowUtc());
|
||||
identityRepository.updateSession(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断用户是否为可登录状态,禁用用户不能登录或继续使用旧 session。
|
||||
*/
|
||||
private boolean isActiveUser(PlatformUserEntity user) {
|
||||
return PlatformUserStatus.ACTIVE.name().equals(user.getUserStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析必须存在的 Bearer token;缺失时返回受控 401。
|
||||
*/
|
||||
private String extractRequiredBearerToken(String authorizationHeader) {
|
||||
return extractBearerToken(authorizationHeader)
|
||||
.orElseThrow(() -> new AuthException(
|
||||
@@ -251,6 +275,9 @@ public class AuthServiceImpl implements AuthService {
|
||||
"请先登录。"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Authorization 请求头中解析 Bearer token;无效格式按空 token 处理。
|
||||
*/
|
||||
private Optional<String> extractBearerToken(String authorizationHeader) {
|
||||
if (authorizationHeader == null || authorizationHeader.isBlank()) {
|
||||
return Optional.empty();
|
||||
@@ -263,6 +290,9 @@ public class AuthServiceImpl implements AuthService {
|
||||
return token.isBlank() ? Optional.empty() : Optional.of(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建统一的 session 失效异常,避免向前端泄露 token 或账号内部状态。
|
||||
*/
|
||||
private AuthException invalidSession() {
|
||||
return new AuthException(
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
@@ -270,10 +300,16 @@ public class AuthServiceImpl implements AuthService {
|
||||
"登录已失效,请重新登录。");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 UTC 当前时间,数据库 LocalDateTime 统一按 UTC 语义保存。
|
||||
*/
|
||||
private LocalDateTime nowUtc() {
|
||||
return LocalDateTime.now(ZoneOffset.UTC);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取客户端 IP 摘要,第一版只保留请求直接来源。
|
||||
*/
|
||||
private String clientIp(HttpServletRequest request) {
|
||||
if (request == null) {
|
||||
return null;
|
||||
@@ -285,6 +321,9 @@ public class AuthServiceImpl implements AuthService {
|
||||
return truncate(request.getRemoteAddr(), 64);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取 User-Agent 摘要,避免过长请求头直接进入 session 表。
|
||||
*/
|
||||
private String userAgent(HttpServletRequest request) {
|
||||
if (request == null) {
|
||||
return null;
|
||||
@@ -292,6 +331,9 @@ public class AuthServiceImpl implements AuthService {
|
||||
return truncate(request.getHeader("User-Agent"), 256);
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断外部输入摘要字段,保护数据库字段长度和日志可读性。
|
||||
*/
|
||||
private String truncate(String value, int maxLength) {
|
||||
if (value == null || value.length() <= maxLength) {
|
||||
return value;
|
||||
|
||||
@@ -9,9 +9,9 @@ spring:
|
||||
enabled: true
|
||||
servlet:
|
||||
multipart:
|
||||
# dev Debug EML multipart 上限必须不小于业务文件上限,避免请求进入 Controller 前被 413 拦截。
|
||||
max-file-size: ${DEBUG_EML_UPLOAD_DEV_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MAX_FILE_BYTES:10485760}}
|
||||
max-request-size: ${DEBUG_EML_UPLOAD_DEV_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:12582912}}
|
||||
# dev multipart 上限高于业务上限,超限文件由 Debug EML 服务层返回受控 JSON 错误。
|
||||
max-file-size: ${DEBUG_EML_UPLOAD_DEV_MULTIPART_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_FILE_BYTES:20971520}}
|
||||
max-request-size: ${DEBUG_EML_UPLOAD_DEV_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_DEV_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:25165824}}}}
|
||||
|
||||
source-message:
|
||||
original-read:
|
||||
|
||||
@@ -9,9 +9,9 @@ spring:
|
||||
enabled: true
|
||||
servlet:
|
||||
multipart:
|
||||
# prod Debug EML 如被显式开启,multipart 上限必须先放行到业务文件上限,再由服务层做受控校验。
|
||||
max-file-size: ${DEBUG_EML_UPLOAD_PROD_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MAX_FILE_BYTES:10485760}}
|
||||
max-request-size: ${DEBUG_EML_UPLOAD_PROD_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:12582912}}
|
||||
# prod Debug EML 如被显式开启,multipart 上限应高于业务上限,便于服务层返回受控错误。
|
||||
max-file-size: ${DEBUG_EML_UPLOAD_PROD_MULTIPART_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_FILE_BYTES:20971520}}
|
||||
max-request-size: ${DEBUG_EML_UPLOAD_PROD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_PROD_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:25165824}}}}
|
||||
|
||||
source-message:
|
||||
original-read:
|
||||
|
||||
@@ -9,9 +9,9 @@ spring:
|
||||
enabled: true
|
||||
servlet:
|
||||
multipart:
|
||||
# test Debug EML multipart 上限必须不小于业务文件上限,避免请求进入 Controller 前被 413 拦截。
|
||||
max-file-size: ${DEBUG_EML_UPLOAD_TEST_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MAX_FILE_BYTES:10485760}}
|
||||
max-request-size: ${DEBUG_EML_UPLOAD_TEST_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:12582912}}
|
||||
# test multipart 上限高于业务上限,超限文件由 Debug EML 服务层返回受控 JSON 错误。
|
||||
max-file-size: ${DEBUG_EML_UPLOAD_TEST_MULTIPART_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_FILE_BYTES:20971520}}
|
||||
max-request-size: ${DEBUG_EML_UPLOAD_TEST_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_TEST_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:25165824}}}}
|
||||
|
||||
source-message:
|
||||
original-read:
|
||||
|
||||
@@ -8,9 +8,9 @@ spring:
|
||||
enabled: true
|
||||
servlet:
|
||||
multipart:
|
||||
# Debug EML 默认允许 10MB 文件;request 上限略大于文件上限,给 multipart 边界和字段留空间。
|
||||
max-file-size: ${DEBUG_EML_UPLOAD_MAX_FILE_BYTES:10485760}
|
||||
max-request-size: ${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:12582912}
|
||||
# multipart 需要高于 Debug EML 业务文件上限,避免超限文件在进入 Controller 前被框架直接 413 拦截。
|
||||
max-file-size: ${DEBUG_EML_UPLOAD_MULTIPART_MAX_FILE_BYTES:20971520}
|
||||
max-request-size: ${DEBUG_EML_UPLOAD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:25165824}}
|
||||
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
-- M001/M003 数据库字符排序规则加固:统一把现有字符串列改为大小写敏感。
|
||||
-- 背景:外部系统 opaque id 可能只差大小写,例如 Outlook external_message_id 中的 X/x。
|
||||
-- MySQL 默认 *_ci collation 会把这类值当成相同字符串,导致幂等查询和唯一键误判。
|
||||
-- 说明:以下 MySQL 版本注释在 MySQL 8.0+ 会执行;H2 MySQL Mode 会把它们当注释跳过,避免本地 H2 测试库不支持 utf8mb4_bin 语法。
|
||||
|
||||
-- SourceMessage:来源消息、正文、媒体、原文审计和重复投递诊断。
|
||||
/*!80000 ALTER TABLE platform_source_message_inbox DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_source_message_inbox CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_source_message_inbox
|
||||
MODIFY COLUMN external_message_id VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL COMMENT '外部邮件系统中的单封邮件唯一 ID,对应 AgentBus source.external_message_id;解析失败时允许为空以保留失败记录' */;
|
||||
/*!80000 ALTER TABLE platform_source_message_payload DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_source_message_payload CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_source_message_body DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_source_message_body CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_source_message_media DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_source_message_media CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_source_message_original_access_audit DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_source_message_original_access_audit CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_source_message_payload_duplicate DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_source_message_payload_duplicate CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
|
||||
-- SuperAgent / Reservation:AI 入站、订单、任务、任务卡、审计和 OPERA 模拟。
|
||||
/*!80000 ALTER TABLE integration_superagent_task_result_nonce DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE integration_superagent_task_result_nonce CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE workflow_reservation_ai_batch DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE workflow_reservation_ai_batch CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE workflow_reservation_ai_transition DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE workflow_reservation_ai_transition CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE workflow_reservation_order DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE workflow_reservation_order CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE workflow_reservation_task DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE workflow_reservation_task CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE workflow_reservation_task_card DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE workflow_reservation_task_card CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE workflow_reservation_audit_log DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE workflow_reservation_audit_log CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE workflow_reservation_opera_operation DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE workflow_reservation_opera_operation CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE workflow_reservation_opera_operation_attempt DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE workflow_reservation_opera_operation_attempt CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
|
||||
-- Debug EML:调试上传链路中的外部 ID、OSS URL、SuperAgent 运行标识和状态。
|
||||
/*!80000 ALTER TABLE platform_debug_eml_superagent_run DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_debug_eml_superagent_run CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
|
||||
-- Identity / Access / Hotel / Menu:用户、角色、权限、酒店和菜单基础表。
|
||||
/*!80000 ALTER TABLE platform_user DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_user CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_user_session DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_user_session CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_role DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_role CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_permission DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_permission CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_user_role DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_user_role CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_role_permission DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_role_permission CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_hotel DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_hotel CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_user_hotel DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_user_hotel CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_menu DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
/*!80000 ALTER TABLE platform_menu CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;
|
||||
@@ -0,0 +1,57 @@
|
||||
package cn.nianxx.thhotel.platform.access.repository;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatNoException;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import cn.nianxx.thhotel.platform.access.domain.PlatformRolePermissionEntity;
|
||||
import cn.nianxx.thhotel.platform.access.domain.PlatformUserRoleEntity;
|
||||
import cn.nianxx.thhotel.platform.access.mapper.PlatformPermissionMapper;
|
||||
import cn.nianxx.thhotel.platform.access.mapper.PlatformRoleMapper;
|
||||
import cn.nianxx.thhotel.platform.access.mapper.PlatformRolePermissionMapper;
|
||||
import cn.nianxx.thhotel.platform.access.mapper.PlatformUserRoleMapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
class MybatisPlatformAccessRepositoryTest {
|
||||
|
||||
@Mock
|
||||
private PlatformRoleMapper roleMapper;
|
||||
@Mock
|
||||
private PlatformPermissionMapper permissionMapper;
|
||||
@Mock
|
||||
private PlatformUserRoleMapper userRoleMapper;
|
||||
@Mock
|
||||
private PlatformRolePermissionMapper rolePermissionMapper;
|
||||
|
||||
@Test
|
||||
void shouldIgnoreDuplicateKeyWhenEnsuringRolePermission() {
|
||||
MybatisPlatformAccessRepository repository = repository();
|
||||
when(rolePermissionMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
|
||||
doThrow(new DuplicateKeyException("duplicate role permission"))
|
||||
.when(rolePermissionMapper).insert(any(PlatformRolePermissionEntity.class));
|
||||
|
||||
assertThatNoException().isThrownBy(() -> repository.ensureRolePermission(1L, 2L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIgnoreDuplicateKeyWhenEnsuringUserRole() {
|
||||
MybatisPlatformAccessRepository repository = repository();
|
||||
when(userRoleMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
|
||||
doThrow(new DuplicateKeyException("duplicate user role"))
|
||||
.when(userRoleMapper).insert(any(PlatformUserRoleEntity.class));
|
||||
|
||||
assertThatNoException().isThrownBy(() -> repository.ensureUserRole(1L, 2L));
|
||||
}
|
||||
|
||||
private MybatisPlatformAccessRepository repository() {
|
||||
return new MybatisPlatformAccessRepository(roleMapper, permissionMapper, userRoleMapper, rolePermissionMapper);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result.ObjectStor
|
||||
import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.ObjectStorageService;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
@@ -152,10 +153,63 @@ class DebugEmlSuperAgentControllerTest {
|
||||
AND superagent_session_id = 'session-debug-001'
|
||||
AND superagent_run_id = 'run-debug-001'
|
||||
AND run_label = 'controller-test'
|
||||
AND safe_error_summary IS NULL
|
||||
""", Long.class);
|
||||
org.assertj.core.api.Assertions.assertThat(debugRunCount).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRecordPhaseBeforeUploadingOriginalEmlToOss() throws Exception {
|
||||
AtomicInteger uploadIndex = new AtomicInteger();
|
||||
when(objectStorageService.putObject(any())).thenAnswer(invocation -> {
|
||||
ObjectStoragePutRequest request = invocation.getArgument(0);
|
||||
if (uploadIndex.incrementAndGet() == 1) {
|
||||
Long phaseCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM platform_debug_eml_superagent_run
|
||||
WHERE run_label = 'phase-debug-upload'
|
||||
AND run_status = 'UPLOADING_ORIGINAL_EML'
|
||||
AND safe_error_summary LIKE '%上传原始 EML%'
|
||||
""", Long.class);
|
||||
org.assertj.core.api.Assertions.assertThat(phaseCount).isEqualTo(1L);
|
||||
}
|
||||
return new ObjectStoragePutResult(
|
||||
request.objectKey(),
|
||||
"https://oss.example.test/" + request.objectKey(),
|
||||
request.contentType(),
|
||||
request.sizeBytes());
|
||||
});
|
||||
when(superAgentOpenApiClient.invokeMailDebug(any())).thenAnswer(invocation -> {
|
||||
Long phaseCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM platform_debug_eml_superagent_run
|
||||
WHERE run_label = 'phase-debug-upload'
|
||||
AND run_status = 'CALLING_SUPERAGENT'
|
||||
AND safe_error_summary LIKE '%调用 SuperAgent Open API%'
|
||||
""", Long.class);
|
||||
org.assertj.core.api.Assertions.assertThat(phaseCount).isEqualTo(1L);
|
||||
return new SuperAgentOpenApiResult(
|
||||
"session-debug-phase",
|
||||
"run-debug-phase",
|
||||
"profile-debug",
|
||||
"profile-version-debug",
|
||||
"debug-model",
|
||||
"{\"ai_task_results\":[{\"task_type\":\"New Booking\"}]}",
|
||||
11,
|
||||
7,
|
||||
18,
|
||||
List.of("metadata", "values", "end"));
|
||||
});
|
||||
|
||||
mockMvc.perform(multipart(ENDPOINT)
|
||||
.file(emlFile())
|
||||
.param("hotel_id", "HOTEL-TEST")
|
||||
.param("run_label", "phase-debug-upload")
|
||||
.header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.status").value("SUPERAGENT_SUCCEEDED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateIndependentSourceMessageForRepeatedDebugUpload() throws Exception {
|
||||
mockStorageAndSuperAgentSuccess();
|
||||
|
||||
@@ -23,9 +23,9 @@ class DebugEmlMultipartConfigurationTest {
|
||||
private DebugEmlSuperAgentProperties debugEmlProperties;
|
||||
|
||||
@Test
|
||||
void shouldAllowMultipartRequestBeforeDebugEmlBusinessLimit() {
|
||||
void shouldLeaveHeadroomForDebugEmlBusinessLimit() {
|
||||
assertThat(multipartProperties.getMaxFileSize().toBytes())
|
||||
.isGreaterThanOrEqualTo(debugEmlProperties.getMaxFileBytes());
|
||||
.isGreaterThan(debugEmlProperties.getMaxFileBytes());
|
||||
assertThat(multipartProperties.getMaxRequestSize().toBytes())
|
||||
.isGreaterThan(debugEmlProperties.getMaxFileBytes());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package cn.nianxx.thhotel.platform.identity.repository;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import cn.nianxx.thhotel.ThHotelApplication;
|
||||
import cn.nianxx.thhotel.platform.identity.common.enums.PlatformUserStatus;
|
||||
import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity;
|
||||
import java.time.LocalDateTime;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
@SpringBootTest(
|
||||
classes = ThHotelApplication.class,
|
||||
properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:m003_identity_repository_review;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE",
|
||||
"auth.bootstrap.admin.username=",
|
||||
"auth.bootstrap.admin.password="
|
||||
})
|
||||
@ActiveProfiles("test")
|
||||
class PlatformIdentityRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private PlatformIdentityRepository identityRepository;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@AfterEach
|
||||
void cleanReviewRows() {
|
||||
jdbcTemplate.update("DELETE FROM platform_user WHERE username LIKE 'm003-review-%'");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldOnlyCountActiveSuperAdminUsers() {
|
||||
long baseline = identityRepository.countSuperAdminUsers();
|
||||
identityRepository.insertUser(user("m003-review-disabled-super-admin", PlatformUserStatus.DISABLED.name(), true));
|
||||
|
||||
assertThat(identityRepository.countSuperAdminUsers()).isEqualTo(baseline);
|
||||
|
||||
identityRepository.insertUser(user("m003-review-active-super-admin", PlatformUserStatus.ACTIVE.name(), true));
|
||||
|
||||
assertThat(identityRepository.countSuperAdminUsers()).isEqualTo(baseline + 1);
|
||||
}
|
||||
|
||||
private PlatformUserEntity user(String username, String status, boolean superAdmin) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
PlatformUserEntity user = new PlatformUserEntity();
|
||||
user.setUsername(username);
|
||||
user.setPasswordHash("{bcrypt}placeholder");
|
||||
user.setDisplayName(username);
|
||||
user.setUserStatus(status);
|
||||
user.setSuperAdmin(superAdmin);
|
||||
user.setPasswordChangedAt(now);
|
||||
user.setCreatedAt(now);
|
||||
user.setUpdatedAt(now);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package cn.nianxx.thhotel.platform.identity.service.impl;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import cn.nianxx.thhotel.ThHotelApplication;
|
||||
import java.time.LocalDateTime;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
@SpringBootTest(
|
||||
classes = ThHotelApplication.class,
|
||||
properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:m003_bootstrap_review;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE",
|
||||
"auth.bootstrap.admin.username=",
|
||||
"auth.bootstrap.admin.password=",
|
||||
"auth.bootstrap.default-hotel-id=HOTEL-TEST",
|
||||
"auth.bootstrap.default-hotel-name=测试酒店"
|
||||
})
|
||||
@ActiveProfiles("test")
|
||||
class PlatformIdentityBootstrapRunnerTest {
|
||||
|
||||
@Autowired
|
||||
private PlatformIdentityBootstrapRunner bootstrapRunner;
|
||||
|
||||
@Autowired
|
||||
private AuthProperties authProperties;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@BeforeEach
|
||||
void cleanReviewRows() {
|
||||
authProperties.getBootstrap().getAdmin().setUsername("");
|
||||
authProperties.getBootstrap().getAdmin().setPassword("");
|
||||
authProperties.getBootstrap().getAdmin().setDisplayName("系统管理员");
|
||||
jdbcTemplate.update("DELETE FROM platform_user_role WHERE user_id IN (SELECT id FROM platform_user WHERE username LIKE 'm003-review-%')");
|
||||
jdbcTemplate.update("DELETE FROM platform_user_hotel WHERE user_id IN (SELECT id FROM platform_user WHERE username LIKE 'm003-review-%')");
|
||||
jdbcTemplate.update("DELETE FROM platform_user WHERE username LIKE 'm003-review-%'");
|
||||
jdbcTemplate.update("DELETE FROM platform_role_permission WHERE role_id IN (SELECT id FROM platform_role WHERE role_code = 'RESERVATION_VIEWER') AND permission_id IN (SELECT id FROM platform_permission WHERE permission_code = 'SYSTEM_DEBUG_EML_RUN')");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateBootstrapAdminWhenOnlyDisabledSuperAdminExists() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO platform_user (
|
||||
id, username, password_hash, display_name, user_status, super_admin,
|
||||
password_changed_at, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
9003001001L,
|
||||
"m003-review-disabled-admin",
|
||||
"{bcrypt}disabled",
|
||||
"Disabled Admin",
|
||||
"DISABLED",
|
||||
true,
|
||||
now,
|
||||
now,
|
||||
now);
|
||||
authProperties.getBootstrap().getAdmin().setUsername("m003-review-recovered-admin");
|
||||
authProperties.getBootstrap().getAdmin().setPassword("Recovered@123456");
|
||||
authProperties.getBootstrap().getAdmin().setDisplayName("恢复管理员");
|
||||
|
||||
bootstrapRunner.run((ApplicationArguments) null);
|
||||
|
||||
Integer activeSuperAdminCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM platform_user
|
||||
WHERE username = 'm003-review-recovered-admin'
|
||||
AND user_status = 'ACTIVE'
|
||||
AND super_admin = 1
|
||||
""", Integer.class);
|
||||
assertThat(activeSuperAdminCount).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRemoveStaleBuiltInRolePermissionsDuringBootstrap() {
|
||||
Long viewerRoleId = jdbcTemplate.queryForObject("""
|
||||
SELECT id FROM platform_role WHERE role_code = 'RESERVATION_VIEWER'
|
||||
""", Long.class);
|
||||
Long debugPermissionId = jdbcTemplate.queryForObject("""
|
||||
SELECT id FROM platform_permission WHERE permission_code = 'SYSTEM_DEBUG_EML_RUN'
|
||||
""", Long.class);
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO platform_role_permission (id, role_id, permission_id, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", 9003002001L, viewerRoleId, debugPermissionId, LocalDateTime.now());
|
||||
|
||||
bootstrapRunner.run((ApplicationArguments) null);
|
||||
|
||||
Integer staleCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM platform_role_permission
|
||||
WHERE role_id = ?
|
||||
AND permission_id = ?
|
||||
""", Integer.class, viewerRoleId, debugPermissionId);
|
||||
assertThat(staleCount).isZero();
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ import cn.nianxx.thhotel.platform.message.repository.SourceMessageInboxRepositor
|
||||
import cn.nianxx.thhotel.platform.message.service.impl.SourceMessageCaptureServiceImpl;
|
||||
import cn.nianxx.thhotel.platform.message.service.impl.SourceMessageSafetySanitizer;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
@@ -33,6 +35,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
@@ -221,6 +224,78 @@ class SourceMessageCaptureServiceImplTest {
|
||||
assertThat(inbox.getUpdatedAt()).isEqualTo(inbox.getCreatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCaptureCaseVariantExternalMessageIdsAsDifferentMessages() {
|
||||
CaptureSourceMessageCommand upperCaseCommand = new CaptureSourceMessageCommand(
|
||||
"HOTEL-TEST",
|
||||
"AGENTBUS",
|
||||
"EMAIL",
|
||||
"mail-case-sensitive-X",
|
||||
"conversation-case-sensitive",
|
||||
"frame-case-sensitive-001",
|
||||
"session-case-sensitive",
|
||||
Instant.parse("2026-07-06T09:20:00Z"),
|
||||
"guest@example.test",
|
||||
"Case sensitive delivery",
|
||||
"Upper case external id",
|
||||
"<html>Upper case external id</html>",
|
||||
"{\"source\":{\"external_message_id\":\"mail-case-sensitive-X\"},\"version\":1}",
|
||||
"agentbus-outlook-v1",
|
||||
List.of()
|
||||
);
|
||||
CaptureSourceMessageCommand lowerCaseCommand = new CaptureSourceMessageCommand(
|
||||
"HOTEL-TEST",
|
||||
"AGENTBUS",
|
||||
"EMAIL",
|
||||
"mail-case-sensitive-x",
|
||||
"conversation-case-sensitive",
|
||||
"frame-case-sensitive-002",
|
||||
"session-case-sensitive",
|
||||
Instant.parse("2026-07-06T09:21:00Z"),
|
||||
"guest@example.test",
|
||||
"Case sensitive delivery",
|
||||
"Lower case external id",
|
||||
"<html>Lower case external id</html>",
|
||||
"{\"source\":{\"external_message_id\":\"mail-case-sensitive-x\"},\"version\":1}",
|
||||
"agentbus-outlook-v1",
|
||||
List.of()
|
||||
);
|
||||
|
||||
SourceMessageCaptureResult upperCaseResult = captureService.capture(upperCaseCommand);
|
||||
SourceMessageCaptureResult lowerCaseResult = captureService.capture(lowerCaseCommand);
|
||||
|
||||
assertThat(upperCaseResult.created()).isTrue();
|
||||
assertThat(lowerCaseResult.created()).isTrue();
|
||||
assertThat(lowerCaseResult.inboxId()).isNotEqualTo(upperCaseResult.inboxId());
|
||||
|
||||
List<String> externalMessageIds = inboxMapper.selectList(Wrappers.<SourceMessageInboxEntity>lambdaQuery()
|
||||
.eq(SourceMessageInboxEntity::getHotelId, "HOTEL-TEST")
|
||||
.eq(SourceMessageInboxEntity::getProvider, "AGENTBUS")
|
||||
.eq(SourceMessageInboxEntity::getChannel, "EMAIL")
|
||||
.eq(SourceMessageInboxEntity::getExternalConversationId, "conversation-case-sensitive")
|
||||
.orderByAsc(SourceMessageInboxEntity::getExternalMessageId))
|
||||
.stream()
|
||||
.map(SourceMessageInboxEntity::getExternalMessageId)
|
||||
.toList();
|
||||
assertThat(externalMessageIds)
|
||||
.containsExactlyInAnyOrder("mail-case-sensitive-X", "mail-case-sensitive-x");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldProvideMysqlMigrationForCaseSensitiveStringCollation() throws IOException {
|
||||
ClassPathResource migration = new ClassPathResource(
|
||||
"db/migration/V13__make_existing_string_columns_case_sensitive.sql");
|
||||
|
||||
assertThat(migration.exists()).isTrue();
|
||||
|
||||
String sql = migration.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(sql).contains("DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin");
|
||||
assertThat(sql).contains("ALTER TABLE platform_source_message_inbox");
|
||||
assertThat(sql).contains(
|
||||
"MODIFY COLUMN external_message_id VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin");
|
||||
assertThat(sql).contains("ALTER TABLE platform_source_message_payload_duplicate");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPersistFailedInboxWhenExternalMessageIdIsMissing() {
|
||||
CaptureSourceMessageCommand command = new CaptureSourceMessageCommand(
|
||||
|
||||
Reference in New Issue
Block a user