From 7e276469eff909344b35f3c997a21034502f4062 Mon Sep 17 00:00:00 2001 From: andy Date: Mon, 6 Jul 2026 21:23:57 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20SourceMessage=20Inbox=20?= =?UTF-8?q?=E4=B8=8E=20AgentBus=20=E5=85=A5=E7=AB=99=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 16 + AGENTS.md | 6 + README.md | 75 ++++ ...nd-base-structure-pagination-guidelines.md | 29 +- .../backend-development-guidelines.md | 23 +- docs/project/README.md | 1 + .../project/backend-development-guidelines.md | 63 +++- docs/project/go-live-notes.md | 250 +++++++++++++ ...gent-agentbus-project-integration-guide.md | 28 +- server/mvnw | 2 + server/pom.xml | 94 +++++ .../cn/nianxx/thhotel/ThHotelApplication.java | 17 + .../adapter/AgentBusConnectionStatus.java | 126 +++++++ .../adapter/AgentBusFrameProcessResult.java | 40 +++ .../adapter/AgentBusFrameProcessor.java | 118 ++++++ .../agentbus/adapter/AgentBusProperties.java | 145 ++++++++ .../adapter/AgentBusSourceMessageAdapter.java | 191 ++++++++++ .../adapter/AgentBusStatusSnapshot.java | 22 ++ .../adapter/AgentBusWebSocketClient.java | 235 ++++++++++++ .../common/config/MybatisPlusConfig.java | 21 ++ .../common/dto/SourceMessageInboxDraft.java | 33 ++ .../dto/SourceMessageInboxSnapshot.java | 24 ++ ...SourceMessageOriginalAccessAuditDraft.java | 15 + .../dto/SourceMessageOriginalContent.java | 14 + .../dto/SourceMessageOriginalMediaItem.java | 14 + .../dto/SourceMessageSummaryResponse.java | 23 ++ .../enums/SourceMessageBodyContentType.java | 19 + .../SourceMessageCaptureFailureReason.java | 22 ++ .../enums/SourceMessageCaptureStatus.java | 20 ++ .../common/enums/SourceMessageMediaType.java | 20 ++ .../SourceMessageOriginalAccessResult.java | 19 + .../request/CaptureSourceMessageCommand.java | 26 ++ .../request/CaptureSourceMessageMedia.java | 14 + .../SourceMessageOriginalAccessRequest.java | 10 + .../request/SourceMessageQueryRequest.java | 14 + .../result/SourceMessageCaptureResult.java | 12 + .../SourceMessageOriginalMediaResponse.java | 14 + .../result/SourceMessageOriginalResponse.java | 16 + .../result/SourceMessagePageResult.java | 14 + .../control/SourceMessageController.java | 94 +++++ .../domain/SourceMessageBodyEntity.java | 85 +++++ .../domain/SourceMessageInboxEntity.java | 205 +++++++++++ .../domain/SourceMessageMediaEntity.java | 105 ++++++ ...ourceMessageOriginalAccessAuditEntity.java | 85 +++++ .../domain/SourceMessagePayloadEntity.java | 75 ++++ .../mapper/SourceMessageBodyMapper.java | 12 + .../mapper/SourceMessageInboxMapper.java | 12 + .../mapper/SourceMessageMediaMapper.java | 12 + ...ourceMessageOriginalAccessAuditMapper.java | 10 + .../mapper/SourceMessagePayloadMapper.java | 12 + .../MybatisSourceMessageInboxRepository.java | 318 ++++++++++++++++ .../SourceMessageInboxRepository.java | 58 +++ .../service/SourceMessageCaptureService.java | 18 + .../service/SourceMessageOriginalService.java | 28 ++ .../service/SourceMessageQueryService.java | 28 ++ .../impl/SourceMessageCaptureServiceImpl.java | 270 ++++++++++++++ .../SourceMessageOriginalServiceImpl.java | 141 ++++++++ .../impl/SourceMessageQueryServiceImpl.java | 105 ++++++ .../impl/SourceMessageSafetySanitizer.java | 58 +++ .../result/AgentBusProbeStatusResponse.java | 23 ++ .../AgentBusProbeStatusController.java | 30 ++ .../system/control/HealthController.java | 23 ++ .../service/AgentBusProbeStatusService.java | 16 + .../impl/AgentBusProbeStatusServiceImpl.java | 51 +++ server/src/main/resources/application.yml | 36 ++ .../V1__create_source_message_inbox.sql | 68 ++++ ...e_source_message_original_access_audit.sql | 13 + .../adapter/AgentBusFrameProcessorTest.java | 125 +++++++ .../AgentBusSourceMessageAdapterTest.java | 81 +++++ .../control/SourceMessageControllerTest.java | 158 ++++++++ .../SourceMessageCaptureServiceImplTest.java | 338 ++++++++++++++++++ .../AgentBusProbeStatusControllerTest.java | 37 ++ .../system/control/HealthControllerTest.java | 30 ++ .../src/test/resources/application-test.yml | 29 ++ .../org.mockito.plugins.MockMaker | 1 + 75 files changed, 4608 insertions(+), 27 deletions(-) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/project/go-live-notes.md create mode 100755 server/mvnw create mode 100644 server/pom.xml create mode 100644 server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusConnectionStatus.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessResult.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessor.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusProperties.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusSourceMessageAdapter.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusStatusSnapshot.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusWebSocketClient.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/common/config/MybatisPlusConfig.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageInboxDraft.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageInboxSnapshot.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageOriginalAccessAuditDraft.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageOriginalContent.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageOriginalMediaItem.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageSummaryResponse.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/common/enums/SourceMessageBodyContentType.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/common/enums/SourceMessageCaptureFailureReason.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/common/enums/SourceMessageCaptureStatus.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/common/enums/SourceMessageMediaType.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/common/enums/SourceMessageOriginalAccessResult.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/common/request/CaptureSourceMessageCommand.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/common/request/CaptureSourceMessageMedia.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/common/request/SourceMessageOriginalAccessRequest.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/common/request/SourceMessageQueryRequest.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageCaptureResult.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageOriginalMediaResponse.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageOriginalResponse.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessagePageResult.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageController.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/domain/SourceMessageBodyEntity.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/domain/SourceMessageInboxEntity.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/domain/SourceMessageMediaEntity.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/domain/SourceMessageOriginalAccessAuditEntity.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/domain/SourceMessagePayloadEntity.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/mapper/SourceMessageBodyMapper.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/mapper/SourceMessageInboxMapper.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/mapper/SourceMessageMediaMapper.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/mapper/SourceMessageOriginalAccessAuditMapper.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/mapper/SourceMessagePayloadMapper.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageCaptureService.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageOriginalService.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageQueryService.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageCaptureServiceImpl.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageOriginalServiceImpl.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageQueryServiceImpl.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageSafetySanitizer.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/system/common/result/AgentBusProbeStatusResponse.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/system/control/AgentBusProbeStatusController.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/system/control/HealthController.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/system/service/AgentBusProbeStatusService.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/system/service/impl/AgentBusProbeStatusServiceImpl.java create mode 100644 server/src/main/resources/application.yml create mode 100644 server/src/main/resources/db/migration/V1__create_source_message_inbox.sql create mode 100644 server/src/main/resources/db/migration/V2__create_source_message_original_access_audit.sql create mode 100644 server/src/test/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessorTest.java create mode 100644 server/src/test/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusSourceMessageAdapterTest.java create mode 100644 server/src/test/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerTest.java create mode 100644 server/src/test/java/cn/nianxx/thhotel/platform/message/service/SourceMessageCaptureServiceImplTest.java create mode 100644 server/src/test/java/cn/nianxx/thhotel/platform/system/control/AgentBusProbeStatusControllerTest.java create mode 100644 server/src/test/java/cn/nianxx/thhotel/platform/system/control/HealthControllerTest.java create mode 100644 server/src/test/resources/application-test.yml create mode 100644 server/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94a099c --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.DS_Store +.idea/ +*.iml + +.env +.env.* +!.env.example + +client/node_modules/ +client/dist/ +client/.vite/ + +server/target/ + +var/ +*.log diff --git a/AGENTS.md b/AGENTS.md index 7323ba8..eee16e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,6 +87,10 @@ - 命名、分层、DTO / Entity / Domain / Response 边界清晰。 - 分层结构、包结构、数据模型和字段映射必须配中文注释或中文说明,说明每层职责、依赖方向和关键字段含义。 +- 后端模块内部包结构必须保持稳定:`control` 放 Controller 具体实现,`service` 放 Service 接口,`service.impl` 放 Service 实现类,`domain` 放 Entity,`mapper` 放 Mapper,`repository` 放 Repository 接口和具体实现,通用数据载体放在对应模块的 `common.dto`、`common.request`、`common.result`,枚举放在 `common.enums`。 +- 外部系统适配类应放在对应 `integrations...adapter` 包;外部协议转换类使用 `Adapter`、`Converter` 等后缀,不使用 `Mapper` 命名,避免和 MyBatis Mapper 混淆。 +- `control`、`service`、`service.impl` 中的方法必须有中文注释,说明业务含义、边界或调用约束;`mapper` 中 MyBatis-Plus 自带继承方法不强制加注释,不为了重命名自带方法写薄 default 包装,自定义语义化查询或写入方法应按复杂度合理补充中文注释;`domain` 中 Entity 字段必须有中文注释,说明字段业务含义。 +- 生成代码前必须先参考 `AGENTS.md`、当前项目后端规范和既有代码习惯;遇到类职责或目录归属不确定时,先询问再继续,不能先写完再统一重构。 - 不写魔法值,稳定业务代码使用常量或枚举。 - 集合、空值、字符串、时间、金额和 `BigDecimal` 使用安全写法。 - 异常不吞掉,日志有上下文但不输出 Secret 或个人敏感信息。 @@ -127,6 +131,8 @@ Coding agent 开始任务前应先读取: 改文件前要说明计划;完成后要说明改了什么、如何验证、还有哪些风险或未完成项。 +生成或修改代码前,必须先参考当前代码开发规范和同模块既有代码习惯;如果目录归属、类名后缀、依赖方向或注释粒度不确定,先询问用户再继续。 + ## 12. 当前第一个 checkpoint 第一个建议 checkpoint: diff --git a/README.md b/README.md new file mode 100644 index 0000000..c9a7eee --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# TH Hotel Simple + +TH Hotel Simple 是一个前后端分离的酒店业务协同项目。当前后端优先建设平台级 SourceMessage Inbox,用于接收 AgentBus 邮件来源事实,并为后续 AI 识别、人工复核、Case / Task 流程提供可追溯输入。 + +## 目录说明 + +```text +client/ +后续前端应用目录。前端只调用本项目后端,不直接访问 AgentBus、SuperAgent、OHIP、数据库或任何 Secret。 + +server/ +后端 Spring Boot 服务目录。后端负责数据库、外部系统适配、业务规则、安全脱敏和审计边界。 + +docs/ +项目文档、可复用规范、当前项目需求和外部系统接入记录。 +``` + +## 后端命令 + +```bash +cd server +./mvnw test +./mvnw verify +./mvnw spring-boot:run +``` + +中文说明: + +- `./mvnw test`:运行后端单元测试和 Spring 集成测试。 +- `./mvnw verify`:运行 Maven verify 阶段,用于提交前完整检查。 +- `./mvnw spring-boot:run`:本地启动后端服务,默认端口为 `8080`。 + +### 测试数据库配置 + +默认 `test` profile 使用 H2 MySQL Mode,便于本地和 CI 在没有 MySQL 的情况下运行: + +```bash +cd server +./mvnw test +``` + +如需使用真实 MySQL 测试库,可启用 `test-mysql` profile,并通过环境变量注入连接信息: + +```bash +cd server +TH_HOTEL_TEST_DB_URL="jdbc:mysql://127.0.0.1:3306/th_hotel_test?useUnicode=true&characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC" \ +TH_HOTEL_TEST_DB_USERNAME=th_hotel_test \ +TH_HOTEL_TEST_DB_PASSWORD=your-local-password \ +./mvnw test -Dspring.profiles.active=test,test-mysql +``` + +中文说明:`test-mysql` 仅用于连接本地或 CI 的测试库,不能填写真实酒店库、真实客户数据或生产凭证。 + +## 当前健康检查 + +```text +GET /api/health +``` + +返回后端最小健康状态,用于本地开发、部署探活和前端连通性验证。 + +## M001 SourceMessage Inbox + +当前实现 checkpoint 聚焦: + +- AgentBus Outlook payload 映射为平台稳定捕获命令。 +- SourceMessage Inbox 幂等落库。 +- 正文、HTML、原始 payload 和媒体 URL 分表保存。 +- 列表和普通详情接口只返回安全摘要。 +- `/api/source-messages/{id}/original` 通过受控访问 key 读取原文,并写入访问审计。 +- AgentBus WebSocket 长连接默认关闭,开启后只接收入站 frame 并写入 SourceMessage Inbox。 +- `/api/system/agentbus-probe` 返回 AgentBus 连接状态和安全计数器,不返回 Token 或原始 frame。 +- 缺少外部邮件 ID 的 payload 保存为 `FAILED`,错误摘要不暴露正文或 Secret。 + +后续 checkpoint 再明确 SourceMessage Replay 到 MessageEvent / Evidence,以及具体业务页面展示位置。 diff --git a/docs/import/reusable/backend-base-structure-pagination-guidelines.md b/docs/import/reusable/backend-base-structure-pagination-guidelines.md index 62ef196..1285f81 100644 --- a/docs/import/reusable/backend-base-structure-pagination-guidelines.md +++ b/docs/import/reusable/backend-base-structure-pagination-guidelines.md @@ -38,25 +38,33 @@ server/src/main/java// ├── service/ │ ├── XxxService.java │ └── impl/XxxServiceImpl.java -├── api/ +├── control/ │ └── XxxController.java -├── dto/ -│ ├── XxxCreateRequest.java -│ ├── XxxUpdateRequest.java -│ └── XxxQueryRequest.java -└── vo/ - └── XxxResponse.java +├── repository/ +│ └── XxxRepository.java +└── common/ + ├── request/ + │ ├── XxxCreateRequest.java + │ ├── XxxUpdateRequest.java + │ └── XxxQueryRequest.java + ├── result/ + │ └── XxxResult.java + ├── dto/ + │ └── XxxSnapshot.java + └── enums/ + └── XxxStatus.java ``` 规则: - `platform` 放通用能力。 - `workflows` 放业务流程。 -- `integrations` 放外部系统适配器。 +- `integrations` 放外部系统适配器;外部协议适配类建议放在 `integrations///adapter`。 - 平台层不得依赖具体业务流程。 - 业务层不得直接依赖外部系统 DTO。 - Controller 不直接访问 Mapper。 - 前端不直接调用外部系统。 +- 外部协议到内部命令或 DTO 的转换类使用 `Adapter`、`Converter` 等后缀,不使用 `Mapper` 命名,避免和 MyBatis Mapper 混淆。 ## 3. 低耦合与可维护性 @@ -71,6 +79,7 @@ server/src/main/java// - 跨模块调用通过 Service、Port、API 契约或事件完成,不直接访问对方 Mapper、Entity 或私有工具类。 - Controller 只调用本模块或明确授权的应用服务,不跨层访问 Mapper 或 Adapter。 - Mapper 只负责本聚合或本表的数据访问,不承载跨业务编排。 +- 不为了重命名 MyBatis-Plus 自带方法而在 Mapper 中写薄 default 包装;语义化查询优先放在 Repository 或确有必要的自定义 Mapper 方法中。 - 公共能力先放在业务模块内,出现真实重复和稳定语义后再提升到 `platform`。 - 不为少量相似代码提前抽象大而全的 `CommonService`、`CommonUtil` 或通用模型。 - 当修改一个模块会牵动多个无关模块时,应优先检查依赖方向和接口边界。 @@ -269,6 +278,7 @@ XxxQueryRequest 规则: - 不使用一个大而全的 `Bo` 承载所有场景。 +- Request、Command、Query 条件建议放在模块内 `common/request`。 - 创建、更新、查询参数分开。 - 使用 Bean Validation 做基础校验。 - 查询对象可以包含分页、排序、时间范围等条件。 @@ -289,6 +299,7 @@ XxxQueryRequest - Long ID 返回给前端时按字符串处理。 - 时间统一按 API 规范格式化。 - 列表接口使用统一分页响应。 +- Result、分页结果、操作结果建议放在模块内 `common/result`;跨层 DTO、Snapshot、Draft 建议放在模块内 `common/dto`。 ## 10. Mapper 规范 @@ -312,6 +323,8 @@ public interface BaseMapperPlus extends BaseMapper { - Mapper 只负责数据库访问。 - 复杂业务判断不放在 Mapper。 - 优先使用 MyBatis-Plus Wrapper。 +- MyBatis-Plus 自带的 `selectById`、`insert`、`updateById` 等继承方法不需要额外 default 包装。 +- 自定义 default 方法只有在表达明确业务语义且能减少真实重复时才添加,不能只是改名转调。 - 手写 SQL 必须使用 `#{}` 参数绑定。 - 禁止使用 `${}` 拼接业务参数。 - 手写 SQL 要避免返回过宽字段。 diff --git a/docs/import/reusable/backend-development-guidelines.md b/docs/import/reusable/backend-development-guidelines.md index fe2ef7f..7290c70 100644 --- a/docs/import/reusable/backend-development-guidelines.md +++ b/docs/import/reusable/backend-development-guidelines.md @@ -33,13 +33,19 @@ server/src/main/java/ ├── config ├── modules │ └── -│ ├── controller +│ ├── control │ ├── service │ ├── service/impl │ ├── domain │ ├── mapper -│ └── repository +│ ├── repository +│ └── common +│ ├── dto +│ ├── request +│ ├── result +│ └── enums ├── integrations +│ └── //adapter └── support ``` @@ -52,6 +58,8 @@ server/src/main/java/ - 跨模块调用优先通过 Service、Port、API 契约或事件完成。 - 禁止直接访问其他模块的 Mapper、Entity 或内部实现。 - 外部系统通过 `integrations` 或 Adapter 隔离,业务层只依赖稳定端口。 +- 外部协议适配类放在具体集成模块的 `adapter` 包,例如 `integrations...adapter`。 +- 外部协议转换类使用 `Adapter`、`Converter` 等后缀,不使用 `Mapper` 命名,避免和 MyBatis Mapper 混淆。 - 外部 DTO、数据库 Entity、领域对象和 API Response 必须分离。 - 公共代码只有在出现真实重复和稳定语义后再抽取。 @@ -64,6 +72,11 @@ server/src/main/java/ - Repository 是领域侧持久化接口。 - Mapper / Entity / SQL 属于持久化细节,不应向上泄漏到 API 层。 - 外部调用通过 Port / Adapter 隔离,业务层不依赖厂商 SDK 或厂商 DTO。 +- Request、Command、Query 条件建议放在模块内 `common.request`。 +- Result、分页结果、操作结果建议放在模块内 `common.result`。 +- 跨层 DTO、Snapshot、Draft 等通用数据载体建议放在模块内 `common.dto`。 +- 枚举、状态码和失败原因建议放在模块内 `common.enums`。 +- 生成或迁移代码前应先阅读项目规范并查看同模块既有代码习惯;目录归属、类名后缀或依赖方向不明确时,先询问再实现。 ## 5. 数据建模规则 @@ -128,6 +141,8 @@ Secret 只能通过环境变量、本地 `.env` 或部署平台 Secret 注入。 - 架构文档、设计文档、目录结构、数据模型、字段映射和接口示例必须优先使用中文说明业务含义。 - 领域对象、应用服务、外部适配器、Controller、配置属性和复杂参数对象应说明业务含义、边界或调用约束。 +- Mapper 中 ORM 自带继承方法不强制补中文注释;不要为了重命名 `selectById`、`insert`、`updateById` 等自带方法而写薄包装。自定义语义化查询、写入、更新方法应根据业务复杂度合理补充中文注释。 +- Entity 字段建议有中文注释,说明字段业务含义、来源、代码值范围或安全限制。 - 复杂流程、幂等键、并发控制、事务边界、错误转换、外部字段映射和安全脱敏逻辑必须说明原因。 - 注释不得只复述类名、方法名或字段名。 - 不得用“TODO 待完善”替代真实说明。 @@ -164,11 +179,15 @@ cd server - 修改接口前确认领域模型、字段映射、前端影响和测试范围。 - 不修改与当前任务无关的用户变更。 - 修改后运行项目已配置的检查命令。 +- 生成或迁移代码前先参考当前项目代码规范和既有代码习惯;如果目录归属或命名不确定,先确认再继续。 - Git commit message 使用中文,清楚说明本次提交的业务或技术变更。 ## 13. 后端提交前检查清单 - [ ] Controller 是否没有直接访问 Mapper 或外部 Adapter? +- [ ] Request、Result、DTO 是否按语义放在 request、result、dto 目录,而不是混在 Service 或临时 application 包? +- [ ] 外部协议适配类是否放在 integrations 的 adapter 包,并避免使用 Mapper 命名混淆 ORM Mapper? +- [ ] Mapper 是否没有为了 ORM 自带继承方法写薄 default 包装? - [ ] 外部 DTO 是否没有进入领域模型? - [ ] DTO、Entity、Domain、Response 是否没有混用? - [ ] 跨模块调用是否通过稳定接口完成? diff --git a/docs/project/README.md b/docs/project/README.md index 5327bd2..486af6e 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -8,5 +8,6 @@ - `backend-development-guidelines.md`:当前项目后端专属规范。 - `frontend-development-guidelines.md`:当前项目前端专属规范。 +- `go-live-notes.md`:当前项目上线注意事项,记录上线前检查、环境变量、安全、AgentBus、验证和回滚。 - `requirements/M001-source-message-inbox-prd.md`:M001 邮件来源入口 PRD,记录 AgentBus 邮件 JSON 入库、历史查询、原文读取和媒体引用边界。 - `integrations/superagent-agentbus-project-integration-guide.md`:当前项目 SuperAgent 与 AgentBus 验证记录和项目级接入细节。 diff --git a/docs/project/backend-development-guidelines.md b/docs/project/backend-development-guidelines.md index e1737cd..c8cae2f 100644 --- a/docs/project/backend-development-guidelines.md +++ b/docs/project/backend-development-guidelines.md @@ -72,22 +72,63 @@ integrations - `platform` 保存部门中立能力,例如消息、证据、AI 调用审计、Operation、Receipt、审计。 - `workflows` 保存部门业务流程,当前明确的是 `reservation`。 -- `integrations` 保存外部系统适配器,例如 OHIP、SuperAgent、AgentBus。 +- `integrations` 保存外部系统适配器,例如 OHIP、SuperAgent、AgentBus;外部协议转换类应放入具体集成模块的 `adapter` 包。 - 平台核心不得依赖预订部专有字段。 - 部门工作流可以依赖平台核心,平台核心不能反向依赖部门模块。 - 外部系统 DTO、Oracle DTO、Provider DTO、领域模型和 API Response 必须分离。 - Oracle DTO 或生成代码只能位于 `integrations.ohip`,不得进入平台领域模型。 +- 外部协议到内部命令或 DTO 的转换类使用 `Adapter`、`Converter` 等能表达适配语义的后缀,不使用 `Mapper` 命名,避免和 MyBatis Mapper 混淆。 + +当前项目后端模块内部目录强制按以下结构组织: + +```text + +├── control +├── service +│ └── impl +├── domain +├── mapper +├── repository +└── common + ├── dto + ├── request + ├── result + └── enums +``` + +中文说明: + +| 目录 | 中文职责 | 放置内容 | +| --- | --- | --- | +| `control` | HTTP 入口层 | Controller 具体实现类,只处理请求契约、参数校验和响应映射 | +| `service` | 服务契约层 | Service 接口,只表达模块对外提供的稳定业务能力 | +| `service.impl` | 服务实现层 | Service 实现类,负责编排事务、幂等、Repository 和外部端口 | +| `domain` | 数据实体层 | Entity 类,字段必须有中文注释,不能与 Response 或外部 DTO 混用 | +| `mapper` | MyBatis 访问层 | Mapper 类和语义化 Mapper 方法,Controller 与 Service 不直接跨层暴露 Mapper | +| `repository` | 持久化封装层 | Repository 接口和具体实现,对 Service 屏蔽 Mapper 与数据库细节 | +| `common.dto` | 通用数据载体层 | 模块内部跨层传递的 DTO、快照、草稿等数据载体 | +| `common.request` | 请求模型层 | Controller、Service 或外部适配器输入侧的 Request、Command、Query 条件 | +| `common.result` | 结果模型层 | Service 或 Controller 输出侧的 Result、分页结果、操作结果 | +| `common.enums` | 模块枚举层 | 本模块稳定业务枚举、状态码和失败原因枚举 | + +后续生成或移动 Java 代码时必须严格遵守上述目录,不再新增 `api`、`application`、`persistence` 等同义包来承载这些职责,除非先更新本规范并说明迁移原因。已有或迁移来的 `application` 包中的 Request、Result、DTO 类,应按语义移动到对应模块的 `common.request`、`common.result`、`common.dto`。如果类职责或目录归属不明确,必须先询问再继续。 ## 4. Controller / Service / Repository 规则 - Controller 只做 HTTP 契约、参数校验、权限入口和响应映射。 - Controller 不直接访问 Mapper。 - Controller 不直接调用外部适配器。 -- Application Service 编排事务、领域对象、Repository 和外部端口。 -- Domain 对象表达稳定业务语义,不引入 HTTP、JSON、MyBatis 或外部 Provider 细节。 -- Repository 是领域侧持久化接口。 -- Infrastructure / Persistence 负责 Entity、Mapper 和数据库细节。 +- Service 接口位于 `service` 包,表达模块对外提供的稳定业务能力。 +- Service 实现类位于 `service.impl` 包,编排事务、Entity、Repository 和外部端口。 +- Request、Command、Query 条件位于 `common.request` 包。 +- Result、分页结果、操作结果位于 `common.result` 包。 +- 跨层 DTO、Snapshot、Draft 等通用数据载体位于 `common.dto` 包。 +- Entity 位于 `domain` 包,表达数据库表字段与业务含义,不与 DTO、Response 或外部 Provider DTO 混用。 +- Mapper 位于 `mapper` 包,只负责本模块数据库表访问和语义化查询方法。 +- Repository 位于 `repository` 包,负责封装 Mapper 和数据库细节,作为 Service 的持久化边界。 - 外部调用通过端口和 Adapter 隔离,业务层不依赖厂商 SDK 或厂商 DTO。 +- 外部协议适配类位于对应 `integrations...adapter` 包,负责把外部 DTO 或 JSON 转换为内部稳定命令。 +- 生成或迁移代码前必须先阅读本规范并查看同模块既有代码习惯;如果放置目录、类名后缀或依赖方向不确定,先询问再实现。 ## 5. 数据建模规则 @@ -177,6 +218,10 @@ groupCode - 架构文档、设计文档、目录结构、数据模型、字段映射和接口示例必须优先使用中文说明业务含义。 - 领域对象、应用服务、外部适配器、Controller、配置属性和复杂参数对象应说明业务含义、边界或调用约束。 +- `control`、`service`、`service.impl` 中的方法必须有中文注释,说明业务动作、调用边界、幂等或安全约束。 +- `mapper` 中 MyBatis-Plus 自带继承方法不强制补中文注释;不要为了重命名 `selectById`、`insert`、`updateById` 等自带方法而写薄 default 包装。本项目自定义的语义化查询、写入、更新方法应根据业务复杂度合理补充中文注释。 +- `domain` 中 Entity 的每个字段必须有中文注释,说明字段业务含义、来源、代码值范围或安全限制。 +- `repository` 中对外暴露的方法必须有中文注释,说明持久化语义和是否返回安全字段。 - 复杂流程、幂等键、并发控制、事务边界、错误转换、外部系统字段映射和安全脱敏逻辑必须说明原因。 - 注释不得只复述类名、方法名或字段名。 - 不得用“TODO 待完善”替代真实说明。 @@ -239,6 +284,8 @@ cd server ./mvnw -Dtest=SomeFocusedTest test ``` +默认 `test` profile 使用 H2 MySQL Mode,保证普通测试不依赖本机 MySQL。需要连接真实测试 MySQL 时,使用 `test,test-mysql` profile,并通过 `TH_HOTEL_TEST_DB_URL`、`TH_HOTEL_TEST_DB_USERNAME`、`TH_HOTEL_TEST_DB_PASSWORD` 注入测试库连接信息。测试 MySQL 只能使用本地或 CI 测试库,不得连接生产库或包含真实酒店、客户、支付数据的数据库。 + 如果命令尚未配置或因环境问题无法运行,必须明确说明,不能假装通过。 ## 16. Git 与协作流程 @@ -254,7 +301,13 @@ cd server ## 17. 后端提交前检查清单 - [ ] 是否遵守 platform / workflows / integrations 分层? +- [ ] 模块内部是否遵守 `control` / `service` / `service.impl` / `domain` / `mapper` / `repository` / `common.dto` / `common.request` / `common.result` / `common.enums` 目录规则? - [ ] Controller 是否没有直接访问 Mapper 或外部 Adapter? +- [ ] Request、Result、DTO 是否放在 `common.request`、`common.result`、`common.dto`,而不是混在 `service` 或临时 `application` 包? +- [ ] `control`、`service`、`service.impl`、`repository` 方法是否有中文注释? +- [ ] Mapper 自定义语义化方法是否按复杂度合理补充中文注释,且未为了 MyBatis-Plus 自带继承方法强行加无意义注释? +- [ ] 外部协议适配类是否放在 `integrations...adapter`,并避免使用 `Mapper` 命名混淆 MyBatis Mapper? +- [ ] Entity 字段是否有中文注释? - [ ] 外部 DTO 是否没有进入领域模型? - [ ] 写接口是否有幂等、版本或审计设计? - [ ] Flyway SQL 是否有规范中文注释? diff --git a/docs/project/go-live-notes.md b/docs/project/go-live-notes.md new file mode 100644 index 0000000..cd32597 --- /dev/null +++ b/docs/project/go-live-notes.md @@ -0,0 +1,250 @@ +# TH Hotel 上线注意事项 + +本文给产品、研发、测试、运维和后续协作 agent 使用,目标是把上线前必须确认的事项放在同一个地方。这里记录的是当前项目专属要求,不作为可整份复制到其他项目的通用模板。 + +## 1. 当前上线范围 + +当前后端已经具备以下能力: + +- `GET /api/health`:后端健康检查。 +- `GET /api/source-messages`:查询 SourceMessage Inbox 安全摘要。 +- `GET /api/source-messages/{id}`:查询单条 SourceMessage 安全摘要。 +- `GET /api/source-messages/{id}/original`:受控读取邮件原文、HTML 和媒体 URL,并记录访问审计。 +- `GET /api/system/agentbus-probe`:查看 AgentBus WebSocket 连接状态和安全计数器。 +- AgentBus WebSocket 入站链路:默认关闭,开启后只把业务 frame 写入 SourceMessage Inbox。 + +当前不要把以下能力当作已上线: + +- SourceMessage Replay 到 MessageEvent / Evidence。 +- AI 识别、Case 匹配、Task 创建、Operation、Receipt。 +- 自动 ACK、`task.result` 或客户回复。 +- 业务前端页面展示邮件原文。 +- OHIP 或其他业务系统写操作。 + +## 2. 上线前必须确认 + +上线前至少确认以下事项: + +- 当前分支、提交和部署包来源清楚,不能混入本地临时文件、真实 Secret、真实客户邮件样本或构建产物。 +- `server` 后端通过完整检查:`cd server && ./mvnw verify`。 +- 生产或 UAT 数据库已经备份,并确认 Flyway migration 只新增不修改历史脚本。 +- 所有 Secret 都通过环境变量、部署平台 Secret 或密钥管理系统注入,不写入仓库、镜像、前端环境变量或普通配置文件。 +- 生产默认不保存 AgentBus raw frame 样本。 +- AgentBus 实时链路开启前,已经确认 WebSocket URL、Token、Bot Address、外部消息幂等键和断线重连语义。 +- 原文读取接口开启前,已经确认谁可以使用、在哪些场景使用、如何轮换访问 key。 +- 日志采集、错误响应和监控面板都不会展示邮件正文、HTML、附件 URL、Token、Cookie、客户姓名、邮箱、电话或支付信息。 + +## 3. 环境变量 + +### 3.1 数据库 + +| 变量 | 是否 Secret | 上线注意事项 | +| --- | --- | --- | +| `TH_HOTEL_DB_URL` | 否 | 指向目标环境数据库。URL 包含 `&` 时要加引号。 | +| `TH_HOTEL_DB_USERNAME` | 是 | 使用最小权限账号,不使用个人账号。 | +| `TH_HOTEL_DB_PASSWORD` | 是 | 只能通过 Secret 注入,不写入仓库。 | +| `TH_HOTEL_DB_DRIVER` | 否 | MySQL 使用 `com.mysql.cj.jdbc.Driver`。 | + +### 3.2 SourceMessage + +| 变量 | 是否 Secret | 上线注意事项 | +| --- | --- | --- | +| `SOURCE_MESSAGE_ORIGINAL_READ_ACCESS_KEY` | 是 | 原文读取临时访问 key。未配置时原文读取默认关闭。 | + +注意: + +- 原文读取 key 不是用户体系,后续接入正式登录和角色权限后应替换。 +- 任何能读取原文的调用都必须有调用方和访问场景,并写入审计表。 + +### 3.3 AgentBus + +| 变量 | 是否 Secret | 上线注意事项 | +| --- | --- | --- | +| `AGENTBUS_PROBE_ENABLED` | 否 | 是否启用 WebSocket 长连接。生产首次上线建议先保持 `false`,完成连通性窗口后再打开。 | +| `AGENTBUS_WS_URL` | 否 | AgentBus WebSocket 地址。 | +| `AGENTBUS_WS_TOKEN` | 是 | WebSocket 鉴权 Token,只能通过 Secret 注入。 | +| `AGENTBUS_WS_RECONNECT_DELAY` | 否 | 断线重连间隔,默认 `5s`。 | +| `AGENTBUS_CONNECT_TIMEOUT` | 否 | 连接超时,默认 `15s`。 | +| `AGENTBUS_MAX_FRAME_BYTES` | 否 | 单个入站 frame 最大字节数,默认 `1048576`。 | +| `AGENTBUS_CAPTURE_ENABLED` | 否 | 是否把业务 frame 写入 SourceMessage Inbox。 | +| `AGENTBUS_DEFAULT_HOTEL_ID` | 否 | AgentBus 未提供酒店上下文时的默认业务上下文。 | + +注意: + +- `AGENTBUS_PROBE_ENABLED=true` 只表示启用连接和入站接收,不代表可以回复客户。 +- `AGENTBUS_CAPTURE_ENABLED=false` 时,业务 frame 会被忽略,不会写入 SourceMessage Inbox。 +- 当前实现不发送 ACK、不发送 `task.result`、不自动回复客户。 + +## 4. 数据库上线注意事项 + +当前 SourceMessage 相关 migration: + +- `server/src/main/resources/db/migration/V1__create_source_message_inbox.sql` +- `server/src/main/resources/db/migration/V2__create_source_message_original_access_audit.sql` + +上线前确认: + +- 目标数据库为空库或 Flyway history 与当前代码一致。 +- MySQL 版本满足项目要求,默认使用 MySQL 8.0+。 +- migration 在 UAT 或测试库已经跑过。 +- 表和字段中文注释能正常创建。 +- 数据库时间按 UTC 写入,接口层负责返回 ISO 8601。 + +禁止事项: + +- 禁止直接修改已发布 migration。 +- 禁止手工改生产表结构后再让代码“凑合跑”。 +- 禁止把真实邮件正文、附件 URL 或客户数据做成测试种子数据提交。 + +## 5. 安全与日志 + +上线前必须确认普通日志、错误响应和状态接口不会输出: + +- Authorization、Cookie、CSRF Token。 +- Provider API Key、AgentBus Token、数据库密码。 +- 邮件正文、HTML、附件 URL、原始 payload。 +- 客户姓名、完整邮箱、电话、证件号。 +- 支付信息。 + +SourceMessage 普通列表和普通详情只能返回安全摘要: + +- 可以返回:SourceMessage ID、酒店 ID、provider、channel、外部邮件 ID、外部邮件链 ID、状态、时间、发送人摘要、主题摘要、安全短摘要。 +- 不应返回:完整正文、HTML、附件 URL、原始 payload。 + +原文读取接口只允许在明确授权场景下使用: + +```text +GET /api/source-messages/{id}/original +Header: X-TH-Hotel-Source-Original-Read-Key +Header: X-TH-Hotel-Actor +Header: X-TH-Hotel-Access-Scene +``` + +前端展示 `htmlBody` 前必须 sanitize。后端返回 `htmlSanitizeRequired=true` 是提醒前端不要直接信任 HTML。 + +## 6. AgentBus 上线注意事项 + +AgentBus 是消息入口,不是 AI Provider。生产实时链路必须保持以下边界: + +- 只写 SourceMessage Inbox。 +- 不创建 MessageEvent、Evidence、Case、Task、Operation、Receipt。 +- 不调用 OHIP、ERP、支付系统等业务写接口。 +- 不自动发送 ACK、`task.result` 或客户回复。 +- 不在普通日志里输出 raw frame、邮件正文、HTML 或附件 URL。 + +建议上线顺序: + +1. 保持 `AGENTBUS_PROBE_ENABLED=false`,先部署服务并确认健康检查。 +2. 确认数据库 migration 和 SourceMessage 查询接口正常。 +3. 配置 AgentBus URL 和 Token,但仍保持连接关闭。 +4. 在约定观察窗口打开 `AGENTBUS_PROBE_ENABLED=true`。 +5. 观察 `/api/system/agentbus-probe`,确认连接状态、`sessionReady`、计数器和最近错误代码。 +6. 用合成测试邮件验证 SourceMessage Inbox 是否写入。 +7. 确认日志和监控没有泄露 raw frame、正文或附件 URL。 + +如果出现异常: + +- 先关闭 `AGENTBUS_PROBE_ENABLED`,停止接收入站 frame。 +- 如果只是想暂停入库但保留连接,可关闭 `AGENTBUS_CAPTURE_ENABLED`。 +- 保留状态接口、应用日志和数据库记录用于排查,但不要导出真实邮件正文或附件 URL。 + +## 7. 上线后冒烟验证 + +后端服务启动后,按顺序验证: + +```text +GET /api/health +``` + +期望: + +- HTTP 200。 +- `status = UP`。 + +```text +GET /api/system/agentbus-probe +``` + +期望: + +- HTTP 200。 +- 返回 `enabled`、`connected`、`sessionReady`、计数器和最近错误代码。 +- 响应中不包含 Token、Authorization、raw frame、payload 或邮件正文。 + +```text +GET /api/source-messages?pageNum=1&pageSize=20 +``` + +期望: + +- HTTP 200。 +- 只返回安全摘要。 +- 不包含正文、HTML、附件 URL 或原始 payload。 + +```text +GET /api/source-messages/{id} +``` + +期望: + +- HTTP 200 或 404。 +- 如果存在记录,只返回安全摘要。 + +```text +GET /api/source-messages/{id}/original +``` + +期望: + +- 未携带正确访问 key 时返回 403。 +- 携带正确访问 key、调用方和访问场景时返回原文内容,并写入 `platform_source_message_original_access_audit`。 + +## 8. 监控建议 + +至少监控: + +- 应用进程是否存活。 +- `GET /api/health` 是否正常。 +- AgentBus `connected` 和 `sessionReady` 状态。 +- AgentBus `failedFrameCount`、`rejectedFrameCount` 是否持续增长。 +- SourceMessage Inbox 每小时入库数量是否异常突增或归零。 +- `FAILED` SourceMessage 数量和安全错误摘要。 +- 原文读取审计数量是否异常。 +- 数据库连接池、慢 SQL、磁盘空间和 migration 状态。 + +告警信息不得包含 Secret、正文、HTML、附件 URL 或客户个人信息。 + +## 9. 回滚与降级 + +优先降级开关: + +```text +AGENTBUS_PROBE_ENABLED=false +AGENTBUS_CAPTURE_ENABLED=false +SOURCE_MESSAGE_ORIGINAL_READ_ACCESS_KEY= +``` + +说明: + +- 关闭 `AGENTBUS_PROBE_ENABLED` 可以停止 WebSocket 入站连接。 +- 关闭 `AGENTBUS_CAPTURE_ENABLED` 可以保留连接但暂停写入 Inbox。 +- 清空 `SOURCE_MESSAGE_ORIGINAL_READ_ACCESS_KEY` 可以关闭原文读取接口。 + +数据库回滚注意: + +- 已执行的 migration 不应直接删除或手工回滚。 +- 如果新版本已写入 SourceMessage 数据,回滚应用前要确认旧版本是否能兼容新表存在。 +- 需要修复表结构时,应新增 migration,而不是修改已发布 migration。 + +## 10. 上线责任确认 + +上线前需要有人明确确认: + +- 部署版本:确认本次上线的分支、提交和构建产物。 +- 数据库:确认 migration、备份和连接信息。 +- Secret:确认所有密钥由部署平台注入。 +- AgentBus:确认是否开启 WebSocket,是否允许捕获入库。 +- 安全:确认日志、错误响应、状态接口和监控面板没有敏感数据。 +- 业务:确认当前上线范围不包含 replay、AI、Case、Task、客户回复或 OHIP 写操作。 + +只要上述任何一项没人确认,就不要打开生产实时入口。 diff --git a/docs/project/integrations/superagent-agentbus-project-integration-guide.md b/docs/project/integrations/superagent-agentbus-project-integration-guide.md index 1a312c7..554f12e 100644 --- a/docs/project/integrations/superagent-agentbus-project-integration-guide.md +++ b/docs/project/integrations/superagent-agentbus-project-integration-guide.md @@ -78,21 +78,22 @@ integrations └── agentbus ``` -TH Hotel 当前代码中的可参考文件: +TH Hotel 当前 M001 相关代码中的可参考文件: | 目的 | 参考文件 | | --- | --- | -| SuperAgent 配置 | `server/src/main/java/cn/nianxx/thhotel/integrations/ai/deerflow/SuperAgentProbeProperties.java` | -| SuperAgent HTTP 客户端 | `server/src/main/java/cn/nianxx/thhotel/integrations/ai/deerflow/SuperAgentOpenApiClient.java` | -| SuperAgent SSE 解析 | `server/src/main/java/cn/nianxx/thhotel/integrations/ai/deerflow/SuperAgentSseParser.java` | -| SuperAgent 能力适配器 | `server/src/main/java/cn/nianxx/thhotel/integrations/ai/deerflow/SuperAgentCapabilityAdapter.java` | -| SuperAgent 探针 API | `server/src/main/java/cn/nianxx/thhotel/platform/system/api/SuperAgentProbeController.java` | -| AI 调用审计 API | `server/src/main/java/cn/nianxx/thhotel/platform/ai/api/AiCapabilityInvocationController.java` | -| AgentBus WebSocket 客户端 | `server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/AgentBusProbeWebSocketClient.java` | -| AgentBus frame 处理 | `server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/AgentBusFrameProcessor.java` | -| AgentBus 到 SourceMessage 映射 | `server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/AgentBusSourceMessageMapper.java` | -| AgentBus 入站持久化 | `server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/PersistingAgentBusSourceMessageCaptureService.java` | -| SourceMessage Replay | `server/src/main/java/cn/nianxx/thhotel/platform/message/application/SourceMessageReplayApplicationService.java` | +| SourceMessage 查询与原文 API | `server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageController.java` | +| SourceMessage 捕获服务 | `server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageCaptureServiceImpl.java` | +| SourceMessage 原文读取服务 | `server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageOriginalServiceImpl.java` | +| SourceMessage 持久化边界 | `server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java` | +| AgentBus WebSocket 客户端 | `server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusWebSocketClient.java` | +| AgentBus frame 处理 | `server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessor.java` | +| AgentBus 到 SourceMessage 适配 | `server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusSourceMessageAdapter.java` | +| AgentBus 状态 API | `server/src/main/java/cn/nianxx/thhotel/platform/system/control/AgentBusProbeStatusController.java` | +| SourceMessage 表结构 | `server/src/main/resources/db/migration/V1__create_source_message_inbox.sql` | +| SourceMessage 原文读取审计表 | `server/src/main/resources/db/migration/V2__create_source_message_original_access_audit.sql` | + +SourceMessage Replay 到 MessageEvent / Evidence 尚未实现,需等 MessageEvent、Evidence 字段模型确认后再进入后续 checkpoint。 ## 4. SuperAgent 对接 @@ -269,6 +270,7 @@ AGENTBUS_MAX_SAMPLES=100 AGENTBUS_CAPTURE_ENABLED=true AGENTBUS_DEFAULT_HOTEL_ID=HOTEL-TEST AGENTBUS_REPLY_MODE=NONE +SOURCE_MESSAGE_ORIGINAL_READ_ACCESS_KEY= ``` 变量说明: @@ -288,6 +290,7 @@ AGENTBUS_REPLY_MODE=NONE | `AGENTBUS_CAPTURE_ENABLED` | 否 | 是否写入 SourceMessage Inbox。 | | `AGENTBUS_DEFAULT_HOTEL_ID` | 否 | AgentBus 未提供租户上下文时的默认业务上下文。 | | `AGENTBUS_REPLY_MODE` | 否 | 调试回复模式。真实客户渠道应保持 `NONE`。 | +| `SOURCE_MESSAGE_ORIGINAL_READ_ACCESS_KEY` | 是 | 原文读取接口的临时受控访问 key,后续可替换为正式权限体系。 | ### 5.2 WebSocket 连接 @@ -508,6 +511,7 @@ MessageEvent - `DEERFLOW_OPEN_API_KEY` - `SUPERAGENT_PROBE_ACCESS_KEY` - `AGENTBUS_WS_TOKEN` +- `SOURCE_MESSAGE_ORIGINAL_READ_ACCESS_KEY` - `SOURCE_MESSAGE_REPLAY_ACCESS_KEY` - 数据库密码 - 任何真实客户渠道 Token diff --git a/server/mvnw b/server/mvnw new file mode 100755 index 0000000..71ca9f3 --- /dev/null +++ b/server/mvnw @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +exec mvn "$@" diff --git a/server/pom.xml b/server/pom.xml new file mode 100644 index 0000000..afead70 --- /dev/null +++ b/server/pom.xml @@ -0,0 +1,94 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.15 + + + + cn.nianxx + th-hotel-server + 0.0.1-SNAPSHOT + th-hotel-server + TH Hotel backend service + + + 17 + 17 + true + 3.5.16 + 2.8.17 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + com.baomidou + mybatis-plus-spring-boot3-starter + ${mybatis-plus.version} + + + com.baomidou + mybatis-plus-jsqlparser + ${mybatis-plus.version} + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-mysql + + + com.mysql + mysql-connector-j + runtime + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + + com.h2database + h2 + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + ${maven.compiler.release} + ${maven.compiler.parameters} + + + + + diff --git a/server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java b/server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java new file mode 100644 index 0000000..ca081c4 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java @@ -0,0 +1,17 @@ +package cn.nianxx.thhotel; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * TH Hotel 后端应用入口。 + */ +@MapperScan("cn.nianxx.thhotel.platform.message.mapper") +@SpringBootApplication +public class ThHotelApplication { + + public static void main(String[] args) { + SpringApplication.run(ThHotelApplication.class, args); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusConnectionStatus.java b/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusConnectionStatus.java new file mode 100644 index 0000000..72d5fca --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusConnectionStatus.java @@ -0,0 +1,126 @@ +package cn.nianxx.thhotel.integrations.messaging.agentbus.adapter; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.concurrent.atomic.AtomicLong; +import org.springframework.stereotype.Component; + +/** + * AgentBus 连接与入站处理状态。该组件只保存安全计数器和错误代码,不保存 raw frame。 + */ +@Component +public class AgentBusConnectionStatus { + + private final AtomicLong receivedFrameCount = new AtomicLong(); + private final AtomicLong capturedFrameCount = new AtomicLong(); + private final AtomicLong ignoredFrameCount = new AtomicLong(); + private final AtomicLong rejectedFrameCount = new AtomicLong(); + private final AtomicLong failedFrameCount = new AtomicLong(); + + private volatile boolean connected; + private volatile boolean sessionReady; + private volatile String lastErrorCode; + private volatile String lastErrorSummary; + private volatile OffsetDateTime connectedAt; + private volatile OffsetDateTime disconnectedAt; + private volatile OffsetDateTime lastFrameReceivedAt; + + /** + * 标记 WebSocket 已连接。 + */ + public void markConnected() { + connected = true; + connectedAt = nowUtc(); + lastErrorCode = null; + lastErrorSummary = null; + } + + /** + * 标记 WebSocket 已断开。 + */ + public void markDisconnected() { + connected = false; + sessionReady = false; + disconnectedAt = nowUtc(); + } + + /** + * 标记已经收到 AgentBus session.ready 控制事件。 + */ + public void markSessionReady() { + sessionReady = true; + } + + /** + * 记录收到一条入站 frame。 + */ + public void markFrameReceived() { + receivedFrameCount.incrementAndGet(); + lastFrameReceivedAt = nowUtc(); + } + + /** + * 记录一条 frame 已写入 SourceMessage Inbox。 + */ + public void markFrameCaptured() { + capturedFrameCount.incrementAndGet(); + } + + /** + * 记录一条控制事件或配置关闭时被忽略的 frame。 + */ + public void markFrameIgnored() { + ignoredFrameCount.incrementAndGet(); + } + + /** + * 记录一条因为安全边界被拒绝的 frame。 + */ + public void markFrameRejected(String errorCode, String errorSummary) { + rejectedFrameCount.incrementAndGet(); + recordError(errorCode, errorSummary); + } + + /** + * 记录一条处理失败的 frame。 + */ + public void markFrameFailed(String errorCode, String errorSummary) { + failedFrameCount.incrementAndGet(); + recordError(errorCode, errorSummary); + } + + /** + * 记录最近一次安全错误代码和摘要,不包含 raw frame、Token 或正文。 + */ + public void recordError(String errorCode, String errorSummary) { + lastErrorCode = errorCode; + lastErrorSummary = errorSummary; + } + + /** + * 返回当前安全状态快照,供系统探针接口展示。 + */ + public AgentBusStatusSnapshot snapshot() { + return new AgentBusStatusSnapshot( + connected, + sessionReady, + receivedFrameCount.get(), + capturedFrameCount.get(), + ignoredFrameCount.get(), + rejectedFrameCount.get(), + failedFrameCount.get(), + lastErrorCode, + lastErrorSummary, + connectedAt, + disconnectedAt, + lastFrameReceivedAt + ); + } + + /** + * 生成 UTC 时间,保证状态接口时间字段稳定。 + */ + private OffsetDateTime nowUtc() { + return OffsetDateTime.now(ZoneOffset.UTC); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessResult.java b/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessResult.java new file mode 100644 index 0000000..ff648cc --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessResult.java @@ -0,0 +1,40 @@ +package cn.nianxx.thhotel.integrations.messaging.agentbus.adapter; + +/** + * AgentBus 单条 frame 处理结果。结果中不包含原始 payload 或客户正文。 + */ +public record AgentBusFrameProcessResult( + String outcome, + Long inboxId, + String captureStatus, + String errorCode +) { + + /** + * 构造忽略结果,用于控制事件或捕获关闭场景。 + */ + public static AgentBusFrameProcessResult ignored() { + return new AgentBusFrameProcessResult("IGNORED", null, null, null); + } + + /** + * 构造捕获成功结果,只返回内部 Inbox ID 和捕获状态。 + */ + public static AgentBusFrameProcessResult captured(Long inboxId, String captureStatus) { + return new AgentBusFrameProcessResult("CAPTURED", inboxId, captureStatus, null); + } + + /** + * 构造拒绝结果,用于超大 frame 等安全边界。 + */ + public static AgentBusFrameProcessResult rejected(String errorCode) { + return new AgentBusFrameProcessResult("REJECTED", null, null, errorCode); + } + + /** + * 构造失败结果,用于 JSON 解析或捕获异常。 + */ + public static AgentBusFrameProcessResult failed(String errorCode) { + return new AgentBusFrameProcessResult("FAILED", null, null, errorCode); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessor.java b/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessor.java new file mode 100644 index 0000000..f5ffff4 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessor.java @@ -0,0 +1,118 @@ +package cn.nianxx.thhotel.integrations.messaging.agentbus.adapter; + +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult; +import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.util.Set; +import org.springframework.stereotype.Component; + +/** + * AgentBus 入站 frame 处理器。只负责控制事件识别和 SourceMessage 捕获,不做业务写操作或客户回复。 + */ +@Component +public class AgentBusFrameProcessor { + + private static final String FRAME_TOO_LARGE = "FRAME_TOO_LARGE"; + private static final String INVALID_JSON = "INVALID_JSON"; + private static final String CAPTURE_FAILED = "CAPTURE_FAILED"; + private static final Set CONTROL_EVENTS = Set.of("session.ready", "task.progress", "task.result"); + + private final ObjectMapper objectMapper; + private final AgentBusSourceMessageAdapter sourceMessageAdapter; + private final SourceMessageCaptureService captureService; + private final AgentBusConnectionStatus status; + private final AgentBusProperties properties; + + /** + * 注入 AgentBus frame 处理依赖。外部协议转换与平台捕获服务通过稳定命令隔离。 + */ + public AgentBusFrameProcessor( + ObjectMapper objectMapper, + AgentBusSourceMessageAdapter sourceMessageAdapter, + SourceMessageCaptureService captureService, + AgentBusConnectionStatus status, + AgentBusProperties properties) { + this.objectMapper = objectMapper; + this.sourceMessageAdapter = sourceMessageAdapter; + this.captureService = captureService; + this.status = status; + this.properties = properties; + } + + /** + * 处理一条 AgentBus raw frame。该方法不保存 raw frame,也不会发送 ACK 或客户回复。 + */ + public AgentBusFrameProcessResult process(String rawFrame) { + status.markFrameReceived(); + if (rawFrame == null || rawFrame.getBytes(StandardCharsets.UTF_8).length > properties.getMaxFrameBytes()) { + status.markFrameRejected(FRAME_TOO_LARGE, "AgentBus frame exceeds max allowed bytes."); + return AgentBusFrameProcessResult.rejected(FRAME_TOO_LARGE); + } + JsonNode frame = parse(rawFrame); + if (frame == null) { + return AgentBusFrameProcessResult.failed(INVALID_JSON); + } + String eventType = firstText(frame, "type", frame, "event"); + if (eventType != null && CONTROL_EVENTS.contains(eventType)) { + if ("session.ready".equals(eventType)) { + status.markSessionReady(); + } + status.markFrameIgnored(); + return AgentBusFrameProcessResult.ignored(); + } + if (!properties.getCapture().isEnabled()) { + status.markFrameIgnored(); + return AgentBusFrameProcessResult.ignored(); + } + try { + CaptureSourceMessageCommand command = sourceMessageAdapter.toCaptureCommand( + properties.getCapture().getDefaultHotelId(), + frame); + SourceMessageCaptureResult result = captureService.capture(command); + status.markFrameCaptured(); + return AgentBusFrameProcessResult.captured(result.inboxId(), result.captureStatus()); + } catch (RuntimeException exception) { + status.markFrameFailed(CAPTURE_FAILED, "AgentBus frame capture failed."); + return AgentBusFrameProcessResult.failed(CAPTURE_FAILED); + } + } + + /** + * 解析 AgentBus JSON frame。解析失败只记录安全错误代码,不回显原始报文。 + */ + private JsonNode parse(String rawFrame) { + try { + return objectMapper.readTree(rawFrame); + } catch (JsonProcessingException exception) { + status.markFrameFailed(INVALID_JSON, "AgentBus frame is not valid JSON."); + return null; + } + } + + /** + * 从两个候选字段中读取第一个非空文本。 + */ + private String firstText(JsonNode firstNode, String firstField, JsonNode secondNode, String secondField) { + String firstValue = text(firstNode, firstField); + if (firstValue != null) { + return firstValue; + } + return text(secondNode, secondField); + } + + /** + * 读取并清理 JSON 文本字段,空白字符串按缺失处理。 + */ + private String text(JsonNode node, String fieldName) { + if (node == null || node.path(fieldName).isMissingNode() || node.path(fieldName).isNull()) { + return null; + } + String value = node.path(fieldName).asText(); + String trimmed = value == null ? "" : value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusProperties.java b/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusProperties.java new file mode 100644 index 0000000..343773b --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusProperties.java @@ -0,0 +1,145 @@ +package cn.nianxx.thhotel.integrations.messaging.agentbus.adapter; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * AgentBus 入站连接配置。Secret 只从环境变量注入,不在响应、日志或测试夹具中输出。 + */ +@Component +@ConfigurationProperties(prefix = "agentbus") +public class AgentBusProperties { + + /** AgentBus 探针和长连接开关,默认关闭。 */ + private Probe probe = new Probe(); + /** AgentBus WebSocket 连接参数。 */ + private Ws ws = new Ws(); + /** AgentBus 入站捕获配置。 */ + private Capture capture = new Capture(); + /** 单个入站 frame 最大字节数,超过后拒绝处理。 */ + private int maxFrameBytes = 1048576; + + public Probe getProbe() { + return probe; + } + + public void setProbe(Probe probe) { + this.probe = probe; + } + + public Ws getWs() { + return ws; + } + + public void setWs(Ws ws) { + this.ws = ws; + } + + public Capture getCapture() { + return capture; + } + + public void setCapture(Capture capture) { + this.capture = capture; + } + + public int getMaxFrameBytes() { + return maxFrameBytes; + } + + public void setMaxFrameBytes(int maxFrameBytes) { + this.maxFrameBytes = maxFrameBytes; + } + + /** + * AgentBus WebSocket 启停配置。 + */ + public static class Probe { + + /** 是否启用 AgentBus WebSocket 长连接。 */ + private boolean enabled = false; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + } + + /** + * AgentBus WebSocket 连接参数。 + */ + public static class Ws { + + /** AgentBus WebSocket 地址。 */ + private String url = "wss://mesh.nianxx.cn/ws"; + /** AgentBus WebSocket 鉴权 Token。 */ + private String token; + /** AgentBus 断线重连等待时间。 */ + private Duration reconnectDelay = Duration.ofSeconds(5); + /** AgentBus 连接超时时间。 */ + private Duration connectTimeout = Duration.ofSeconds(15); + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public Duration getReconnectDelay() { + return reconnectDelay; + } + + public void setReconnectDelay(Duration reconnectDelay) { + this.reconnectDelay = reconnectDelay; + } + + public Duration getConnectTimeout() { + return connectTimeout; + } + + public void setConnectTimeout(Duration connectTimeout) { + this.connectTimeout = connectTimeout; + } + } + + /** + * AgentBus 入站消息捕获配置。 + */ + public static class Capture { + + /** 是否把业务 frame 写入 SourceMessage Inbox。 */ + private boolean enabled = true; + /** AgentBus 未提供酒店上下文时使用的默认酒店 ID。 */ + private String defaultHotelId = "HOTEL-TEST"; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getDefaultHotelId() { + return defaultHotelId; + } + + public void setDefaultHotelId(String defaultHotelId) { + this.defaultHotelId = defaultHotelId; + } + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusSourceMessageAdapter.java b/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusSourceMessageAdapter.java new file mode 100644 index 0000000..13bade2 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusSourceMessageAdapter.java @@ -0,0 +1,191 @@ +package cn.nianxx.thhotel.integrations.messaging.agentbus.adapter; + +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand; +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageMedia; +import cn.nianxx.thhotel.platform.message.common.enums.SourceMessageMediaType; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import org.springframework.stereotype.Component; + +/** + * AgentBus Outlook 邮件 payload 适配器。外部协议字段只在适配层解析,不泄漏到平台消息领域。 + */ +@Component +public class AgentBusSourceMessageAdapter { + + private static final String PROVIDER_AGENTBUS = "AGENTBUS"; + private static final String SCHEMA_VERSION = "agentbus-outlook-v1"; + + private final ObjectMapper objectMapper; + + public AgentBusSourceMessageAdapter(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** + * 将 AgentBus frame 转换为平台 SourceMessage 捕获命令。 + */ + public CaptureSourceMessageCommand toCaptureCommand(String hotelId, JsonNode frame) { + JsonNode payload = frame.path("payload").isMissingNode() ? frame : frame.path("payload"); + JsonNode source = payload.path("source"); + JsonNode body = payload.path("body"); + String channel = uppercaseOrDefault(text(source, "channel"), "EMAIL"); + + return new CaptureSourceMessageCommand( + hotelId, + PROVIDER_AGENTBUS, + channel, + text(source, "external_message_id"), + text(source, "external_conversation_id"), + text(frame, "id"), + text(frame, "session_id"), + parseInstant(text(source, "sent_at")), + text(source, "sender"), + text(source, "subject"), + firstText(body, "text", payload, "text"), + text(body, "html"), + toPayloadJson(payload), + SCHEMA_VERSION, + mediaItems(payload) + ); + } + + /** + * 从 AgentBus payload 中提取内嵌图片和附件引用。 + */ + private List mediaItems(JsonNode payload) { + List result = new ArrayList<>(); + addMedia(result, SourceMessageMediaType.INLINE_IMAGE.code(), payload.path("inline_images")); + addMedia(result, SourceMessageMediaType.ATTACHMENT.code(), payload.path("attachments")); + return List.copyOf(result); + } + + /** + * 将指定媒体数组追加为平台统一媒体请求项。 + */ + private void addMedia(List result, String mediaType, JsonNode items) { + if (!items.isArray()) { + return; + } + for (JsonNode item : items) { + result.add(new CaptureSourceMessageMedia( + mediaType, + firstText(item, "file_name", item, "filename", item, "name"), + firstText(item, "content_type", item, "mime_type"), + firstLong(item, "size_bytes", item, "size"), + firstText(item, "external_url", item, "url"), + text(item, "id") + )); + } + } + + /** + * 将规范化后的 AgentBus payload 序列化,供 SourceMessage 原始载荷追溯。 + */ + private String toPayloadJson(JsonNode payload) { + try { + return objectMapper.writeValueAsString(payload); + } catch (JsonProcessingException exception) { + throw new IllegalArgumentException("AgentBus payload 无法序列化为规范 JSON", exception); + } + } + + /** + * 解析 AgentBus 来源时间,格式非法时保持为空,避免阻断消息入站。 + */ + private Instant parseInstant(String value) { + if (value == null) { + return null; + } + try { + return Instant.parse(value); + } catch (DateTimeParseException exception) { + return null; + } + } + + /** + * 将渠道等稳定代码转为大写,缺失时使用服务端默认值。 + */ + private String uppercaseOrDefault(String value, String defaultValue) { + if (value == null) { + return defaultValue; + } + return value.toUpperCase(Locale.ROOT); + } + + /** + * 从两个候选字段中读取第一个非空文本。 + */ + private String firstText(JsonNode firstNode, String firstField, JsonNode secondNode, String secondField) { + String firstValue = text(firstNode, firstField); + if (firstValue != null) { + return firstValue; + } + return text(secondNode, secondField); + } + + /** + * 从三个候选字段中读取第一个非空文本。 + */ + private String firstText( + JsonNode firstNode, + String firstField, + JsonNode secondNode, + String secondField, + JsonNode thirdNode, + String thirdField) { + String firstValue = text(firstNode, firstField); + if (firstValue != null) { + return firstValue; + } + String secondValue = text(secondNode, secondField); + if (secondValue != null) { + return secondValue; + } + return text(thirdNode, thirdField); + } + + /** + * 从两个候选字段中读取第一个可转换为 Long 的值。 + */ + private Long firstLong(JsonNode firstNode, String firstField, JsonNode secondNode, String secondField) { + Long firstValue = longValue(firstNode, firstField); + if (firstValue != null) { + return firstValue; + } + return longValue(secondNode, secondField); + } + + /** + * 读取并清理 AgentBus JSON 文本字段,空白字符串按缺失处理。 + */ + private String text(JsonNode node, String fieldName) { + if (node == null || node.isMissingNode() || node.path(fieldName).isMissingNode() || node.path(fieldName).isNull()) { + return null; + } + String value = node.path(fieldName).asText(); + String trimmed = value == null ? "" : value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + /** + * 读取 AgentBus JSON 数字字段,非数字或缺失时返回空。 + */ + private Long longValue(JsonNode node, String fieldName) { + if (node == null || node.isMissingNode() || node.path(fieldName).isMissingNode() || node.path(fieldName).isNull()) { + return null; + } + JsonNode value = node.path(fieldName); + if (value.canConvertToLong()) { + return value.asLong(); + } + return null; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusStatusSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusStatusSnapshot.java new file mode 100644 index 0000000..6914959 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusStatusSnapshot.java @@ -0,0 +1,22 @@ +package cn.nianxx.thhotel.integrations.messaging.agentbus.adapter; + +import java.time.OffsetDateTime; + +/** + * AgentBus 连接状态快照。只包含安全状态和计数器,不包含 Token 或原始 frame。 + */ +public record AgentBusStatusSnapshot( + boolean connected, + boolean sessionReady, + long receivedFrameCount, + long capturedFrameCount, + long ignoredFrameCount, + long rejectedFrameCount, + long failedFrameCount, + String lastErrorCode, + String lastErrorSummary, + OffsetDateTime connectedAt, + OffsetDateTime disconnectedAt, + OffsetDateTime lastFrameReceivedAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusWebSocketClient.java b/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusWebSocketClient.java new file mode 100644 index 0000000..cdabc11 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusWebSocketClient.java @@ -0,0 +1,235 @@ +package cn.nianxx.thhotel.integrations.messaging.agentbus.adapter; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.time.Duration; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import jakarta.annotation.PreDestroy; +import org.springframework.context.SmartLifecycle; +import org.springframework.stereotype.Component; + +/** + * AgentBus WebSocket 长连接客户端。默认关闭,开启后只接收入站 frame 并交给 SourceMessage 捕获链路。 + */ +@Component +public class AgentBusWebSocketClient implements SmartLifecycle { + + private static final String MISSING_TOKEN = "MISSING_TOKEN"; + private static final String INVALID_WS_URL = "INVALID_WS_URL"; + private static final String CONNECT_FAILED = "CONNECT_FAILED"; + private static final String WEBSOCKET_ERROR = "WEBSOCKET_ERROR"; + + private final AgentBusProperties properties; + private final AgentBusFrameProcessor frameProcessor; + private final AgentBusConnectionStatus status; + private final ScheduledExecutorService reconnectExecutor; + private final AtomicBoolean running = new AtomicBoolean(false); + + private volatile WebSocket webSocket; + + /** + * 注入 AgentBus 连接配置、frame 处理器和安全状态组件。 + */ + public AgentBusWebSocketClient( + AgentBusProperties properties, + AgentBusFrameProcessor frameProcessor, + AgentBusConnectionStatus status) { + this.properties = properties; + this.frameProcessor = frameProcessor; + this.status = status; + this.reconnectExecutor = Executors.newSingleThreadScheduledExecutor(runnable -> { + Thread thread = new Thread(runnable, "agentbus-websocket-reconnect"); + thread.setDaemon(true); + return thread; + }); + } + + /** + * Spring 容器启动后按配置决定是否连接 AgentBus WebSocket。 + */ + @Override + public void start() { + if (!properties.getProbe().isEnabled()) { + return; + } + if (running.compareAndSet(false, true)) { + connect(); + } + } + + /** + * 停止 WebSocket 长连接并关闭重连循环。 + */ + @Override + public void stop() { + running.set(false); + WebSocket current = webSocket; + if (current != null) { + current.sendClose(WebSocket.NORMAL_CLOSURE, "application stopping"); + } + status.markDisconnected(); + } + + /** + * 返回 WebSocket 客户端是否处于运行状态。 + */ + @Override + public boolean isRunning() { + return running.get(); + } + + /** + * 当前客户端默认随应用自动启动,但只有配置开启时才真正建立连接。 + */ + @Override + public boolean isAutoStartup() { + return true; + } + + /** + * Spring 容器销毁时关闭重连线程,避免本地测试或重启时残留后台任务。 + */ + @PreDestroy + public void shutdown() { + stop(); + reconnectExecutor.shutdownNow(); + } + + /** + * 建立 AgentBus WebSocket 连接。Token 缺失时只记录错误代码,不尝试连接。 + */ + private void connect() { + if (!running.get()) { + return; + } + String token = trimToNull(properties.getWs().getToken()); + if (token == null) { + status.recordError(MISSING_TOKEN, "AgentBus WebSocket token is not configured."); + return; + } + URI uri = buildReadyUri(properties.getWs().getUrl()); + if (uri == null) { + status.recordError(INVALID_WS_URL, "AgentBus WebSocket URL is invalid."); + return; + } + HttpClient.newBuilder() + .connectTimeout(normalizeDuration(properties.getWs().getConnectTimeout(), Duration.ofSeconds(15))) + .build() + .newWebSocketBuilder() + .header("Authorization", "Bearer " + token) + .buildAsync(uri, new Listener()) + .whenComplete((socket, exception) -> { + if (exception != null) { + status.recordError(CONNECT_FAILED, "AgentBus WebSocket connection failed."); + scheduleReconnect(); + } else { + webSocket = socket; + } + }); + } + + /** + * 安排断线后重连。停止状态下不会继续重连。 + */ + private void scheduleReconnect() { + if (!running.get()) { + return; + } + long delayMillis = normalizeDuration(properties.getWs().getReconnectDelay(), Duration.ofSeconds(5)).toMillis(); + reconnectExecutor.schedule(this::connect, delayMillis, TimeUnit.MILLISECONDS); + } + + /** + * 将 WebSocket URL 补上 ready=1 查询参数,用于请求 AgentBus session.ready。 + */ + private URI buildReadyUri(String rawUrl) { + String url = trimToNull(rawUrl); + if (url == null) { + return null; + } + try { + String separator = url.contains("?") ? "&" : "?"; + return URI.create(url + separator + "ready=1"); + } catch (IllegalArgumentException exception) { + return null; + } + } + + /** + * 标准化 Duration 配置,缺失或非正数时使用默认值。 + */ + private Duration normalizeDuration(Duration value, Duration defaultValue) { + if (value == null || value.isZero() || value.isNegative()) { + return defaultValue; + } + return value; + } + + /** + * 将空白字符串统一视为未配置。 + */ + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + /** + * JDK WebSocket Listener。只接收文本 frame,不发送 ACK、task.result 或客户回复。 + */ + private class Listener implements WebSocket.Listener { + + private final StringBuilder textBuffer = new StringBuilder(); + + /** + * 标记 WebSocket 已连接,并请求第一条消息。 + */ + @Override + public void onOpen(WebSocket webSocket) { + status.markConnected(); + WebSocket.Listener.super.onOpen(webSocket); + } + + /** + * 收到完整文本 frame 后交给 AgentBusFrameProcessor 处理。 + */ + @Override + public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { + textBuffer.append(data); + if (last) { + frameProcessor.process(textBuffer.toString()); + textBuffer.setLength(0); + } + webSocket.request(1); + return WebSocket.Listener.super.onText(webSocket, data, last); + } + + /** + * 连接关闭时记录断开并按配置重连。 + */ + @Override + public CompletionStage onClose(WebSocket webSocket, int statusCode, String reason) { + status.markDisconnected(); + scheduleReconnect(); + return WebSocket.Listener.super.onClose(webSocket, statusCode, reason); + } + + /** + * WebSocket 异常只记录安全错误代码,不输出 Token 或原始 frame。 + */ + @Override + public void onError(WebSocket webSocket, Throwable error) { + status.markDisconnected(); + status.recordError(WEBSOCKET_ERROR, "AgentBus WebSocket error."); + scheduleReconnect(); + WebSocket.Listener.super.onError(webSocket, error); + } + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/common/config/MybatisPlusConfig.java b/server/src/main/java/cn/nianxx/thhotel/platform/common/config/MybatisPlusConfig.java new file mode 100644 index 0000000..83e9e9e --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/common/config/MybatisPlusConfig.java @@ -0,0 +1,21 @@ +package cn.nianxx.thhotel.platform.common.config; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * MyBatis-Plus 基础配置。分页拦截器用于把 Page 查询转换为数据库分页,避免应用层全量分页。 + */ +@Configuration +public class MybatisPlusConfig { + + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); + return interceptor; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageInboxDraft.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageInboxDraft.java new file mode 100644 index 0000000..4acecaf --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageInboxDraft.java @@ -0,0 +1,33 @@ +package cn.nianxx.thhotel.platform.message.common.dto; + +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageMedia; +import java.time.LocalDateTime; +import java.util.List; + +/** + * SourceMessage Inbox 待保存草稿。服务实现层负责填好安全摘要和业务状态,持久化层只负责落库。 + */ +public record SourceMessageInboxDraft( + String hotelId, + String provider, + String channel, + String externalMessageId, + String externalConversationId, + String providerFrameId, + String providerSessionId, + String payloadSha256, + String captureStatus, + boolean duplicatePayloadChanged, + String safeErrorSummary, + LocalDateTime receivedAt, + LocalDateTime sourceSentAt, + String senderSummary, + String subject, + String safeSnippet, + String payloadJson, + String schemaVersion, + String textBody, + String htmlBody, + List mediaItems +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageInboxSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageInboxSnapshot.java new file mode 100644 index 0000000..40b75ee --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageInboxSnapshot.java @@ -0,0 +1,24 @@ +package cn.nianxx.thhotel.platform.message.common.dto; + +import java.time.LocalDateTime; + +/** + * SourceMessage Inbox 已保存快照。只包含服务层判断和安全查询需要的字段,不暴露数据库 Entity。 + */ +public record SourceMessageInboxSnapshot( + Long id, + String hotelId, + String provider, + String channel, + String externalMessageId, + String externalConversationId, + String payloadSha256, + String captureStatus, + boolean duplicatePayloadChanged, + LocalDateTime receivedAt, + LocalDateTime sourceSentAt, + String senderSummary, + String subject, + String safeSnippet +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageOriginalAccessAuditDraft.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageOriginalAccessAuditDraft.java new file mode 100644 index 0000000..0b190ff --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageOriginalAccessAuditDraft.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.platform.message.common.dto; + +import java.time.LocalDateTime; + +/** + * SourceMessage 原文读取审计草稿,由服务层生成,Repository 负责持久化。 + */ +public record SourceMessageOriginalAccessAuditDraft( + Long inboxId, + String actorId, + String accessScene, + String accessResult, + LocalDateTime accessedAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageOriginalContent.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageOriginalContent.java new file mode 100644 index 0000000..5602189 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageOriginalContent.java @@ -0,0 +1,14 @@ +package cn.nianxx.thhotel.platform.message.common.dto; + +import java.util.List; + +/** + * SourceMessage 原文内容快照。包含正文和媒体 URL,只能由受控原文读取服务使用。 + */ +public record SourceMessageOriginalContent( + Long inboxId, + String textBody, + String htmlBody, + List mediaItems +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageOriginalMediaItem.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageOriginalMediaItem.java new file mode 100644 index 0000000..e523293 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageOriginalMediaItem.java @@ -0,0 +1,14 @@ +package cn.nianxx.thhotel.platform.message.common.dto; + +/** + * SourceMessage 原文读取媒体项。该 DTO 只在受控原文读取链路内部使用。 + */ +public record SourceMessageOriginalMediaItem( + String mediaType, + String fileName, + String contentType, + Long sizeBytes, + String externalUrl, + String externalMediaId +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageSummaryResponse.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageSummaryResponse.java new file mode 100644 index 0000000..770c2f2 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageSummaryResponse.java @@ -0,0 +1,23 @@ +package cn.nianxx.thhotel.platform.message.common.dto; + +import java.time.OffsetDateTime; + +/** + * 来源消息安全摘要。该响应不得包含正文、HTML、附件 URL 或原始 payload。 + */ +public record SourceMessageSummaryResponse( + String id, + String hotelId, + String provider, + String channel, + String externalMessageId, + String externalConversationId, + String captureStatus, + boolean duplicatePayloadChanged, + OffsetDateTime receivedAt, + OffsetDateTime sourceSentAt, + String senderSummary, + String subject, + String safeSnippet +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/enums/SourceMessageBodyContentType.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/enums/SourceMessageBodyContentType.java new file mode 100644 index 0000000..a3a97c0 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/enums/SourceMessageBodyContentType.java @@ -0,0 +1,19 @@ +package cn.nianxx.thhotel.platform.message.common.enums; + +/** + * 来源消息正文保存类型。当前阶段同时保存 text/html,因此使用 MIXED 稳定代码。 + */ +public enum SourceMessageBodyContentType { + + MIXED("MIXED"); + + private final String code; + + SourceMessageBodyContentType(String code) { + this.code = code; + } + + public String code() { + return code; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/enums/SourceMessageCaptureFailureReason.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/enums/SourceMessageCaptureFailureReason.java new file mode 100644 index 0000000..23d8d58 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/enums/SourceMessageCaptureFailureReason.java @@ -0,0 +1,22 @@ +package cn.nianxx.thhotel.platform.message.common.enums; + +/** + * 来源消息捕获失败原因。safeSummary 只用于安全排查摘要,不包含原文、附件 URL 或 Secret。 + */ +public enum SourceMessageCaptureFailureReason { + + MISSING_EXTERNAL_MESSAGE_ID("payload missing external message id"), + INVALID_MEDIA_ITEM("payload media item is invalid"), + MISSING_MEDIA_TYPE("payload media item missing media type"), + MISSING_MEDIA_EXTERNAL_URL("payload media item missing external url"); + + private final String safeSummary; + + SourceMessageCaptureFailureReason(String safeSummary) { + this.safeSummary = safeSummary; + } + + public String safeSummary() { + return safeSummary; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/enums/SourceMessageCaptureStatus.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/enums/SourceMessageCaptureStatus.java new file mode 100644 index 0000000..dac9fac --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/enums/SourceMessageCaptureStatus.java @@ -0,0 +1,20 @@ +package cn.nianxx.thhotel.platform.message.common.enums; + +/** + * SourceMessage Inbox 捕获状态。接口和数据库均使用稳定英文代码,不使用展示文案做判断。 + */ +public enum SourceMessageCaptureStatus { + + RECEIVED("RECEIVED"), + FAILED("FAILED"); + + private final String code; + + SourceMessageCaptureStatus(String code) { + this.code = code; + } + + public String code() { + return code; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/enums/SourceMessageMediaType.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/enums/SourceMessageMediaType.java new file mode 100644 index 0000000..6db7523 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/enums/SourceMessageMediaType.java @@ -0,0 +1,20 @@ +package cn.nianxx.thhotel.platform.message.common.enums; + +/** + * 来源消息媒体类型。用于区分正文内图片和附件引用,第一阶段不下载、不转存文件。 + */ +public enum SourceMessageMediaType { + + INLINE_IMAGE("INLINE_IMAGE"), + ATTACHMENT("ATTACHMENT"); + + private final String code; + + SourceMessageMediaType(String code) { + this.code = code; + } + + public String code() { + return code; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/enums/SourceMessageOriginalAccessResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/enums/SourceMessageOriginalAccessResult.java new file mode 100644 index 0000000..9dfc083 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/enums/SourceMessageOriginalAccessResult.java @@ -0,0 +1,19 @@ +package cn.nianxx.thhotel.platform.message.common.enums; + +/** + * SourceMessage 原文读取访问结果稳定代码。 + */ +public enum SourceMessageOriginalAccessResult { + + GRANTED("GRANTED"); + + private final String code; + + SourceMessageOriginalAccessResult(String code) { + this.code = code; + } + + public String code() { + return code; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/request/CaptureSourceMessageCommand.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/request/CaptureSourceMessageCommand.java new file mode 100644 index 0000000..8333b9b --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/request/CaptureSourceMessageCommand.java @@ -0,0 +1,26 @@ +package cn.nianxx.thhotel.platform.message.common.request; + +import java.time.Instant; +import java.util.List; + +/** + * 平台来源消息捕获命令。业务层只依赖该稳定命令,不直接依赖 AgentBus 原始 DTO。 + */ +public record CaptureSourceMessageCommand( + String hotelId, + String provider, + String channel, + String externalMessageId, + String externalConversationId, + String providerFrameId, + String providerSessionId, + Instant sourceSentAt, + String senderIdentifier, + String subject, + String textBody, + String htmlBody, + String payloadJson, + String schemaVersion, + List mediaItems +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/request/CaptureSourceMessageMedia.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/request/CaptureSourceMessageMedia.java new file mode 100644 index 0000000..c0c9a67 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/request/CaptureSourceMessageMedia.java @@ -0,0 +1,14 @@ +package cn.nianxx.thhotel.platform.message.common.request; + +/** + * 来源消息媒体引用。这里只保存外部系统已处理好的 URL,不下载、不转存二进制内容。 + */ +public record CaptureSourceMessageMedia( + String mediaType, + String fileName, + String contentType, + Long sizeBytes, + String externalUrl, + String externalMediaId +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/request/SourceMessageOriginalAccessRequest.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/request/SourceMessageOriginalAccessRequest.java new file mode 100644 index 0000000..93843a6 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/request/SourceMessageOriginalAccessRequest.java @@ -0,0 +1,10 @@ +package cn.nianxx.thhotel.platform.message.common.request; + +/** + * SourceMessage 原文读取访问请求。调用方和访问场景用于审计,不进入普通摘要查询。 + */ +public record SourceMessageOriginalAccessRequest( + String actorId, + String accessScene +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/request/SourceMessageQueryRequest.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/request/SourceMessageQueryRequest.java new file mode 100644 index 0000000..623107c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/request/SourceMessageQueryRequest.java @@ -0,0 +1,14 @@ +package cn.nianxx.thhotel.platform.message.common.request; + +/** + * 来源消息安全摘要查询条件。查询只面向已落库的来源事实,不主动拉取邮箱历史。 + */ +public record SourceMessageQueryRequest( + String hotelId, + String externalMessageId, + String externalConversationId, + String captureStatus, + Integer pageNum, + Integer pageSize +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageCaptureResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageCaptureResult.java new file mode 100644 index 0000000..7688093 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageCaptureResult.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.platform.message.common.result; + +/** + * 来源消息捕获结果,区分首次创建和重复投递命中,便于上游做幂等判断。 + */ +public record SourceMessageCaptureResult( + Long inboxId, + boolean created, + boolean duplicatePayloadChanged, + String captureStatus +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageOriginalMediaResponse.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageOriginalMediaResponse.java new file mode 100644 index 0000000..2bedce4 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageOriginalMediaResponse.java @@ -0,0 +1,14 @@ +package cn.nianxx.thhotel.platform.message.common.result; + +/** + * SourceMessage 原文读取接口返回的媒体引用。externalUrl 只允许在具备原文读取权限时返回。 + */ +public record SourceMessageOriginalMediaResponse( + String mediaType, + String fileName, + String contentType, + Long sizeBytes, + String externalUrl, + String externalMediaId +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageOriginalResponse.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageOriginalResponse.java new file mode 100644 index 0000000..4a91f4e --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageOriginalResponse.java @@ -0,0 +1,16 @@ +package cn.nianxx.thhotel.platform.message.common.result; + +import java.util.List; + +/** + * SourceMessage 原文读取响应。该响应包含敏感正文和媒体 URL,只能由受控接口返回。 + */ +public record SourceMessageOriginalResponse( + String id, + String textBody, + String htmlBody, + boolean htmlSanitizeRequired, + List inlineImages, + List attachments +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessagePageResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessagePageResult.java new file mode 100644 index 0000000..2537974 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessagePageResult.java @@ -0,0 +1,14 @@ +package cn.nianxx.thhotel.platform.message.common.result; + +import java.util.List; + +/** + * 平台消息分页响应。pageNum 从 1 开始,避免前端一次性拉取全部数据。 + */ +public record SourceMessagePageResult( + List items, + long total, + int pageNum, + int pageSize +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageController.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageController.java new file mode 100644 index 0000000..54f9f3b --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageController.java @@ -0,0 +1,94 @@ +package cn.nianxx.thhotel.platform.message.control; + +import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageOriginalResponse; +import cn.nianxx.thhotel.platform.message.common.request.SourceMessageOriginalAccessRequest; +import cn.nianxx.thhotel.platform.message.common.request.SourceMessageQueryRequest; +import cn.nianxx.thhotel.platform.message.service.SourceMessageOriginalService; +import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService; +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +/** + * SourceMessage Inbox 安全查询接口。该 Controller 不返回正文、HTML、附件 URL 或原始 payload。 + */ +@RestController +@RequestMapping("/api/source-messages") +public class SourceMessageController { + + private final SourceMessageQueryService queryService; + private final SourceMessageOriginalService originalService; + + /** + * 注入 SourceMessage 查询与原文读取服务,Controller 不直接访问 Mapper 或 Repository。 + */ + public SourceMessageController( + SourceMessageQueryService queryService, + SourceMessageOriginalService originalService) { + this.queryService = queryService; + this.originalService = originalService; + } + + /** + * 分页查询 SourceMessage Inbox 安全摘要,普通列表不返回正文、HTML、附件 URL 或原始 payload。 + */ + @GetMapping + public SourceMessagePageResult list( + @RequestParam(required = false) String hotelId, + @RequestParam(required = false) String externalMessageId, + @RequestParam(required = false) String externalConversationId, + @RequestParam(required = false) String captureStatus, + @RequestParam(required = false) Integer pageNum, + @RequestParam(required = false) Integer pageSize) { + return queryService.query(new SourceMessageQueryRequest( + hotelId, + externalMessageId, + externalConversationId, + captureStatus, + pageNum, + pageSize + )); + } + + /** + * 读取单条 SourceMessage Inbox 安全详情,用于排查消息是否已入站。 + */ + @GetMapping("/{id}") + public SourceMessageSummaryResponse detail(@PathVariable Long id) { + return queryService.getSummary(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "source message not found")); + } + + /** + * 读取 SourceMessage 原文内容。该接口必须携带受控访问口令、调用方和访问场景,并会写入审计。 + */ + @GetMapping("/{id}/original") + public SourceMessageOriginalResponse original( + @PathVariable Long id, + @RequestHeader(name = "X-TH-Hotel-Source-Original-Read-Key", required = false) String accessKey, + @RequestHeader(name = "X-TH-Hotel-Actor", required = false) String actorId, + @RequestHeader(name = "X-TH-Hotel-Access-Scene", required = false) String accessScene) { + if (!originalService.canReadOriginal(accessKey)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "source message original read denied"); + } + if (!hasText(actorId) || !hasText(accessScene)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "actor and access scene are required"); + } + return originalService.readOriginal(id, new SourceMessageOriginalAccessRequest(actorId, accessScene)) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "source message not found")); + } + + /** + * 校验请求头文本是否有效,避免空白调用方或场景进入审计记录。 + */ + private boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/domain/SourceMessageBodyEntity.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/domain/SourceMessageBodyEntity.java new file mode 100644 index 0000000..6127581 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/domain/SourceMessageBodyEntity.java @@ -0,0 +1,85 @@ +package cn.nianxx.thhotel.platform.message.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * 来源消息正文实体。正文只能由受控原文读取能力返回,列表和普通详情不得暴露。 + */ +@TableName("platform_source_message_body") +public class SourceMessageBodyEntity { + + /** 正文记录内部主键。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 所属 SourceMessage Inbox ID。 */ + private Long inboxId; + /** 正文类型稳定代码,例如 MIXED。 */ + private String contentType; + /** 纯文本正文,普通查询接口不得返回。 */ + private String textBody; + /** HTML 原文,前端展示前必须 sanitize,普通查询接口不得返回。 */ + private String htmlBody; + /** text/html 合并内容的 SHA-256 哈希。 */ + private String bodySha256; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getInboxId() { + return inboxId; + } + + public void setInboxId(Long inboxId) { + this.inboxId = inboxId; + } + + public String getContentType() { + return contentType; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } + + public String getTextBody() { + return textBody; + } + + public void setTextBody(String textBody) { + this.textBody = textBody; + } + + public String getHtmlBody() { + return htmlBody; + } + + public void setHtmlBody(String htmlBody) { + this.htmlBody = htmlBody; + } + + public String getBodySha256() { + return bodySha256; + } + + public void setBodySha256(String bodySha256) { + this.bodySha256 = bodySha256; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/domain/SourceMessageInboxEntity.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/domain/SourceMessageInboxEntity.java new file mode 100644 index 0000000..cb4774c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/domain/SourceMessageInboxEntity.java @@ -0,0 +1,205 @@ +package cn.nianxx.thhotel.platform.message.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * 来源消息索引实体。保存查询和幂等需要的安全字段,不保存完整正文。 + */ +@TableName("platform_source_message_inbox") +public class SourceMessageInboxEntity { + + /** 本项目内部 SourceMessage ID,作为平台侧关联来源消息的主键。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 酒店或业务上下文 ID,用于隔离不同酒店的外部消息 ID。 */ + private String hotelId; + /** 来源提供方稳定代码,例如 AGENTBUS。 */ + private String provider; + /** 来源渠道稳定代码,例如 EMAIL。 */ + private String channel; + /** 外部邮件系统中的单封邮件唯一 ID,是幂等键的一部分。 */ + private String externalMessageId; + /** 外部邮件链或会话 ID,用于查询同一邮件线程。 */ + private String externalConversationId; + /** AgentBus frame ID,仅用于排查推送帧,不作为业务唯一键。 */ + private String providerFrameId; + /** AgentBus session ID,仅用于排查连接会话,不作为邮件链 ID。 */ + private String providerSessionId; + /** 原始 payload 的 SHA-256 哈希,用于判断重复投递内容是否变化。 */ + private String payloadSha256; + /** 捕获状态稳定代码,例如 RECEIVED 或 FAILED。 */ + private String captureStatus; + /** 重复投递命中时 payload 是否与首次保存内容不同。 */ + private Boolean duplicatePayloadChanged; + /** 安全错误摘要,不包含正文、附件 URL、Token 或个人敏感信息。 */ + private String safeErrorSummary; + /** 本项目接收消息的 UTC 时间。 */ + private LocalDateTime receivedAt; + /** 来源系统提供的邮件发送 UTC 时间,缺失时为空。 */ + private LocalDateTime sourceSentAt; + /** 发送人安全摘要,普通查询可展示。 */ + private String senderSummary; + /** 脱敏后的邮件主题,普通查询可展示。 */ + private String subject; + /** 脱敏后的正文短摘要,普通查询可展示。 */ + private String safeSnippet; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + /** 记录更新 UTC 时间。 */ + private LocalDateTime updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getHotelId() { + return hotelId; + } + + public void setHotelId(String hotelId) { + this.hotelId = hotelId; + } + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public String getChannel() { + return channel; + } + + public void setChannel(String channel) { + this.channel = channel; + } + + public String getExternalMessageId() { + return externalMessageId; + } + + public void setExternalMessageId(String externalMessageId) { + this.externalMessageId = externalMessageId; + } + + public String getExternalConversationId() { + return externalConversationId; + } + + public void setExternalConversationId(String externalConversationId) { + this.externalConversationId = externalConversationId; + } + + public String getProviderFrameId() { + return providerFrameId; + } + + public void setProviderFrameId(String providerFrameId) { + this.providerFrameId = providerFrameId; + } + + public String getProviderSessionId() { + return providerSessionId; + } + + public void setProviderSessionId(String providerSessionId) { + this.providerSessionId = providerSessionId; + } + + public String getPayloadSha256() { + return payloadSha256; + } + + public void setPayloadSha256(String payloadSha256) { + this.payloadSha256 = payloadSha256; + } + + public String getCaptureStatus() { + return captureStatus; + } + + public void setCaptureStatus(String captureStatus) { + this.captureStatus = captureStatus; + } + + public Boolean getDuplicatePayloadChanged() { + return duplicatePayloadChanged; + } + + public void setDuplicatePayloadChanged(Boolean duplicatePayloadChanged) { + this.duplicatePayloadChanged = duplicatePayloadChanged; + } + + public String getSafeErrorSummary() { + return safeErrorSummary; + } + + public void setSafeErrorSummary(String safeErrorSummary) { + this.safeErrorSummary = safeErrorSummary; + } + + public LocalDateTime getReceivedAt() { + return receivedAt; + } + + public void setReceivedAt(LocalDateTime receivedAt) { + this.receivedAt = receivedAt; + } + + public LocalDateTime getSourceSentAt() { + return sourceSentAt; + } + + public void setSourceSentAt(LocalDateTime sourceSentAt) { + this.sourceSentAt = sourceSentAt; + } + + public String getSenderSummary() { + return senderSummary; + } + + public void setSenderSummary(String senderSummary) { + this.senderSummary = senderSummary; + } + + public String getSubject() { + return subject; + } + + public void setSubject(String subject) { + this.subject = subject; + } + + public String getSafeSnippet() { + return safeSnippet; + } + + public void setSafeSnippet(String safeSnippet) { + this.safeSnippet = safeSnippet; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/domain/SourceMessageMediaEntity.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/domain/SourceMessageMediaEntity.java new file mode 100644 index 0000000..5f0346c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/domain/SourceMessageMediaEntity.java @@ -0,0 +1,105 @@ +package cn.nianxx.thhotel.platform.message.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * 来源消息媒体引用实体。externalUrl 只在受控原文读取场景返回,普通查询不得暴露。 + */ +@TableName("platform_source_message_media") +public class SourceMessageMediaEntity { + + /** 媒体引用记录内部主键。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 所属 SourceMessage Inbox ID。 */ + private Long inboxId; + /** 媒体类型稳定代码,例如 INLINE_IMAGE 或 ATTACHMENT。 */ + private String mediaType; + /** 附件或正文图片文件名,外部系统未提供时为空。 */ + private String fileName; + /** 媒体 MIME 类型,例如 image/png 或 application/pdf。 */ + private String contentType; + /** 媒体大小字节数,外部系统未提供时为空。 */ + private Long sizeBytes; + /** AgentBus 返回的长期有效媒体 URL,普通查询接口不得返回。 */ + private String externalUrl; + /** 外部系统媒体 ID,用于排查和追溯。 */ + private String externalMediaId; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getInboxId() { + return inboxId; + } + + public void setInboxId(Long inboxId) { + this.inboxId = inboxId; + } + + public String getMediaType() { + return mediaType; + } + + public void setMediaType(String mediaType) { + this.mediaType = mediaType; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public String getContentType() { + return contentType; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } + + public Long getSizeBytes() { + return sizeBytes; + } + + public void setSizeBytes(Long sizeBytes) { + this.sizeBytes = sizeBytes; + } + + public String getExternalUrl() { + return externalUrl; + } + + public void setExternalUrl(String externalUrl) { + this.externalUrl = externalUrl; + } + + public String getExternalMediaId() { + return externalMediaId; + } + + public void setExternalMediaId(String externalMediaId) { + this.externalMediaId = externalMediaId; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/domain/SourceMessageOriginalAccessAuditEntity.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/domain/SourceMessageOriginalAccessAuditEntity.java new file mode 100644 index 0000000..cf5571a --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/domain/SourceMessageOriginalAccessAuditEntity.java @@ -0,0 +1,85 @@ +package cn.nianxx.thhotel.platform.message.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * SourceMessage 原文读取审计实体。记录受控接口读取正文、HTML 或媒体 URL 的访问痕迹。 + */ +@TableName("platform_source_message_original_access_audit") +public class SourceMessageOriginalAccessAuditEntity { + + /** 原文读取审计记录内部主键。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 被读取的 SourceMessage Inbox ID。 */ + private Long inboxId; + /** 读取原文的调用方或操作者标识。 */ + private String actorId; + /** 原文读取发生的业务或运维场景。 */ + private String accessScene; + /** 访问结果稳定代码,例如 GRANTED。 */ + private String accessResult; + /** 原文读取发生 UTC 时间。 */ + private LocalDateTime accessedAt; + /** 审计记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getInboxId() { + return inboxId; + } + + public void setInboxId(Long inboxId) { + this.inboxId = inboxId; + } + + public String getActorId() { + return actorId; + } + + public void setActorId(String actorId) { + this.actorId = actorId; + } + + public String getAccessScene() { + return accessScene; + } + + public void setAccessScene(String accessScene) { + this.accessScene = accessScene; + } + + public String getAccessResult() { + return accessResult; + } + + public void setAccessResult(String accessResult) { + this.accessResult = accessResult; + } + + public LocalDateTime getAccessedAt() { + return accessedAt; + } + + public void setAccessedAt(LocalDateTime accessedAt) { + this.accessedAt = accessedAt; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/domain/SourceMessagePayloadEntity.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/domain/SourceMessagePayloadEntity.java new file mode 100644 index 0000000..0e18f5d --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/domain/SourceMessagePayloadEntity.java @@ -0,0 +1,75 @@ +package cn.nianxx.thhotel.platform.message.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * 来源消息原始载荷实体。该表用于追溯和排查,不通过普通查询接口返回。 + */ +@TableName("platform_source_message_payload") +public class SourceMessagePayloadEntity { + + /** 原始 payload 记录内部主键。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 所属 SourceMessage Inbox ID。 */ + private Long inboxId; + /** AgentBus 规范 JSON 原始载荷,普通查询接口不得返回。 */ + private String payloadJson; + /** payloadJson 的 SHA-256 哈希。 */ + private String payloadSha256; + /** payload 结构版本,用于后续兼容不同外部协议版本。 */ + private String schemaVersion; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getInboxId() { + return inboxId; + } + + public void setInboxId(Long inboxId) { + this.inboxId = inboxId; + } + + public String getPayloadJson() { + return payloadJson; + } + + public void setPayloadJson(String payloadJson) { + this.payloadJson = payloadJson; + } + + public String getPayloadSha256() { + return payloadSha256; + } + + public void setPayloadSha256(String payloadSha256) { + this.payloadSha256 = payloadSha256; + } + + public String getSchemaVersion() { + return schemaVersion; + } + + public void setSchemaVersion(String schemaVersion) { + this.schemaVersion = schemaVersion; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/mapper/SourceMessageBodyMapper.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/mapper/SourceMessageBodyMapper.java new file mode 100644 index 0000000..84feec6 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/mapper/SourceMessageBodyMapper.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.platform.message.mapper; + +import cn.nianxx.thhotel.platform.message.domain.SourceMessageBodyEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * SourceMessage 正文 Mapper,仅供平台消息服务在受控边界内使用。 + */ +@Mapper +public interface SourceMessageBodyMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/mapper/SourceMessageInboxMapper.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/mapper/SourceMessageInboxMapper.java new file mode 100644 index 0000000..f202ec5 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/mapper/SourceMessageInboxMapper.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.platform.message.mapper; + +import cn.nianxx.thhotel.platform.message.domain.SourceMessageInboxEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * SourceMessage Inbox 索引表 Mapper,只负责本表持久化访问。 + */ +@Mapper +public interface SourceMessageInboxMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/mapper/SourceMessageMediaMapper.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/mapper/SourceMessageMediaMapper.java new file mode 100644 index 0000000..63c4abe --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/mapper/SourceMessageMediaMapper.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.platform.message.mapper; + +import cn.nianxx.thhotel.platform.message.domain.SourceMessageMediaEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * SourceMessage 媒体引用 Mapper,媒体 URL 不得进入普通查询响应。 + */ +@Mapper +public interface SourceMessageMediaMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/mapper/SourceMessageOriginalAccessAuditMapper.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/mapper/SourceMessageOriginalAccessAuditMapper.java new file mode 100644 index 0000000..73e4161 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/mapper/SourceMessageOriginalAccessAuditMapper.java @@ -0,0 +1,10 @@ +package cn.nianxx.thhotel.platform.message.mapper; + +import cn.nianxx.thhotel.platform.message.domain.SourceMessageOriginalAccessAuditEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** + * SourceMessage 原文读取审计 Mapper,仅供 Repository 写入受控访问记录。 + */ +public interface SourceMessageOriginalAccessAuditMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/mapper/SourceMessagePayloadMapper.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/mapper/SourceMessagePayloadMapper.java new file mode 100644 index 0000000..a2ca7ec --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/mapper/SourceMessagePayloadMapper.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.platform.message.mapper; + +import cn.nianxx.thhotel.platform.message.domain.SourceMessagePayloadEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * SourceMessage 原始 payload Mapper,不向 Controller 直接暴露。 + */ +@Mapper +public interface SourceMessagePayloadMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java new file mode 100644 index 0000000..6eab17b --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java @@ -0,0 +1,318 @@ +package cn.nianxx.thhotel.platform.message.repository; + +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageMedia; +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxDraft; +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot; +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalAccessAuditDraft; +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalContent; +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalMediaItem; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult; +import cn.nianxx.thhotel.platform.message.common.request.SourceMessageQueryRequest; +import cn.nianxx.thhotel.platform.message.common.enums.SourceMessageBodyContentType; +import cn.nianxx.thhotel.platform.message.common.enums.SourceMessageCaptureStatus; +import cn.nianxx.thhotel.platform.message.domain.SourceMessageBodyEntity; +import cn.nianxx.thhotel.platform.message.domain.SourceMessageInboxEntity; +import cn.nianxx.thhotel.platform.message.domain.SourceMessageMediaEntity; +import cn.nianxx.thhotel.platform.message.domain.SourceMessageOriginalAccessAuditEntity; +import cn.nianxx.thhotel.platform.message.domain.SourceMessagePayloadEntity; +import cn.nianxx.thhotel.platform.message.mapper.SourceMessageBodyMapper; +import cn.nianxx.thhotel.platform.message.mapper.SourceMessageInboxMapper; +import cn.nianxx.thhotel.platform.message.mapper.SourceMessageMediaMapper; +import cn.nianxx.thhotel.platform.message.mapper.SourceMessageOriginalAccessAuditMapper; +import cn.nianxx.thhotel.platform.message.mapper.SourceMessagePayloadMapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Repository; + +/** + * SourceMessage Inbox 的 MyBatis-Plus 持久化实现。数据库 Entity 和 Mapper 只停留在本层。 + */ +@Repository +public class MybatisSourceMessageInboxRepository implements SourceMessageInboxRepository { + + private final SourceMessageInboxMapper inboxMapper; + private final SourceMessagePayloadMapper payloadMapper; + private final SourceMessageBodyMapper bodyMapper; + private final SourceMessageMediaMapper mediaMapper; + private final SourceMessageOriginalAccessAuditMapper originalAccessAuditMapper; + + /** + * 注入本模块 Mapper,数据库访问细节只保留在 Repository 实现内部。 + */ + public MybatisSourceMessageInboxRepository( + SourceMessageInboxMapper inboxMapper, + SourceMessagePayloadMapper payloadMapper, + SourceMessageBodyMapper bodyMapper, + SourceMessageMediaMapper mediaMapper, + SourceMessageOriginalAccessAuditMapper originalAccessAuditMapper) { + this.inboxMapper = inboxMapper; + this.payloadMapper = payloadMapper; + this.bodyMapper = bodyMapper; + this.mediaMapper = mediaMapper; + this.originalAccessAuditMapper = originalAccessAuditMapper; + } + + /** + * 根据内部主键读取 Inbox 快照,避免 Service 直接接触数据库 Entity。 + */ + @Override + public Optional findById(Long id) { + if (id == null) { + return Optional.empty(); + } + return Optional.ofNullable(inboxMapper.selectById(id)).map(this::toSnapshot); + } + + /** + * 根据 SourceMessage 幂等键读取已有记录,用于重复投递判断。 + */ + @Override + public Optional findByIdempotencyKey( + String hotelId, + String provider, + String channel, + String externalMessageId) { + SourceMessageInboxEntity entity = inboxMapper.selectOne(Wrappers.lambdaQuery() + .eq(SourceMessageInboxEntity::getHotelId, hotelId) + .eq(SourceMessageInboxEntity::getProvider, provider) + .eq(SourceMessageInboxEntity::getChannel, channel) + .eq(SourceMessageInboxEntity::getExternalMessageId, externalMessageId)); + return Optional.ofNullable(entity).map(this::toSnapshot); + } + + /** + * 执行 SourceMessage 安全摘要分页查询,只读取列表和详情需要的索引字段。 + */ + @Override + public SourceMessagePageResult query( + SourceMessageQueryRequest request, + int pageNum, + int pageSize) { + Page page = inboxMapper.selectPage(Page.of(pageNum, pageSize), + Wrappers.lambdaQuery() + .eq(hasText(request.hotelId()), SourceMessageInboxEntity::getHotelId, trim(request.hotelId())) + .eq(hasText(request.externalMessageId()), + SourceMessageInboxEntity::getExternalMessageId, + trim(request.externalMessageId())) + .eq(hasText(request.externalConversationId()), + SourceMessageInboxEntity::getExternalConversationId, + trim(request.externalConversationId())) + .eq(hasText(request.captureStatus()), + SourceMessageInboxEntity::getCaptureStatus, + trim(request.captureStatus())) + .orderByDesc(SourceMessageInboxEntity::getReceivedAt)); + List items = page.getRecords().stream().map(this::toSnapshot).toList(); + return new SourceMessagePageResult<>(items, page.getTotal(), pageNum, pageSize); + } + + /** + * 插入 Inbox 主记录及原始 payload;RECEIVED 状态额外保存正文和媒体引用。 + */ + @Override + public Long insert(SourceMessageInboxDraft draft) { + SourceMessageInboxEntity inbox = toInboxEntity(draft); + inboxMapper.insert(inbox); + insertPayload(draft, inbox.getId()); + if (SourceMessageCaptureStatus.RECEIVED.code().equals(draft.captureStatus())) { + insertBody(draft, inbox.getId()); + insertMedia(draft.mediaItems(), inbox.getId(), draft.receivedAt()); + } + return inbox.getId(); + } + + /** + * 记录重复投递 payload 差异,不覆盖第一次保存的原始 payload。 + */ + @Override + public void markDuplicatePayloadChanged(Long id, String safeErrorSummary, LocalDateTime updatedAt) { + SourceMessageInboxEntity entity = new SourceMessageInboxEntity(); + entity.setId(id); + entity.setDuplicatePayloadChanged(Boolean.TRUE); + entity.setSafeErrorSummary(safeErrorSummary); + entity.setUpdatedAt(updatedAt); + inboxMapper.updateById(entity); + } + + /** + * 读取 SourceMessage 原文内容和媒体 URL。该方法不供普通摘要查询调用。 + */ + @Override + public Optional findOriginalContent(Long id) { + if (id == null || inboxMapper.selectById(id) == null) { + return Optional.empty(); + } + SourceMessageBodyEntity body = bodyMapper.selectOne(Wrappers.lambdaQuery() + .eq(SourceMessageBodyEntity::getInboxId, id)); + List mediaItems = mediaMapper.selectList(Wrappers.lambdaQuery() + .eq(SourceMessageMediaEntity::getInboxId, id) + .orderByAsc(SourceMessageMediaEntity::getId)) + .stream() + .map(this::toOriginalMediaItem) + .toList(); + return Optional.of(new SourceMessageOriginalContent( + id, + body == null ? null : body.getTextBody(), + body == null ? null : body.getHtmlBody(), + mediaItems + )); + } + + /** + * 写入原文读取审计,Repository 负责把服务层审计草稿转换为数据库实体。 + */ + @Override + public void insertOriginalAccessAudit(SourceMessageOriginalAccessAuditDraft draft) { + SourceMessageOriginalAccessAuditEntity entity = new SourceMessageOriginalAccessAuditEntity(); + entity.setInboxId(draft.inboxId()); + entity.setActorId(draft.actorId()); + entity.setAccessScene(draft.accessScene()); + entity.setAccessResult(draft.accessResult()); + entity.setAccessedAt(draft.accessedAt()); + entity.setCreatedAt(draft.accessedAt()); + originalAccessAuditMapper.insert(entity); + } + + /** + * 将服务层入库草稿转换为 Inbox 数据库实体。 + */ + private SourceMessageInboxEntity toInboxEntity(SourceMessageInboxDraft draft) { + SourceMessageInboxEntity inbox = new SourceMessageInboxEntity(); + inbox.setHotelId(draft.hotelId()); + inbox.setProvider(draft.provider()); + inbox.setChannel(draft.channel()); + inbox.setExternalMessageId(draft.externalMessageId()); + inbox.setExternalConversationId(draft.externalConversationId()); + inbox.setProviderFrameId(draft.providerFrameId()); + inbox.setProviderSessionId(draft.providerSessionId()); + inbox.setPayloadSha256(draft.payloadSha256()); + inbox.setCaptureStatus(draft.captureStatus()); + inbox.setDuplicatePayloadChanged(draft.duplicatePayloadChanged()); + inbox.setSafeErrorSummary(draft.safeErrorSummary()); + inbox.setReceivedAt(draft.receivedAt()); + inbox.setSourceSentAt(draft.sourceSentAt()); + inbox.setSenderSummary(draft.senderSummary()); + inbox.setSubject(draft.subject()); + inbox.setSafeSnippet(draft.safeSnippet()); + inbox.setCreatedAt(draft.receivedAt()); + inbox.setUpdatedAt(draft.receivedAt()); + return inbox; + } + + /** + * 保存原始 payload 记录,普通查询接口不会读取该表内容。 + */ + private void insertPayload(SourceMessageInboxDraft draft, Long inboxId) { + SourceMessagePayloadEntity payload = new SourceMessagePayloadEntity(); + payload.setInboxId(inboxId); + payload.setPayloadJson(draft.payloadJson()); + payload.setPayloadSha256(draft.payloadSha256()); + payload.setSchemaVersion(draft.schemaVersion()); + payload.setCreatedAt(draft.receivedAt()); + payloadMapper.insert(payload); + } + + /** + * 保存正文记录,为后续受控原文读取保留追溯能力。 + */ + private void insertBody(SourceMessageInboxDraft draft, Long inboxId) { + SourceMessageBodyEntity body = new SourceMessageBodyEntity(); + body.setInboxId(inboxId); + body.setContentType(SourceMessageBodyContentType.MIXED.code()); + body.setTextBody(draft.textBody()); + body.setHtmlBody(draft.htmlBody()); + body.setBodySha256(sha256(draft.textBody() + "\n" + draft.htmlBody())); + body.setCreatedAt(draft.receivedAt()); + bodyMapper.insert(body); + } + + /** + * 保存媒体引用记录,只登记外部 URL,不在当前服务下载附件。 + */ + private void insertMedia(List mediaItems, Long inboxId, LocalDateTime now) { + if (mediaItems == null || mediaItems.isEmpty()) { + return; + } + for (CaptureSourceMessageMedia item : mediaItems) { + SourceMessageMediaEntity media = new SourceMessageMediaEntity(); + media.setInboxId(inboxId); + media.setMediaType(item.mediaType()); + media.setFileName(item.fileName()); + media.setContentType(item.contentType()); + media.setSizeBytes(item.sizeBytes()); + media.setExternalUrl(item.externalUrl()); + media.setExternalMediaId(item.externalMediaId()); + media.setCreatedAt(now); + mediaMapper.insert(media); + } + } + + /** + * 判断查询条件是否有有效文本,避免空白字符串参与数据库过滤。 + */ + private boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } + + /** + * 统一清理查询参数首尾空白,避免 Repository 调用 Mapper 时重复表达 trim 逻辑。 + */ + private String trim(String value) { + return value == null ? null : value.trim(); + } + + /** + * 将数据库实体转换为服务层快照,隐藏 Entity 和 Mapper 细节。 + */ + private SourceMessageInboxSnapshot toSnapshot(SourceMessageInboxEntity entity) { + return new SourceMessageInboxSnapshot( + entity.getId(), + entity.getHotelId(), + entity.getProvider(), + entity.getChannel(), + entity.getExternalMessageId(), + entity.getExternalConversationId(), + entity.getPayloadSha256(), + entity.getCaptureStatus(), + Boolean.TRUE.equals(entity.getDuplicatePayloadChanged()), + entity.getReceivedAt(), + entity.getSourceSentAt(), + entity.getSenderSummary(), + entity.getSubject(), + entity.getSafeSnippet() + ); + } + + /** + * 将媒体 Entity 转为原文读取内部 DTO,避免 Entity 泄漏到 Service。 + */ + private SourceMessageOriginalMediaItem toOriginalMediaItem(SourceMessageMediaEntity entity) { + return new SourceMessageOriginalMediaItem( + entity.getMediaType(), + entity.getFileName(), + entity.getContentType(), + entity.getSizeBytes(), + entity.getExternalUrl(), + entity.getExternalMediaId() + ); + } + + /** + * 计算正文内容 SHA-256,用于后续排查正文是否被意外改写。 + */ + private String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(bytes); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("当前 Java 运行时不支持 SHA-256", exception); + } + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java new file mode 100644 index 0000000..ef90593 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java @@ -0,0 +1,58 @@ +package cn.nianxx.thhotel.platform.message.repository; + +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxDraft; +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot; +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalAccessAuditDraft; +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalContent; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult; +import cn.nianxx.thhotel.platform.message.common.request.SourceMessageQueryRequest; +import java.time.LocalDateTime; +import java.util.Optional; + +/** + * SourceMessage Inbox 持久化端口。应用服务依赖该端口,不直接依赖 MyBatis Mapper 或数据库 Entity。 + */ +public interface SourceMessageInboxRepository { + + /** + * 按内部 SourceMessage ID 查询已保存的 Inbox 快照。 + */ + Optional findById(Long id); + + /** + * 按酒店、来源、渠道、外部邮件 ID 查询幂等记录。 + */ + Optional findByIdempotencyKey( + String hotelId, + String provider, + String channel, + String externalMessageId); + + /** + * 按查询条件分页读取 Inbox 快照,分页参数已经由 Service 层归一化。 + */ + SourceMessagePageResult query( + SourceMessageQueryRequest request, + int pageNum, + int pageSize); + + /** + * 插入 Inbox 及其 payload/body/media 子记录,返回内部 SourceMessage ID。 + */ + Long insert(SourceMessageInboxDraft draft); + + /** + * 幂等命中但 payload 变化时,标记差异并保存安全排查摘要。 + */ + void markDuplicatePayloadChanged(Long id, String safeErrorSummary, LocalDateTime updatedAt); + + /** + * 按内部 SourceMessage ID 读取原文内容和媒体 URL,仅供受控原文读取服务使用。 + */ + Optional findOriginalContent(Long id); + + /** + * 写入 SourceMessage 原文读取审计记录,记录调用方、场景和访问时间。 + */ + void insertOriginalAccessAudit(SourceMessageOriginalAccessAuditDraft draft); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageCaptureService.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageCaptureService.java new file mode 100644 index 0000000..3c6fedd --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageCaptureService.java @@ -0,0 +1,18 @@ +package cn.nianxx.thhotel.platform.message.service; + +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult; + +/** + * 来源消息捕获服务。对外只暴露稳定捕获命令,不暴露 AgentBus DTO 或数据库 Entity。 + */ +public interface SourceMessageCaptureService { + + /** + * 捕获一条来源消息,完成幂等判断、安全摘要生成和落库状态转换。 + * + * @param command 来源消息捕获命令 + * @return 捕获结果,包含内部 SourceMessage ID 和是否新建 + */ + SourceMessageCaptureResult capture(CaptureSourceMessageCommand command); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageOriginalService.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageOriginalService.java new file mode 100644 index 0000000..e101e9b --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageOriginalService.java @@ -0,0 +1,28 @@ +package cn.nianxx.thhotel.platform.message.service; + +import cn.nianxx.thhotel.platform.message.common.request.SourceMessageOriginalAccessRequest; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageOriginalResponse; +import java.util.Optional; + +/** + * SourceMessage 原文读取服务。该服务负责权限口令校验、原文读取和访问审计。 + */ +public interface SourceMessageOriginalService { + + /** + * 校验原文读取访问口令,当前用于替代尚未落地的角色权限体系。 + * + * @param submittedAccessKey 调用方提交的原文读取口令 + * @return 口令可用时返回 true + */ + boolean canReadOriginal(String submittedAccessKey); + + /** + * 按内部 SourceMessage ID 读取原文内容,并记录成功访问审计。 + * + * @param inboxId SourceMessage Inbox ID + * @param request 原文读取调用方和访问场景 + * @return 存在时返回原文内容,不存在时为空 + */ + Optional readOriginal(Long inboxId, SourceMessageOriginalAccessRequest request); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageQueryService.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageQueryService.java new file mode 100644 index 0000000..fb11800 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageQueryService.java @@ -0,0 +1,28 @@ +package cn.nianxx.thhotel.platform.message.service; + +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse; +import cn.nianxx.thhotel.platform.message.common.request.SourceMessageQueryRequest; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult; +import java.util.Optional; + +/** + * 来源消息安全查询服务。列表和普通详情只能返回安全摘要,不返回正文、HTML、附件 URL 或 payload。 + */ +public interface SourceMessageQueryService { + + /** + * 按安全查询条件分页读取 SourceMessage Inbox 摘要。 + * + * @param request 查询条件和分页参数 + * @return 分页后的安全摘要结果 + */ + SourceMessagePageResult query(SourceMessageQueryRequest request); + + /** + * 按内部 SourceMessage ID 读取单条安全摘要。 + * + * @param inboxId 内部 SourceMessage Inbox ID + * @return 存在时返回安全摘要,不存在时为空 + */ + Optional getSummary(Long inboxId); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageCaptureServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageCaptureServiceImpl.java new file mode 100644 index 0000000..a5f2ba9 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageCaptureServiceImpl.java @@ -0,0 +1,270 @@ +package cn.nianxx.thhotel.platform.message.service.impl; + +import cn.nianxx.thhotel.platform.message.common.enums.SourceMessageCaptureFailureReason; +import cn.nianxx.thhotel.platform.message.common.enums.SourceMessageCaptureStatus; +import cn.nianxx.thhotel.platform.message.repository.SourceMessageInboxRepository; +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand; +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageMedia; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult; +import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService; +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxDraft; +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.HexFormat; +import java.util.List; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 来源消息捕获服务实现。负责幂等、状态、安全摘要和事务边界,不依赖数据库 Mapper。 + */ +@Service +public class SourceMessageCaptureServiceImpl implements SourceMessageCaptureService { + + private static final String DUPLICATE_PAYLOAD_CHANGED_SUMMARY = "重复投递 payload 与原始 payload 不一致,已保留原始载荷。"; + + private final SourceMessageInboxRepository inboxRepository; + private final SourceMessageSafetySanitizer safetySanitizer; + + /** + * 注入 SourceMessage 持久化边界和安全摘要工具,Service 实现不直接依赖 Mapper。 + */ + public SourceMessageCaptureServiceImpl( + SourceMessageInboxRepository inboxRepository, + SourceMessageSafetySanitizer safetySanitizer) { + this.inboxRepository = inboxRepository; + this.safetySanitizer = safetySanitizer; + } + + /** + * 捕获 AgentBus 等外部入口映射后的稳定消息命令,只落 SourceMessage Inbox,不触发业务任务。 + */ + @Override + @Transactional + public SourceMessageCaptureResult capture(CaptureSourceMessageCommand command) { + String hotelId = requireText(command.hotelId(), "hotelId"); + String provider = requireText(command.provider(), "provider"); + String channel = requireText(command.channel(), "channel"); + String externalMessageId = trimToNull(command.externalMessageId()); + String payloadJson = command.payloadJson() == null ? "" : command.payloadJson(); + String payloadSha256 = sha256(payloadJson); + if (externalMessageId == null) { + return captureFailed( + command, + hotelId, + provider, + channel, + null, + payloadSha256, + SourceMessageCaptureFailureReason.MISSING_EXTERNAL_MESSAGE_ID); + } + + SourceMessageInboxSnapshot existing = selectExisting(hotelId, provider, channel, externalMessageId); + if (existing != null) { + return existingResult(existing, payloadSha256); + } + + SourceMessageCaptureFailureReason mediaValidationError = validateMediaItems(command.mediaItems()); + if (mediaValidationError != null) { + return captureFailed( + command, + hotelId, + provider, + channel, + externalMessageId, + payloadSha256, + mediaValidationError); + } + + SourceMessageInboxDraft draft = buildDraft( + command, + hotelId, + provider, + channel, + externalMessageId, + payloadSha256, + SourceMessageCaptureStatus.RECEIVED, + null); + try { + Long inboxId = inboxRepository.insert(draft); + return new SourceMessageCaptureResult(inboxId, true, false, SourceMessageCaptureStatus.RECEIVED.code()); + } catch (DuplicateKeyException exception) { + // 并发重复投递时,数据库唯一键是最终幂等防线;命中后回查已有 Inbox。 + SourceMessageInboxSnapshot concurrentlyCreated = selectExisting(hotelId, provider, channel, externalMessageId); + if (concurrentlyCreated != null) { + return existingResult(concurrentlyCreated, payloadSha256); + } + throw exception; + } + } + + /** + * 按失败原因保存 FAILED 状态 Inbox,错误摘要必须是安全摘要而不是原始正文。 + */ + private SourceMessageCaptureResult captureFailed( + CaptureSourceMessageCommand command, + String hotelId, + String provider, + String channel, + String externalMessageId, + String payloadSha256, + SourceMessageCaptureFailureReason failureReason) { + SourceMessageInboxDraft draft = buildDraft( + command, + hotelId, + provider, + channel, + externalMessageId, + payloadSha256, + SourceMessageCaptureStatus.FAILED, + failureReason.safeSummary()); + Long inboxId = inboxRepository.insert(draft); + return new SourceMessageCaptureResult(inboxId, true, false, SourceMessageCaptureStatus.FAILED.code()); + } + + /** + * 按 SourceMessage 幂等键回查已有记录,用于重复投递和并发唯一键冲突处理。 + */ + private SourceMessageInboxSnapshot selectExisting( + String hotelId, + String provider, + String channel, + String externalMessageId) { + return inboxRepository.findByIdempotencyKey(hotelId, provider, channel, externalMessageId).orElse(null); + } + + /** + * 将已存在的 Inbox 转换为捕获结果,并在 payload 变化时只记录差异标记。 + */ + private SourceMessageCaptureResult existingResult(SourceMessageInboxSnapshot existing, String payloadSha256) { + boolean payloadChanged = !payloadSha256.equals(existing.payloadSha256()); + if (payloadChanged && !existing.duplicatePayloadChanged()) { + inboxRepository.markDuplicatePayloadChanged(existing.id(), DUPLICATE_PAYLOAD_CHANGED_SUMMARY, nowUtc()); + } + return new SourceMessageCaptureResult(existing.id(), false, payloadChanged, existing.captureStatus()); + } + + /** + * 校验媒体引用最小必填字段,避免保存无法追溯的附件或内嵌媒体记录。 + */ + private SourceMessageCaptureFailureReason validateMediaItems(List mediaItems) { + if (mediaItems == null || mediaItems.isEmpty()) { + return null; + } + for (CaptureSourceMessageMedia item : mediaItems) { + if (item == null) { + return SourceMessageCaptureFailureReason.INVALID_MEDIA_ITEM; + } + if (trimToNull(item.mediaType()) == null) { + return SourceMessageCaptureFailureReason.MISSING_MEDIA_TYPE; + } + if (trimToNull(item.externalUrl()) == null) { + return SourceMessageCaptureFailureReason.MISSING_MEDIA_EXTERNAL_URL; + } + } + return null; + } + + /** + * 构建入库草稿,统一生成安全主题、发送人摘要和正文摘要。 + */ + private SourceMessageInboxDraft buildDraft( + CaptureSourceMessageCommand command, + String hotelId, + String provider, + String channel, + String externalMessageId, + String payloadSha256, + SourceMessageCaptureStatus captureStatus, + String safeErrorSummary) { + LocalDateTime now = nowUtc(); + String textBody = command.textBody() == null ? "" : command.textBody(); + String htmlBody = command.htmlBody() == null ? "" : command.htmlBody(); + // 邮件主题也可能携带联系方式或带 token 的 URL,普通查询只保存可展示摘要。 + String safeSubject = safetySanitizer.safeSnippet(command.subject()); + String safeSnippet = SourceMessageCaptureStatus.FAILED == captureStatus + ? safeErrorSummary + : safetySanitizer.safeSnippet(textBody); + return new SourceMessageInboxDraft( + hotelId, + provider, + channel, + externalMessageId, + trimToNull(command.externalConversationId()), + trimToNull(command.providerFrameId()), + trimToNull(command.providerSessionId()), + payloadSha256, + captureStatus.code(), + false, + safeErrorSummary, + now, + toLocalDateTime(command.sourceSentAt()), + safetySanitizer.senderSummary(command.senderIdentifier()), + safeSubject, + safeSnippet, + command.payloadJson() == null ? "" : command.payloadJson(), + trimToNull(command.schemaVersion()), + textBody, + htmlBody, + command.mediaItems() + ); + } + + /** + * 计算原始 payload 的 SHA-256,用于重复投递内容变化判断。 + */ + private String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(bytes); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("当前 Java 运行时不支持 SHA-256", exception); + } + } + + /** + * 校验必填文本字段,返回清理后的值,防止空白字符串进入幂等键。 + */ + private String requireText(String value, String fieldName) { + String trimmed = trimToNull(value); + if (trimmed == null) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + return trimmed; + } + + /** + * 将空白字符串统一转换为 null,保证可选字段入库语义一致。 + */ + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + /** + * 将来源系统时间转换为 UTC 本地时间字段,缺失时保持为空。 + */ + private LocalDateTime toLocalDateTime(Instant instant) { + if (instant == null) { + return null; + } + return LocalDateTime.ofInstant(instant, ZoneOffset.UTC); + } + + /** + * 生成当前 UTC 时间,保证数据库业务时间不混入服务器本地时区。 + */ + private LocalDateTime nowUtc() { + return LocalDateTime.now(ZoneOffset.UTC); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageOriginalServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageOriginalServiceImpl.java new file mode 100644 index 0000000..59b803d --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageOriginalServiceImpl.java @@ -0,0 +1,141 @@ +package cn.nianxx.thhotel.platform.message.service.impl; + +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalAccessAuditDraft; +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalContent; +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalMediaItem; +import cn.nianxx.thhotel.platform.message.common.enums.SourceMessageMediaType; +import cn.nianxx.thhotel.platform.message.common.enums.SourceMessageOriginalAccessResult; +import cn.nianxx.thhotel.platform.message.common.request.SourceMessageOriginalAccessRequest; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageOriginalMediaResponse; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageOriginalResponse; +import cn.nianxx.thhotel.platform.message.repository.SourceMessageInboxRepository; +import cn.nianxx.thhotel.platform.message.service.SourceMessageOriginalService; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * SourceMessage 原文读取服务实现。当前用受控访问口令替代尚未落地的角色权限体系。 + */ +@Service +public class SourceMessageOriginalServiceImpl implements SourceMessageOriginalService { + + private final SourceMessageInboxRepository inboxRepository; + private final String configuredAccessKey; + + /** + * 注入 SourceMessage 持久化边界和原文读取访问口令,服务层负责审计写入。 + */ + public SourceMessageOriginalServiceImpl( + SourceMessageInboxRepository inboxRepository, + @Value("${source-message.original-read.access-key:}") String configuredAccessKey) { + this.inboxRepository = inboxRepository; + this.configuredAccessKey = configuredAccessKey; + } + + /** + * 校验原文读取口令。配置为空时默认关闭原文读取能力,避免误开放敏感正文和媒体 URL。 + */ + @Override + public boolean canReadOriginal(String submittedAccessKey) { + String configured = trimToNull(configuredAccessKey); + return configured != null && configured.equals(submittedAccessKey); + } + + /** + * 读取 SourceMessage 原文并记录成功访问审计。普通摘要查询不会调用该方法。 + */ + @Override + @Transactional + public Optional readOriginal( + Long inboxId, + SourceMessageOriginalAccessRequest request) { + String actorId = requireText(request.actorId(), "actorId"); + String accessScene = requireText(request.accessScene(), "accessScene"); + Optional content = inboxRepository.findOriginalContent(inboxId); + content.ifPresent(value -> inboxRepository.insertOriginalAccessAudit(new SourceMessageOriginalAccessAuditDraft( + value.inboxId(), + actorId, + accessScene, + SourceMessageOriginalAccessResult.GRANTED.code(), + nowUtc() + ))); + return content.map(this::toResponse); + } + + /** + * 将原文内容快照转换为 Controller 响应,并按媒体类型分组。 + */ + private SourceMessageOriginalResponse toResponse(SourceMessageOriginalContent content) { + return new SourceMessageOriginalResponse( + content.inboxId().toString(), + content.textBody(), + content.htmlBody(), + true, + filterMedia(content.mediaItems(), SourceMessageMediaType.INLINE_IMAGE.code()), + filterMedia(content.mediaItems(), SourceMessageMediaType.ATTACHMENT.code()) + ); + } + + /** + * 按媒体类型筛选原文响应媒体项,避免 Controller 了解媒体分类细节。 + */ + private List filterMedia( + List mediaItems, + String mediaType) { + if (mediaItems == null || mediaItems.isEmpty()) { + return List.of(); + } + return mediaItems.stream() + .filter(item -> mediaType.equals(item.mediaType())) + .map(this::toMediaResponse) + .toList(); + } + + /** + * 将原文读取内部媒体 DTO 转为 API 响应对象。 + */ + private SourceMessageOriginalMediaResponse toMediaResponse(SourceMessageOriginalMediaItem item) { + return new SourceMessageOriginalMediaResponse( + item.mediaType(), + item.fileName(), + item.contentType(), + item.sizeBytes(), + item.externalUrl(), + item.externalMediaId() + ); + } + + /** + * 校验审计必填文本,保证访问记录可以追溯到调用方和使用场景。 + */ + private String requireText(String value, String fieldName) { + String trimmed = trimToNull(value); + if (trimmed == null) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + return trimmed; + } + + /** + * 将空白字符串统一转换为空,避免空白访问口令或审计字段通过校验。 + */ + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + /** + * 生成当前 UTC 时间,确保审计时间不依赖服务器本地时区。 + */ + private LocalDateTime nowUtc() { + return LocalDateTime.now(ZoneOffset.UTC); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageQueryServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageQueryServiceImpl.java new file mode 100644 index 0000000..4d16e39 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageQueryServiceImpl.java @@ -0,0 +1,105 @@ +package cn.nianxx.thhotel.platform.message.service.impl; + +import cn.nianxx.thhotel.platform.message.repository.SourceMessageInboxRepository; +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult; +import cn.nianxx.thhotel.platform.message.common.request.SourceMessageQueryRequest; +import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService; +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Service; + +/** + * 来源消息安全查询服务。该服务只返回摘要字段,原文读取必须走独立权限接口。 + */ +@Service +public class SourceMessageQueryServiceImpl implements SourceMessageQueryService { + + private static final int DEFAULT_PAGE_NUM = 1; + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_SIZE = 100; + + private final SourceMessageInboxRepository inboxRepository; + + /** + * 注入 SourceMessage 持久化边界,查询服务不直接依赖 Mapper。 + */ + public SourceMessageQueryServiceImpl(SourceMessageInboxRepository inboxRepository) { + this.inboxRepository = inboxRepository; + } + + /** + * 分页查询 SourceMessage Inbox 安全摘要,最大页大小由服务端控制。 + */ + @Override + public SourceMessagePageResult query(SourceMessageQueryRequest request) { + int pageNum = normalizePageNum(request.pageNum()); + int pageSize = normalizePageSize(request.pageSize()); + SourceMessagePageResult page = inboxRepository.query(request, pageNum, pageSize); + List items = page.items().stream().map(this::toSummary).toList(); + return new SourceMessagePageResult<>(items, page.total(), pageNum, pageSize); + } + + /** + * 读取单条 SourceMessage 安全摘要,不返回正文、HTML、媒体 URL 或原始 payload。 + */ + @Override + public Optional getSummary(Long inboxId) { + return inboxRepository.findById(inboxId).map(this::toSummary); + } + + /** + * 规范化页码,缺失或非法页码统一回到第一页。 + */ + private int normalizePageNum(Integer pageNum) { + if (pageNum == null || pageNum < 1) { + return DEFAULT_PAGE_NUM; + } + return pageNum; + } + + /** + * 规范化页大小,防止调用方一次拉取过多 SourceMessage 摘要。 + */ + private int normalizePageSize(Integer pageSize) { + if (pageSize == null || pageSize < 1) { + return DEFAULT_PAGE_SIZE; + } + return Math.min(pageSize, MAX_PAGE_SIZE); + } + + /** + * 将持久化快照转换为 Controller 可返回的安全响应对象。 + */ + private SourceMessageSummaryResponse toSummary(SourceMessageInboxSnapshot snapshot) { + return new SourceMessageSummaryResponse( + snapshot.id().toString(), + snapshot.hotelId(), + snapshot.provider(), + snapshot.channel(), + snapshot.externalMessageId(), + snapshot.externalConversationId(), + snapshot.captureStatus(), + snapshot.duplicatePayloadChanged(), + toOffsetDateTime(snapshot.receivedAt()), + toOffsetDateTime(snapshot.sourceSentAt()), + snapshot.senderSummary(), + snapshot.subject(), + snapshot.safeSnippet() + ); + } + + /** + * 将数据库 UTC 时间转换为带 UTC offset 的 API 时间字段。 + */ + private OffsetDateTime toOffsetDateTime(LocalDateTime value) { + if (value == null) { + return null; + } + return value.atOffset(ZoneOffset.UTC); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageSafetySanitizer.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageSafetySanitizer.java new file mode 100644 index 0000000..f5d71d8 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageSafetySanitizer.java @@ -0,0 +1,58 @@ +package cn.nianxx.thhotel.platform.message.service.impl; + +import java.util.Locale; +import java.util.regex.Pattern; +import org.springframework.stereotype.Component; + +/** + * 来源消息安全摘要工具。列表和普通详情只能使用脱敏后的摘要,不能透出原文或媒体 URL。 + */ +@Component +public class SourceMessageSafetySanitizer { + + private static final int MAX_SNIPPET_LENGTH = 200; + private static final Pattern EMAIL_PATTERN = Pattern.compile( + "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"); + private static final Pattern URL_PATTERN = Pattern.compile("https?://\\S+"); + private static final Pattern LONG_DIGIT_PATTERN = Pattern.compile("\\d{8,}"); + private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+"); + + /** + * 生成发送人安全摘要,避免普通列表直接暴露完整邮箱或客户标识。 + */ + String senderSummary(String senderIdentifier) { + String value = trimToEmpty(senderIdentifier); + if (value.isEmpty()) { + return ""; + } + int atIndex = value.indexOf('@'); + if (atIndex > 0 && atIndex < value.length() - 1) { + return value.charAt(0) + "***" + value.substring(atIndex).toLowerCase(Locale.ROOT); + } + if (value.length() <= 2) { + return "*"; + } + return value.charAt(0) + "***" + value.charAt(value.length() - 1); + } + + /** + * 生成可展示短摘要,统一替换邮箱、URL 和长数字等敏感片段。 + */ + String safeSnippet(String textBody) { + String normalized = WHITESPACE_PATTERN.matcher(trimToEmpty(textBody)).replaceAll(" "); + String withoutUrls = URL_PATTERN.matcher(normalized).replaceAll("[url]"); + String withoutEmails = EMAIL_PATTERN.matcher(withoutUrls).replaceAll("[email]"); + String withoutLongDigits = LONG_DIGIT_PATTERN.matcher(withoutEmails).replaceAll("[number]"); + if (withoutLongDigits.length() <= MAX_SNIPPET_LENGTH) { + return withoutLongDigits; + } + return withoutLongDigits.substring(0, MAX_SNIPPET_LENGTH); + } + + /** + * 将空值转换为空字符串,并清理首尾空白,方便后续脱敏规则处理。 + */ + private String trimToEmpty(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/system/common/result/AgentBusProbeStatusResponse.java b/server/src/main/java/cn/nianxx/thhotel/platform/system/common/result/AgentBusProbeStatusResponse.java new file mode 100644 index 0000000..bd62daf --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/system/common/result/AgentBusProbeStatusResponse.java @@ -0,0 +1,23 @@ +package cn.nianxx.thhotel.platform.system.common.result; + +import java.time.OffsetDateTime; + +/** + * AgentBus 探针状态响应。只返回连接状态和安全计数器,不返回 Token 或 raw frame。 + */ +public record AgentBusProbeStatusResponse( + boolean enabled, + boolean connected, + boolean sessionReady, + long receivedFrameCount, + long capturedFrameCount, + long ignoredFrameCount, + long rejectedFrameCount, + long failedFrameCount, + String lastErrorCode, + String lastErrorSummary, + OffsetDateTime connectedAt, + OffsetDateTime disconnectedAt, + OffsetDateTime lastFrameReceivedAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/system/control/AgentBusProbeStatusController.java b/server/src/main/java/cn/nianxx/thhotel/platform/system/control/AgentBusProbeStatusController.java new file mode 100644 index 0000000..1a732ac --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/system/control/AgentBusProbeStatusController.java @@ -0,0 +1,30 @@ +package cn.nianxx.thhotel.platform.system.control; + +import cn.nianxx.thhotel.platform.system.common.result.AgentBusProbeStatusResponse; +import cn.nianxx.thhotel.platform.system.service.AgentBusProbeStatusService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * AgentBus 系统探针接口,用于查看 WebSocket 连接和入站处理状态。 + */ +@RestController +public class AgentBusProbeStatusController { + + private final AgentBusProbeStatusService statusService; + + /** + * 注入 AgentBus 探针状态服务,Controller 不直接操作 WebSocket 客户端。 + */ + public AgentBusProbeStatusController(AgentBusProbeStatusService statusService) { + this.statusService = statusService; + } + + /** + * 返回 AgentBus 安全状态,不包含 Token、Authorization、raw frame 或邮件正文。 + */ + @GetMapping("/api/system/agentbus-probe") + public AgentBusProbeStatusResponse status() { + return statusService.getStatus(); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/system/control/HealthController.java b/server/src/main/java/cn/nianxx/thhotel/platform/system/control/HealthController.java new file mode 100644 index 0000000..10f6045 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/system/control/HealthController.java @@ -0,0 +1,23 @@ +package cn.nianxx.thhotel.platform.system.control; + +import java.util.Map; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 系统健康检查入口,用于本地开发、部署探活和前端最小连通性验证。 + */ +@RestController +public class HealthController { + + /** + * 返回后端最小健康状态,用于前端联通性检查和部署探活。 + */ + @GetMapping("/api/health") + public Map health() { + return Map.of( + "status", "UP", + "service", "th-hotel-server" + ); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/system/service/AgentBusProbeStatusService.java b/server/src/main/java/cn/nianxx/thhotel/platform/system/service/AgentBusProbeStatusService.java new file mode 100644 index 0000000..4c79841 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/system/service/AgentBusProbeStatusService.java @@ -0,0 +1,16 @@ +package cn.nianxx.thhotel.platform.system.service; + +import cn.nianxx.thhotel.platform.system.common.result.AgentBusProbeStatusResponse; + +/** + * AgentBus 探针状态服务。系统 Controller 通过该服务读取安全状态,不直接接触 WebSocket 客户端。 + */ +public interface AgentBusProbeStatusService { + + /** + * 读取当前 AgentBus 连接和入站处理安全状态。 + * + * @return AgentBus 状态响应 + */ + AgentBusProbeStatusResponse getStatus(); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/system/service/impl/AgentBusProbeStatusServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/system/service/impl/AgentBusProbeStatusServiceImpl.java new file mode 100644 index 0000000..896ba57 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/system/service/impl/AgentBusProbeStatusServiceImpl.java @@ -0,0 +1,51 @@ +package cn.nianxx.thhotel.platform.system.service.impl; + +import cn.nianxx.thhotel.integrations.messaging.agentbus.adapter.AgentBusConnectionStatus; +import cn.nianxx.thhotel.integrations.messaging.agentbus.adapter.AgentBusProperties; +import cn.nianxx.thhotel.integrations.messaging.agentbus.adapter.AgentBusStatusSnapshot; +import cn.nianxx.thhotel.platform.system.common.result.AgentBusProbeStatusResponse; +import cn.nianxx.thhotel.platform.system.service.AgentBusProbeStatusService; +import org.springframework.stereotype.Service; + +/** + * AgentBus 探针状态服务实现。只组合配置开关和安全状态快照。 + */ +@Service +public class AgentBusProbeStatusServiceImpl implements AgentBusProbeStatusService { + + private final AgentBusProperties properties; + private final AgentBusConnectionStatus status; + + /** + * 注入 AgentBus 配置和状态组件,不读取或返回 WebSocket Token。 + */ + public AgentBusProbeStatusServiceImpl( + AgentBusProperties properties, + AgentBusConnectionStatus status) { + this.properties = properties; + this.status = status; + } + + /** + * 返回 AgentBus 当前安全状态,供系统探针接口展示。 + */ + @Override + public AgentBusProbeStatusResponse getStatus() { + AgentBusStatusSnapshot snapshot = status.snapshot(); + return new AgentBusProbeStatusResponse( + properties.getProbe().isEnabled(), + snapshot.connected(), + snapshot.sessionReady(), + snapshot.receivedFrameCount(), + snapshot.capturedFrameCount(), + snapshot.ignoredFrameCount(), + snapshot.rejectedFrameCount(), + snapshot.failedFrameCount(), + snapshot.lastErrorCode(), + snapshot.lastErrorSummary(), + snapshot.connectedAt(), + snapshot.disconnectedAt(), + snapshot.lastFrameReceivedAt() + ); + } +} diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml new file mode 100644 index 0000000..d5b97a5 --- /dev/null +++ b/server/src/main/resources/application.yml @@ -0,0 +1,36 @@ +spring: + application: + name: th-hotel-server + datasource: + url: ${TH_HOTEL_DB_URL:jdbc:h2:mem:th_hotel;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE} + username: ${TH_HOTEL_DB_USERNAME:sa} + password: ${TH_HOTEL_DB_PASSWORD:} + driver-class-name: ${TH_HOTEL_DB_DRIVER:org.h2.Driver} + flyway: + enabled: true + +mybatis-plus: + configuration: + map-underscore-to-camel-case: true + global-config: + banner: false + +source-message: + original-read: + access-key: ${SOURCE_MESSAGE_ORIGINAL_READ_ACCESS_KEY:} + +agentbus: + probe: + enabled: ${AGENTBUS_PROBE_ENABLED:false} + ws: + url: ${AGENTBUS_WS_URL:wss://mesh.nianxx.cn/ws} + token: ${AGENTBUS_WS_TOKEN:} + reconnect-delay: ${AGENTBUS_WS_RECONNECT_DELAY:5s} + connect-timeout: ${AGENTBUS_CONNECT_TIMEOUT:15s} + max-frame-bytes: ${AGENTBUS_MAX_FRAME_BYTES:1048576} + capture: + enabled: ${AGENTBUS_CAPTURE_ENABLED:true} + default-hotel-id: ${AGENTBUS_DEFAULT_HOTEL_ID:HOTEL-TEST} + +server: + port: ${SERVER_PORT:8080} diff --git a/server/src/main/resources/db/migration/V1__create_source_message_inbox.sql b/server/src/main/resources/db/migration/V1__create_source_message_inbox.sql new file mode 100644 index 0000000..09c663d --- /dev/null +++ b/server/src/main/resources/db/migration/V1__create_source_message_inbox.sql @@ -0,0 +1,68 @@ +-- M001 SourceMessage Inbox:来源消息索引表,保存单封邮件的幂等键、状态和安全摘要。 +CREATE TABLE platform_source_message_inbox ( + id BIGINT NOT NULL COMMENT '本项目内部 SourceMessage ID,供后续业务流程关联来源邮件', + hotel_id VARCHAR(64) NOT NULL COMMENT '酒店或业务上下文 ID,避免不同酒店外部邮件 ID 冲突', + provider VARCHAR(32) NOT NULL COMMENT '来源提供方稳定代码,例如 AGENTBUS', + channel VARCHAR(32) NOT NULL COMMENT '来源渠道稳定代码,例如 EMAIL', + external_message_id VARCHAR(256) NULL COMMENT '外部邮件系统中的单封邮件唯一 ID,对应 AgentBus source.external_message_id;解析失败时允许为空以保留失败记录', + external_conversation_id VARCHAR(256) NULL COMMENT '外部邮件链或会话 ID,对应 AgentBus source.external_conversation_id', + provider_frame_id VARCHAR(128) NULL COMMENT 'AgentBus frame ID,仅用于排查推送帧,不作为邮件唯一 ID', + provider_session_id VARCHAR(128) NULL COMMENT 'AgentBus session ID,仅用于排查连接会话,不作为邮件链 ID', + payload_sha256 CHAR(64) NOT NULL COMMENT '规范 JSON 的 SHA-256 哈希,用于排查重复投递 payload 是否变化', + capture_status VARCHAR(32) NOT NULL COMMENT '捕获状态,第一阶段使用 RECEIVED 或 FAILED', + duplicate_payload_changed TINYINT(1) NOT NULL DEFAULT 0 COMMENT '重复投递命中时 payload 是否与首次保存内容不同', + safe_error_summary VARCHAR(512) NULL COMMENT '安全错误摘要,不包含正文、HTML、附件 URL、Token 或个人敏感信息', + received_at DATETIME(6) NOT NULL COMMENT '本项目接收时间,按 UTC 写入', + source_sent_at DATETIME(6) NULL COMMENT '邮件来源发送时间,payload 提供时保存,按 UTC 写入', + sender_summary VARCHAR(256) NULL COMMENT '发送人安全摘要,列表可展示,不保存完整敏感展示值', + subject VARCHAR(512) NULL COMMENT '邮件主题,作为安全摘要字段展示', + safe_snippet VARCHAR(512) NULL COMMENT '邮件安全短摘要,不包含完整正文、HTML、附件 URL 或敏感凭证', + created_at DATETIME(6) NOT NULL COMMENT '记录创建时间,按 UTC 写入', + updated_at DATETIME(6) NOT NULL COMMENT '记录更新时间,按 UTC 写入', + PRIMARY KEY (id), + UNIQUE KEY uk_source_message_inbox_idempotency (hotel_id, provider, channel, external_message_id), + KEY idx_source_message_inbox_conversation (hotel_id, external_conversation_id), + KEY idx_source_message_inbox_status_time (hotel_id, capture_status, received_at) +) COMMENT='SourceMessage Inbox 来源消息索引表,保存邮件来源事实的幂等键、状态和安全摘要'; + +-- M001 SourceMessage Inbox:原始载荷表,保存 AgentBus 规范 JSON,用于追溯和排查。 +CREATE TABLE platform_source_message_payload ( + id BIGINT NOT NULL COMMENT '原始载荷记录 ID', + inbox_id BIGINT NOT NULL COMMENT '所属 SourceMessage Inbox 记录 ID', + payload_json LONGTEXT NOT NULL COMMENT 'AgentBus 规范 JSON 原始载荷,普通查询接口不得返回', + payload_sha256 CHAR(64) NOT NULL COMMENT 'payload_json 的 SHA-256 哈希', + schema_version VARCHAR(64) NULL COMMENT 'payload 结构版本,AgentBus 有提供则保存,否则使用本项目捕获版本', + created_at DATETIME(6) NOT NULL COMMENT '记录创建时间,按 UTC 写入', + PRIMARY KEY (id), + UNIQUE KEY uk_source_message_payload_inbox (inbox_id), + KEY idx_source_message_payload_hash (payload_sha256) +) COMMENT='SourceMessage 原始载荷表,用于追溯、排查和后续受控重新处理'; + +-- M001 SourceMessage Inbox:正文表,正文只通过后续受控原文读取接口返回。 +CREATE TABLE platform_source_message_body ( + id BIGINT NOT NULL COMMENT '正文记录 ID', + inbox_id BIGINT NOT NULL COMMENT '所属 SourceMessage Inbox 记录 ID', + content_type VARCHAR(32) NOT NULL COMMENT '正文类型,例如 TEXT、HTML、MIXED', + text_body LONGTEXT NULL COMMENT '纯文本正文,用于摘要、搜索和后续 AI 处理', + html_body LONGTEXT NULL COMMENT 'HTML 原文,前端展示前必须 sanitize', + body_sha256 CHAR(64) NOT NULL COMMENT '正文内容 SHA-256 哈希,用于排查正文是否变化', + created_at DATETIME(6) NOT NULL COMMENT '记录创建时间,按 UTC 写入', + PRIMARY KEY (id), + UNIQUE KEY uk_source_message_body_inbox (inbox_id) +) COMMENT='SourceMessage 正文表,保存 text/html 正文,普通查询接口不得返回'; + +-- M001 SourceMessage Inbox:媒体引用表,只保存 AgentBus 已处理好的长期有效 URL,不下载文件。 +CREATE TABLE platform_source_message_media ( + id BIGINT NOT NULL COMMENT '媒体引用记录 ID', + inbox_id BIGINT NOT NULL COMMENT '所属 SourceMessage Inbox 记录 ID', + media_type VARCHAR(32) NOT NULL COMMENT '媒体类型,INLINE_IMAGE 表示正文图片,ATTACHMENT 表示附件', + file_name VARCHAR(512) NULL COMMENT '文件名,附件通常有,正文图片可能为空', + content_type VARCHAR(128) NULL COMMENT '媒体 MIME 类型,例如 image/png 或 application/pdf', + size_bytes BIGINT NULL COMMENT '文件大小字节数,AgentBus 有返回则保存,没有则为空', + external_url VARCHAR(2048) NOT NULL COMMENT 'AgentBus 返回的长期有效访问 URL,只能通过受控原文读取接口返回', + external_media_id VARCHAR(256) NULL COMMENT 'AgentBus 或邮件系统中的媒体 ID,用于排查和追溯', + created_at DATETIME(6) NOT NULL COMMENT '记录创建时间,按 UTC 写入', + PRIMARY KEY (id), + KEY idx_source_message_media_inbox (inbox_id), + KEY idx_source_message_media_type (inbox_id, media_type) +) COMMENT='SourceMessage 媒体引用表,保存正文图片和附件 URL 引用,不保存二进制文件'; diff --git a/server/src/main/resources/db/migration/V2__create_source_message_original_access_audit.sql b/server/src/main/resources/db/migration/V2__create_source_message_original_access_audit.sql new file mode 100644 index 0000000..8d0137c --- /dev/null +++ b/server/src/main/resources/db/migration/V2__create_source_message_original_access_audit.sql @@ -0,0 +1,13 @@ +-- M001 checkpoint 2:原文读取访问审计,记录谁在什么场景读取了 SourceMessage 原文。 +CREATE TABLE platform_source_message_original_access_audit ( + id BIGINT NOT NULL COMMENT '原文读取审计记录 ID', + inbox_id BIGINT NOT NULL COMMENT '被读取的 SourceMessage Inbox ID', + actor_id VARCHAR(128) NOT NULL COMMENT '读取原文的调用方或操作者标识,初期由受控请求头传入', + access_scene VARCHAR(128) NOT NULL COMMENT '读取原文的业务或运维场景,例如 reservation-detail 或 agentbus-debug', + access_result VARCHAR(32) NOT NULL COMMENT '访问结果稳定代码,当前使用 GRANTED 表示已授权读取', + accessed_at DATETIME(6) NOT NULL COMMENT '原文读取发生时间,按 UTC 写入', + created_at DATETIME(6) NOT NULL COMMENT '审计记录创建时间,按 UTC 写入', + PRIMARY KEY (id), + KEY idx_source_message_original_access_inbox (inbox_id, accessed_at), + KEY idx_source_message_original_access_actor (actor_id, accessed_at) +) COMMENT='SourceMessage 原文读取访问审计表,记录受控原文读取行为'; diff --git a/server/src/test/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessorTest.java b/server/src/test/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessorTest.java new file mode 100644 index 0000000..6437554 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessorTest.java @@ -0,0 +1,125 @@ +package cn.nianxx.thhotel.integrations.messaging.agentbus.adapter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult; +import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class AgentBusFrameProcessorTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void shouldIgnoreSessionReadyAndMarkStatusWithoutCapturing() { + SourceMessageCaptureService captureService = mock(SourceMessageCaptureService.class); + AgentBusConnectionStatus status = new AgentBusConnectionStatus(); + AgentBusFrameProcessor processor = processor(captureService, status, properties(true, 1024)); + + AgentBusFrameProcessResult result = processor.process(""" + {"type":"session.ready","session_id":"session-agentbus-001"} + """); + + assertThat(result.outcome()).isEqualTo("IGNORED"); + assertThat(status.snapshot().sessionReady()).isTrue(); + assertThat(status.snapshot().ignoredFrameCount()).isEqualTo(1L); + verifyNoInteractions(captureService); + } + + @Test + void shouldCaptureBusinessFrameIntoSourceMessageInbox() { + SourceMessageCaptureService captureService = mock(SourceMessageCaptureService.class); + when(captureService.capture(any(CaptureSourceMessageCommand.class))) + .thenReturn(new SourceMessageCaptureResult(88001L, true, false, "RECEIVED")); + AgentBusConnectionStatus status = new AgentBusConnectionStatus(); + AgentBusFrameProcessor processor = processor(captureService, status, properties(true, 4096)); + + AgentBusFrameProcessResult result = processor.process(""" + { + "id": "frame-agentbus-capture-001", + "session_id": "session-agentbus-capture", + "payload": { + "body": {"text": "Please update my reservation.", "html": "Reservation"}, + "source": { + "channel": "email", + "external_message_id": "mail-agentbus-capture-001", + "external_conversation_id": "conversation-agentbus-capture", + "sender": "guest@example.test", + "subject": "Reservation update" + } + } + } + """); + + assertThat(result.outcome()).isEqualTo("CAPTURED"); + assertThat(result.inboxId()).isEqualTo(88001L); + assertThat(status.snapshot().capturedFrameCount()).isEqualTo(1L); + ArgumentCaptor captor = ArgumentCaptor.forClass(CaptureSourceMessageCommand.class); + verify(captureService).capture(captor.capture()); + assertThat(captor.getValue().hotelId()).isEqualTo("HOTEL-TEST"); + assertThat(captor.getValue().externalMessageId()).isEqualTo("mail-agentbus-capture-001"); + assertThat(captor.getValue().providerFrameId()).isEqualTo("frame-agentbus-capture-001"); + } + + @Test + void shouldIgnoreBusinessFrameWhenCaptureDisabled() { + SourceMessageCaptureService captureService = mock(SourceMessageCaptureService.class); + AgentBusConnectionStatus status = new AgentBusConnectionStatus(); + AgentBusFrameProcessor processor = processor(captureService, status, properties(false, 4096)); + + AgentBusFrameProcessResult result = processor.process(""" + { + "payload": { + "body": {"text": "Ignored body"}, + "source": {"external_message_id": "mail-agentbus-disabled-001"} + } + } + """); + + assertThat(result.outcome()).isEqualTo("IGNORED"); + assertThat(status.snapshot().ignoredFrameCount()).isEqualTo(1L); + verifyNoInteractions(captureService); + } + + @Test + void shouldRejectOversizedFrameWithoutCapturing() { + SourceMessageCaptureService captureService = mock(SourceMessageCaptureService.class); + AgentBusConnectionStatus status = new AgentBusConnectionStatus(); + AgentBusFrameProcessor processor = processor(captureService, status, properties(true, 10)); + + AgentBusFrameProcessResult result = processor.process("{\"type\":\"business\",\"payload\":{\"text\":\"too-large\"}}"); + + assertThat(result.outcome()).isEqualTo("REJECTED"); + assertThat(status.snapshot().rejectedFrameCount()).isEqualTo(1L); + assertThat(status.snapshot().lastErrorCode()).isEqualTo("FRAME_TOO_LARGE"); + verifyNoInteractions(captureService); + } + + private AgentBusFrameProcessor processor( + SourceMessageCaptureService captureService, + AgentBusConnectionStatus status, + AgentBusProperties properties) { + return new AgentBusFrameProcessor( + objectMapper, + new AgentBusSourceMessageAdapter(objectMapper), + captureService, + status, + properties); + } + + private AgentBusProperties properties(boolean captureEnabled, int maxFrameBytes) { + AgentBusProperties properties = new AgentBusProperties(); + properties.getCapture().setEnabled(captureEnabled); + properties.getCapture().setDefaultHotelId("HOTEL-TEST"); + properties.setMaxFrameBytes(maxFrameBytes); + return properties; + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusSourceMessageAdapterTest.java b/server/src/test/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusSourceMessageAdapterTest.java new file mode 100644 index 0000000..4074b90 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusSourceMessageAdapterTest.java @@ -0,0 +1,81 @@ +package cn.nianxx.thhotel.integrations.messaging.agentbus.adapter; + +import static org.assertj.core.api.Assertions.assertThat; + +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class AgentBusSourceMessageAdapterTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final AgentBusSourceMessageAdapter adapter = new AgentBusSourceMessageAdapter(objectMapper); + + @Test + void shouldMapOutlookFrameToStableCaptureCommand() throws Exception { + JsonNode frame = objectMapper.readTree(""" + { + "id": "frame-agentbus-001", + "session_id": "session-agentbus-001", + "payload": { + "text": "Fallback body text", + "body": { + "content_type": "html", + "html": "Please change booking", + "text": "Please change booking" + }, + "inline_images": [ + { + "id": "inline-001", + "file_name": "inline.png", + "content_type": "image/png", + "size_bytes": 1200, + "url": "https://media.example.test/inline.png" + } + ], + "attachments": [ + { + "id": "attachment-001", + "name": "booking.pdf", + "content_type": "application/pdf", + "size": 3400, + "external_url": "https://media.example.test/booking.pdf" + } + ], + "source": { + "channel": "email", + "external_message_id": "outlook-message-001", + "external_conversation_id": "outlook-conversation-001", + "sender": "guest@example.test", + "subject": "Booking change", + "sent_at": "2026-07-06T08:00:00Z" + } + } + } + """); + + CaptureSourceMessageCommand command = adapter.toCaptureCommand("HOTEL-TEST", frame); + + assertThat(command.hotelId()).isEqualTo("HOTEL-TEST"); + assertThat(command.provider()).isEqualTo("AGENTBUS"); + assertThat(command.channel()).isEqualTo("EMAIL"); + assertThat(command.externalMessageId()).isEqualTo("outlook-message-001"); + assertThat(command.externalConversationId()).isEqualTo("outlook-conversation-001"); + assertThat(command.providerFrameId()).isEqualTo("frame-agentbus-001"); + assertThat(command.providerSessionId()).isEqualTo("session-agentbus-001"); + assertThat(command.sourceSentAt()).isEqualTo(Instant.parse("2026-07-06T08:00:00Z")); + assertThat(command.senderIdentifier()).isEqualTo("guest@example.test"); + assertThat(command.subject()).isEqualTo("Booking change"); + assertThat(command.textBody()).isEqualTo("Please change booking"); + assertThat(command.htmlBody()).contains("Please change booking"); + assertThat(command.payloadJson()).contains("outlook-message-001"); + assertThat(command.schemaVersion()).isEqualTo("agentbus-outlook-v1"); + assertThat(command.mediaItems()).hasSize(2); + assertThat(command.mediaItems().get(0).mediaType()).isEqualTo("INLINE_IMAGE"); + assertThat(command.mediaItems().get(0).externalUrl()).isEqualTo("https://media.example.test/inline.png"); + assertThat(command.mediaItems().get(1).mediaType()).isEqualTo("ATTACHMENT"); + assertThat(command.mediaItems().get(1).fileName()).isEqualTo("booking.pdf"); + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerTest.java new file mode 100644 index 0000000..1110118 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerTest.java @@ -0,0 +1,158 @@ +package cn.nianxx.thhotel.platform.message.control; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import cn.nianxx.thhotel.ThHotelApplication; +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand; +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageMedia; +import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest( + classes = ThHotelApplication.class, + properties = "source-message.original-read.access-key=test-original-read-key") +@AutoConfigureMockMvc +@ActiveProfiles("test") +class SourceMessageControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private SourceMessageCaptureService captureService; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void shouldListAndReadSummaryWithoutOriginalContentOrMediaUrls() throws Exception { + SourceMessageCaptureResult result = captureService.capture(command( + "mail-api-001", + "conversation-api-001", + "Please update guest name. Private phone 13800138000.", + "Private HTML", + "https://media.example.test/private.pdf?token=secret" + )); + + mockMvc.perform(get("/api/source-messages") + .param("hotelId", "HOTEL-TEST") + .param("externalConversationId", "conversation-api-001") + .param("pageNum", "1") + .param("pageSize", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].id").value(result.inboxId().toString())) + .andExpect(jsonPath("$.items[0].externalMessageId").value("mail-api-001")) + .andExpect(jsonPath("$.items[0].externalConversationId").value("conversation-api-001")) + .andExpect(jsonPath("$.items[0].captureStatus").value("RECEIVED")) + .andExpect(jsonPath("$.items[0].safeSnippet").value(containsString("Please update guest name"))) + .andExpect(content().string(not(containsString("13800138000")))) + .andExpect(content().string(not(containsString("Private HTML")))) + .andExpect(content().string(not(containsString("media.example.test")))) + .andExpect(content().string(not(containsString("token=secret")))) + .andExpect(content().string(not(containsString("payloadJson")))); + + mockMvc.perform(get("/api/source-messages/{id}", result.inboxId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(result.inboxId().toString())) + .andExpect(jsonPath("$.externalMessageId").value("mail-api-001")) + .andExpect(jsonPath("$.safeSnippet").value(containsString("Please update guest name"))) + .andExpect(content().string(not(containsString("Private HTML")))) + .andExpect(content().string(not(containsString("media.example.test")))) + .andExpect(content().string(not(containsString("payloadJson")))); + } + + @Test + void shouldRejectOriginalReadWithoutAccessKey() throws Exception { + SourceMessageCaptureResult result = captureService.capture(command( + "mail-original-denied-001", + "conversation-original-denied-001", + "Private original text", + "Private original HTML", + "https://media.example.test/denied.pdf?token=secret" + )); + + mockMvc.perform(get("/api/source-messages/{id}/original", result.inboxId()) + .header("X-TH-Hotel-Actor", "operator-001") + .header("X-TH-Hotel-Access-Scene", "reservation-detail")) + .andExpect(status().isForbidden()); + } + + @Test + void shouldReadOriginalContentWithAccessKeyAndRecordAudit() throws Exception { + SourceMessageCaptureResult result = captureService.capture(command( + "mail-original-001", + "conversation-original-001", + "Original text body with private context.", + "Original HTML body", + "https://media.example.test/original.pdf" + )); + + mockMvc.perform(get("/api/source-messages/{id}/original", result.inboxId()) + .header("X-TH-Hotel-Source-Original-Read-Key", "test-original-read-key") + .header("X-TH-Hotel-Actor", "operator-001") + .header("X-TH-Hotel-Access-Scene", "reservation-detail")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(result.inboxId().toString())) + .andExpect(jsonPath("$.textBody").value(containsString("Original text body"))) + .andExpect(jsonPath("$.htmlBody").value(containsString("Original HTML body"))) + .andExpect(jsonPath("$.htmlSanitizeRequired").value(true)) + .andExpect(jsonPath("$.attachments[0].fileName").value("private.pdf")) + .andExpect(jsonPath("$.attachments[0].externalUrl") + .value("https://media.example.test/original.pdf")); + + Long auditCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM platform_source_message_original_access_audit + WHERE inbox_id = ? + AND actor_id = 'operator-001' + AND access_scene = 'reservation-detail' + """, Long.class, result.inboxId()); + assert auditCount != null; + org.assertj.core.api.Assertions.assertThat(auditCount).isEqualTo(1L); + } + + private CaptureSourceMessageCommand command( + String externalMessageId, + String externalConversationId, + String textBody, + String htmlBody, + String mediaUrl) { + return new CaptureSourceMessageCommand( + "HOTEL-TEST", + "AGENTBUS", + "EMAIL", + externalMessageId, + externalConversationId, + "frame-" + externalMessageId, + "session-api", + Instant.parse("2026-07-06T10:00:00Z"), + "guest@example.test", + "Reservation update", + textBody, + htmlBody, + "{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}", + "agentbus-outlook-v1", + List.of(new CaptureSourceMessageMedia( + "ATTACHMENT", + "private.pdf", + "application/pdf", + 1000L, + mediaUrl, + "attachment-api-001")) + ); + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/message/service/SourceMessageCaptureServiceImplTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/message/service/SourceMessageCaptureServiceImplTest.java new file mode 100644 index 0000000..e46ce4d --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/platform/message/service/SourceMessageCaptureServiceImplTest.java @@ -0,0 +1,338 @@ +package cn.nianxx.thhotel.platform.message.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import cn.nianxx.thhotel.ThHotelApplication; +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxDraft; +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot; +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse; +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand; +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageMedia; +import cn.nianxx.thhotel.platform.message.common.request.SourceMessageQueryRequest; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult; +import cn.nianxx.thhotel.platform.message.domain.SourceMessageInboxEntity; +import cn.nianxx.thhotel.platform.message.domain.SourceMessagePayloadEntity; +import cn.nianxx.thhotel.platform.message.mapper.SourceMessageInboxMapper; +import cn.nianxx.thhotel.platform.message.mapper.SourceMessagePayloadMapper; +import cn.nianxx.thhotel.platform.message.repository.SourceMessageInboxRepository; +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.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +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.test.context.ActiveProfiles; + +@SpringBootTest(classes = ThHotelApplication.class) +@ActiveProfiles("test") +class SourceMessageCaptureServiceImplTest { + + @Autowired + private SourceMessageCaptureService captureService; + + @Autowired + private SourceMessageQueryService queryService; + + @Autowired + private SourceMessageInboxMapper inboxMapper; + + @Autowired + private SourceMessagePayloadMapper payloadMapper; + + @Test + void shouldCaptureReceivedEmailAndExposeOnlySafeSummaryForQueries() { + CaptureSourceMessageCommand command = new CaptureSourceMessageCommand( + "HOTEL-TEST", + "AGENTBUS", + "EMAIL", + "mail-m001-001", + "conversation-m001", + "frame-m001-001", + "session-m001", + Instant.parse("2026-07-06T09:00:00Z"), + "guest@example.test", + "Booking change request", + "Please change the arrival date. Private phone 13800138000.", + "Please change ", + "{\"source\":{\"external_message_id\":\"mail-m001-001\"}}", + "agentbus-outlook-v1", + List.of( + new CaptureSourceMessageMedia( + "INLINE_IMAGE", + "inline.png", + "image/png", + 12345L, + "https://media.example.test/inline.png?token=secret", + "inline-media-001"), + new CaptureSourceMessageMedia( + "ATTACHMENT", + "booking.pdf", + "application/pdf", + 45678L, + "https://media.example.test/booking.pdf?token=secret", + "attachment-001") + ) + ); + + SourceMessageCaptureResult result = captureService.capture(command); + + assertThat(result.created()).isTrue(); + assertThat(result.captureStatus()).isEqualTo("RECEIVED"); + assertThat(result.inboxId()).isNotNull(); + + SourceMessagePageResult page = queryService.query( + new SourceMessageQueryRequest("HOTEL-TEST", null, "conversation-m001", null, 1, 20) + ); + + assertThat(page.items()).hasSize(1); + SourceMessageSummaryResponse summary = page.items().get(0); + assertThat(summary.id()).isEqualTo(result.inboxId().toString()); + assertThat(summary.externalMessageId()).isEqualTo("mail-m001-001"); + assertThat(summary.externalConversationId()).isEqualTo("conversation-m001"); + assertThat(summary.captureStatus()).isEqualTo("RECEIVED"); + assertThat(summary.senderSummary()).isEqualTo("g***@example.test"); + assertThat(summary.subject()).isEqualTo("Booking change request"); + assertThat(summary.safeSnippet()).contains("Please change the arrival date"); + assertThat(summary.safeSnippet()).doesNotContain("13800138000"); + assertThat(summary.toString()).doesNotContain("html"); + assertThat(summary.toString()).doesNotContain("media.example.test"); + assertThat(summary.toString()).doesNotContain("payload"); + assertThat(summary.toString()).doesNotContain("token=secret"); + } + + @Test + void shouldKeepIdempotentInboxAndOriginalPayloadWhenDuplicatePayloadChanges() { + CaptureSourceMessageCommand firstCommand = new CaptureSourceMessageCommand( + "HOTEL-TEST", + "AGENTBUS", + "EMAIL", + "mail-duplicate-001", + "conversation-duplicate", + "frame-duplicate-001", + "session-duplicate", + Instant.parse("2026-07-06T09:10:00Z"), + "guest@example.test", + "Duplicate delivery", + "Original body", + "Original body", + "{\"source\":{\"external_message_id\":\"mail-duplicate-001\"},\"version\":1}", + "agentbus-outlook-v1", + List.of() + ); + CaptureSourceMessageCommand changedCommand = new CaptureSourceMessageCommand( + "HOTEL-TEST", + "AGENTBUS", + "EMAIL", + "mail-duplicate-001", + "conversation-duplicate", + "frame-duplicate-002", + "session-duplicate", + Instant.parse("2026-07-06T09:11:00Z"), + "guest@example.test", + "Duplicate delivery changed", + "Changed body", + "Changed body", + "{\"source\":{\"external_message_id\":\"mail-duplicate-001\"},\"version\":2}", + "agentbus-outlook-v1", + List.of() + ); + + SourceMessageCaptureResult firstResult = captureService.capture(firstCommand); + SourceMessageCaptureResult duplicateResult = captureService.capture(changedCommand); + + assertThat(duplicateResult.created()).isFalse(); + assertThat(duplicateResult.inboxId()).isEqualTo(firstResult.inboxId()); + assertThat(duplicateResult.duplicatePayloadChanged()).isTrue(); + + Long payloadCount = payloadMapper.selectCount(Wrappers.lambdaQuery() + .eq(SourceMessagePayloadEntity::getInboxId, firstResult.inboxId())); + assertThat(payloadCount).isEqualTo(1L); + + SourceMessagePayloadEntity payload = payloadMapper.selectOne(Wrappers.lambdaQuery() + .eq(SourceMessagePayloadEntity::getInboxId, firstResult.inboxId())); + assertThat(payload.getPayloadJson()).contains("\"version\":1"); + assertThat(payload.getPayloadJson()).doesNotContain("\"version\":2"); + + SourceMessageInboxEntity inbox = inboxMapper.selectById(firstResult.inboxId()); + assertThat(inbox.getDuplicatePayloadChanged()).isTrue(); + assertThat(inbox.getSafeErrorSummary()).contains("重复投递 payload"); + } + + @Test + void shouldPersistFailedInboxWhenExternalMessageIdIsMissing() { + CaptureSourceMessageCommand command = new CaptureSourceMessageCommand( + "HOTEL-TEST", + "AGENTBUS", + "EMAIL", + null, + "conversation-invalid", + "frame-invalid-001", + "session-invalid", + Instant.parse("2026-07-06T09:30:00Z"), + "guest@example.test", + "Invalid source payload", + "This text contains private phone 13800138000 and must not appear in the error summary.", + "private html", + "{\"source\":{\"external_conversation_id\":\"conversation-invalid\"},\"token\":\"secret\"}", + "agentbus-outlook-v1", + List.of() + ); + + SourceMessageCaptureResult result = captureService.capture(command); + + assertThat(result.created()).isTrue(); + assertThat(result.captureStatus()).isEqualTo("FAILED"); + + SourceMessagePageResult page = queryService.query( + new SourceMessageQueryRequest("HOTEL-TEST", null, "conversation-invalid", "FAILED", 1, 20) + ); + + assertThat(page.items()).hasSize(1); + SourceMessageSummaryResponse summary = page.items().get(0); + assertThat(summary.id()).isEqualTo(result.inboxId().toString()); + assertThat(summary.captureStatus()).isEqualTo("FAILED"); + assertThat(summary.externalMessageId()).isNull(); + assertThat(summary.safeSnippet()).contains("payload missing external message id"); + assertThat(summary.toString()).doesNotContain("13800138000"); + assertThat(summary.toString()).doesNotContain("private html"); + assertThat(summary.toString()).doesNotContain("token"); + assertThat(summary.toString()).doesNotContain("secret"); + } + + @Test + void shouldSanitizeSensitiveSubjectForSafeQueries() { + CaptureSourceMessageCommand command = new CaptureSourceMessageCommand( + "HOTEL-TEST", + "AGENTBUS", + "EMAIL", + "mail-sensitive-subject-001", + "conversation-sensitive-subject", + "frame-sensitive-subject-001", + "session-sensitive-subject", + Instant.parse("2026-07-06T09:40:00Z"), + "guest@example.test", + "Urgent: guest@example.test phone 13800138000 https://media.example.test/file.pdf?token=secret", + "Please check the booking.", + "Please check the booking.", + "{\"source\":{\"external_message_id\":\"mail-sensitive-subject-001\"}}", + "agentbus-outlook-v1", + List.of() + ); + + SourceMessageCaptureResult result = captureService.capture(command); + + SourceMessageSummaryResponse summary = queryService.getSummary(result.inboxId()).orElseThrow(); + assertThat(summary.subject()).doesNotContain("guest@example.test"); + assertThat(summary.subject()).doesNotContain("13800138000"); + assertThat(summary.subject()).doesNotContain("media.example.test"); + assertThat(summary.subject()).doesNotContain("token=secret"); + assertThat(summary.subject()).contains("[email]"); + assertThat(summary.subject()).contains("[number]"); + assertThat(summary.subject()).contains("[url]"); + } + + @Test + void shouldPersistFailedInboxWhenMediaUrlIsMissing() { + CaptureSourceMessageCommand command = new CaptureSourceMessageCommand( + "HOTEL-TEST", + "AGENTBUS", + "EMAIL", + "mail-invalid-media-001", + "conversation-invalid-media", + "frame-invalid-media-001", + "session-invalid-media", + Instant.parse("2026-07-06T09:45:00Z"), + "guest@example.test", + "Invalid media source payload", + "This text must not appear as an exception.", + "private html", + "{\"source\":{\"external_message_id\":\"mail-invalid-media-001\"},\"token\":\"secret\"}", + "agentbus-outlook-v1", + List.of(new CaptureSourceMessageMedia( + "ATTACHMENT", + "private.pdf", + "application/pdf", + 123L, + null, + "attachment-invalid-media-001")) + ); + + SourceMessageCaptureResult result = captureService.capture(command); + + assertThat(result.created()).isTrue(); + assertThat(result.captureStatus()).isEqualTo("FAILED"); + + SourceMessageSummaryResponse summary = queryService.getSummary(result.inboxId()).orElseThrow(); + assertThat(summary.captureStatus()).isEqualTo("FAILED"); + assertThat(summary.externalMessageId()).isEqualTo("mail-invalid-media-001"); + assertThat(summary.safeSnippet()).contains("payload media item missing external url"); + assertThat(summary.toString()).doesNotContain("private html"); + assertThat(summary.toString()).doesNotContain("token"); + assertThat(summary.toString()).doesNotContain("secret"); + } + + @Test + void shouldReturnExistingInboxWhenConcurrentInsertHitsUniqueConstraint() { + SourceMessageInboxRepository localRepository = mock(SourceMessageInboxRepository.class); + SourceMessageCaptureService localService = new SourceMessageCaptureServiceImpl( + localRepository, + new SourceMessageSafetySanitizer() + ); + SourceMessageInboxSnapshot existing = new SourceMessageInboxSnapshot( + 99001L, + "HOTEL-TEST", + "AGENTBUS", + "EMAIL", + "mail-race-001", + "conversation-race", + "different-payload-hash", + "RECEIVED", + false, + LocalDateTime.parse("2026-07-06T09:50:00"), + LocalDateTime.parse("2026-07-06T09:50:00"), + "g***@example.test", + "Race delivery", + "Race body" + ); + when(localRepository.findByIdempotencyKey("HOTEL-TEST", "AGENTBUS", "EMAIL", "mail-race-001")) + .thenReturn(Optional.empty()) + .thenReturn(Optional.of(existing)); + when(localRepository.insert(any(SourceMessageInboxDraft.class))) + .thenThrow(new DuplicateKeyException("duplicate source message")); + CaptureSourceMessageCommand command = new CaptureSourceMessageCommand( + "HOTEL-TEST", + "AGENTBUS", + "EMAIL", + "mail-race-001", + "conversation-race", + "frame-race-001", + "session-race", + Instant.parse("2026-07-06T09:50:00Z"), + "guest@example.test", + "Race delivery", + "Race body", + "Race body", + "{\"source\":{\"external_message_id\":\"mail-race-001\"},\"version\":2}", + "agentbus-outlook-v1", + List.of() + ); + + SourceMessageCaptureResult result = localService.capture(command); + + assertThat(result.created()).isFalse(); + assertThat(result.inboxId()).isEqualTo(99001L); + assertThat(result.duplicatePayloadChanged()).isTrue(); + verify(localRepository).markDuplicatePayloadChanged(eq(99001L), any(String.class), any(LocalDateTime.class)); + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/system/control/AgentBusProbeStatusControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/system/control/AgentBusProbeStatusControllerTest.java new file mode 100644 index 0000000..cf57c43 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/platform/system/control/AgentBusProbeStatusControllerTest.java @@ -0,0 +1,37 @@ +package cn.nianxx.thhotel.platform.system.control; + +import static org.hamcrest.Matchers.not; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import cn.nianxx.thhotel.ThHotelApplication; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest(classes = ThHotelApplication.class) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AgentBusProbeStatusControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void shouldExposeAgentBusStatusWithoutSecretsOrRawFrames() throws Exception { + mockMvc.perform(get("/api/system/agentbus-probe")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.enabled").value(false)) + .andExpect(jsonPath("$.connected").value(false)) + .andExpect(jsonPath("$.sessionReady").value(false)) + .andExpect(jsonPath("$.receivedFrameCount").value(0)) + .andExpect(content().string(not(org.hamcrest.Matchers.containsString("AGENTBUS_WS_TOKEN")))) + .andExpect(content().string(not(org.hamcrest.Matchers.containsString("Authorization")))) + .andExpect(content().string(not(org.hamcrest.Matchers.containsString("payload")))); + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/system/control/HealthControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/system/control/HealthControllerTest.java new file mode 100644 index 0000000..c9332f7 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/platform/system/control/HealthControllerTest.java @@ -0,0 +1,30 @@ +package cn.nianxx.thhotel.platform.system.control; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import cn.nianxx.thhotel.ThHotelApplication; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest(classes = ThHotelApplication.class) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class HealthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void shouldReturnBackendHealthStatus() throws Exception { + mockMvc.perform(get("/api/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("UP")) + .andExpect(jsonPath("$.service").value("th-hotel-server")); + } +} diff --git a/server/src/test/resources/application-test.yml b/server/src/test/resources/application-test.yml new file mode 100644 index 0000000..723c483 --- /dev/null +++ b/server/src/test/resources/application-test.yml @@ -0,0 +1,29 @@ +spring: + datasource: + # 默认测试环境使用 H2 MySQL Mode,保证普通 ./mvnw test 不依赖本机 MySQL。 + url: "jdbc:h2:mem:th_hotel_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE" + username: sa + password: + driver-class-name: org.h2.Driver + flyway: + enabled: true + +springdoc: + api-docs: + enabled: false + swagger-ui: + enabled: false + +--- +spring: + config: + activate: + on-profile: test-mysql + datasource: + # 需要真实 MySQL 集成测试时启用 test,test-mysql profile,并用环境变量覆盖连接信息。 + url: "${TH_HOTEL_TEST_DB_URL:jdbc:mysql://127.0.0.1:3306/th_hotel_test?useUnicode=true&characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC}" + username: ${TH_HOTEL_TEST_DB_USERNAME:th_hotel_test} + password: ${TH_HOTEL_TEST_DB_PASSWORD:} + driver-class-name: com.mysql.cj.jdbc.Driver + flyway: + enabled: true diff --git a/server/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/server/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..fdbd0b1 --- /dev/null +++ b/server/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-subclass