提交一次代码

This commit is contained in:
andy
2026-07-12 09:57:54 +08:00
parent 9bd6bc1cd0
commit 6e47158d9e
46 changed files with 3524 additions and 48 deletions

View File

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

View File

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

View File

@@ -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 APIsource_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 状态。
*/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 / ReservationAI 入站、订单、任务、任务卡、审计和 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 */;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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