diff --git a/docs/project/README.md b/docs/project/README.md index 38ebddf..5d724db 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -20,6 +20,7 @@ | `../../README.md` | 当前有效 | 项目根说明,记录目录、启动命令、健康检查、Debug EML 和 MCP 基础说明。 | | `backend-development-guidelines.md` | 当前有效 | 当前项目后端专属规范。 | | `backend-time-design.md` | 当前有效 | 当前项目时间设计说明,记录数据库 UTC、API `Z` 时间、酒店时区展示和本地日期边界。 | +| `security-access-control-boundary.md` | 当前有效 | 当前项目接口暴露、权限码、酒店隔离和审计边界总表;新增或修改接口时必须同步。 | | `frontend-development-guidelines.md` | 当前有效 | 当前项目前端专属规范。 | | `frontend-backend/README.md` | 当前有效 | 前后端协作入口,记录接口契约来源、字段白名单和当前后置事项。 | | `go-live-notes.md` | 当前有效 | 当前项目上线注意事项,记录上线前检查、环境变量、安全、AgentBus、验证和回滚。 | @@ -42,6 +43,7 @@ | `requirements/M005-hotel-context-unification-plan.md` | 当前有效 | M005 酒店上下文统一收口方案。 | | `requirements/M006-system-admin-management-console-v1.md` | 草案 | M006 系统管理后台方案,覆盖用户、角色、权限、菜单、酒店和用户酒店授权维护。 | | `requirements/M007-agentbus-superagent-auto-dispatch-v1.md` | 当前有效 | M007 AgentBus 新邮件入库后异步分发 SuperAgent 的后端 V1 方案,当前默认关闭,等待测试机联调。 | +| `requirements/M008-excel-to-pdf-conversion-v1.md` | 当前有效 | M008 Excel 转 PDF 文件转换能力方案,记录 LibreOffice headless、手动上传转换、邮件附件自动派生 PDF 和部署要求;CP2 已实现手动上传后端接口。 | ## 集成契约 @@ -70,6 +72,7 @@ - SuperAgent 对外 HTTP 接口以 `integrations/superagent-api-contract.md` 为权威来源。 - SuperAgent MCP 文档以 `integrations/superagent-mcp/` 为对外交付资料包,但字段语义应跟随 HTTP 总契约。 +- 接口暴露、权限、酒店隔离和审计边界以 `security-access-control-boundary.md` 为总检查清单;具体 SuperAgent / MCP / AgentBus 请求响应契约仍以 `integrations/` 下对应文档为准。 - M002 V1 只作为历史参考;V2 记录当前阶段实现;后续 M002 新开发以 `requirements/M002-order-task-workflow-v3.md` 为开发基线。 - 前端展示 / 编辑字段以 2026-07-11 P0 冻结基线中的前端字段表、0712 字段控件说明和 `requirements/M002-task-field-control-contract-v1.md` 为白名单和控件契约基线;后端完整校验和 OPERA 映射仍以任务卡完整矩阵、0711 runtime 契约和后端规则为准。 - 时间点语义以 `backend-time-design.md` 为准;数据库时间点按 UTC 理解,API 返回带 `Z` 的 UTC 时间,页面再按酒店或用户时区展示。 diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index e972f63..ad647ec 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -65,8 +65,8 @@ | `GET /api/reservation/tasks/{taskId}/audits` | 查询任务审计流水 | 必须带 Bearer token,需要 `RESERVATION_AUDIT_READ`,后端按任务所属酒店做访问校验;用于展示人工确认、转换、模拟操作等轨迹。 | | `GET /api/source-messages` | 查询来源消息安全摘要 | 必须带 Bearer token,需要 `SOURCE_MESSAGE_READ`;列表不返回邮件正文、HTML、附件 URL 或原始 payload;查询参数以 `hotel_id`、`external_message_id`、`external_conversation_id`、`page_num`、`page_size` 为准,后端暂兼容早期 camelCase 参数。 | | `GET /api/source-messages/{id}` | 查询来源消息安全详情 | 必须带 Bearer token,需要 `SOURCE_MESSAGE_READ`,后端按消息所属酒店做访问校验;只用于安全摘要详情。 | -| `GET /api/source-messages/{id}/original` | 读取来源消息原文 | 需要受控访问头,返回 HTML 时前端展示前必须 sanitize。 | -| `GET /api/source-messages/{sourceMessageId}/conversation` | 读取邮件会话详情 | 返回同一外部会话全部邮件的完整 text/html、`html_body_sanitized`、附件外链、内联图片和关联订单 / 任务摘要;前端不传原文读取 key,展示 HTML 时优先使用 `html_body_sanitized`。 | +| `GET /api/source-messages/{id}/original` | 读取来源消息原文 | 必须带 Bearer token,需要同时拥有 `SOURCE_MESSAGE_READ` 和 `SOURCE_MESSAGE_ORIGINAL_READ`;后端按消息所属酒店做访问校验;返回 HTML 时前端展示前必须 sanitize。 | +| `GET /api/source-messages/{sourceMessageId}/conversation` | 读取邮件会话详情 | 必须带 Bearer token,需要同时拥有 `SOURCE_MESSAGE_READ` 和 `SOURCE_MESSAGE_ORIGINAL_READ`;返回同一外部会话全部邮件的完整 text/html、`html_body_sanitized`、附件外链、内联图片和关联订单 / 任务摘要;前端不传原文读取 key,展示 HTML 时优先使用 `html_body_sanitized`。 | | `POST /api/system/debug/eml-superagent-runs` | Debug 页面上传 `.eml` 并调用 SuperAgent | 仅 dev/test 受控调试使用;会写入 SourceMessage Inbox,但不创建订单和任务。 | | `GET/POST/PUT /api/admin/users...` | 系统管理用户维护 | 需要 Bearer token 和 `SYSTEM_USER_MANAGE`;用户 ID 返回字符串;禁用用户会撤销其 ACTIVE session。 | | `GET/POST/PUT /api/admin/roles...` | 系统管理角色权限维护 | 需要 `SYSTEM_ROLE_MANAGE`;内置角色只读,自定义角色可新增、编辑和分配权限。 | @@ -107,7 +107,7 @@ POST /api/auth/logout - 当前后端已强制拦截第一批 Reservation / SourceMessage 只读接口:任务列表、订单列表、订单详情、任务详情、任务审计列表、SourceMessage 摘要列表和摘要详情。调用这些接口必须带 Bearer token。 - 第一批只读接口权限码分别是:`RESERVATION_TASK_READ`、`RESERVATION_ORDER_READ`、`RESERVATION_AUDIT_READ`、`SOURCE_MESSAGE_READ`。前端菜单、按钮和路由守卫应使用 `/api/auth/me` 返回的 `permissions[]` 与 `menus[]`。 - 后端会按当前登录用户的可访问酒店集合做隔离;显式传 `hotel_id` 时会校验该酒店是否可访问,按 `orderId`、`taskId`、`sourceMessageId` 定位的详情接口会反查对象实际所属酒店并校验访问权。 -- Reservation 写操作、邮件原文 / conversation 完整正文、Debug / Demo / Replay / Probe 等接口仍按 `../security-access-control-boundary.md` 的分阶段计划继续收口,前端不要自行假设它们和第一批只读接口完全一致。 +- 邮件原文 / conversation 完整正文接口已完成权限收口,必须带 Bearer token 且同时需要 `SOURCE_MESSAGE_READ` 和 `SOURCE_MESSAGE_ORIGINAL_READ`;Reservation 写操作、Debug / Demo / Replay / Probe 等接口仍按 `../security-access-control-boundary.md` 的分阶段计划继续收口。 - `/api/auth/me` 返回 `user`、`default_hotel_id`、`hotels[]`、`permissions[]`、`menus[]`;菜单入口应优先使用 `menus[]`,不要继续硬编码订单列表、任务队列、Debug EML。 - `menus[]` 只包含可见菜单;订单详情、任务详情和邮件会话详情是隐藏详情路由,不会作为菜单项返回。 - `DEBUG_EML_SUPERAGENT` 菜单第一版只授予 `SYSTEM_ADMIN`;这只表示页面入口是否可见,不代表后端会把 `X-TH-Hotel-Debug-Upload-Key` 下发给前端。 @@ -131,14 +131,14 @@ POST /api/auth/logout ### 5.4 邮件会话详情接入注意 -- `GET /api/source-messages/{sourceMessageId}/conversation` 只接收路径参数 `sourceMessageId`;第一版不接收 `hotelId`、`includeBody`、`includeRelated`。 +- `GET /api/source-messages/{sourceMessageId}/conversation` 只接收路径参数 `sourceMessageId`;第一版不接收 `hotelId`、`includeBody`、`includeRelated`;请求必须带 `Authorization: Bearer `,且当前用户需要同时拥有 `SOURCE_MESSAGE_READ` 和 `SOURCE_MESSAGE_ORIGINAL_READ`。 - Reservation 列表、任务列表和订单详情默认不需要前端传 `hotel_id`;如果前端已经接入酒店选择器,可以把当前选中酒店作为可选 `hotel_id` 传给后端。任务详情、任务写操作和邮件会话详情当前仍按对象 ID 定位,不接收该参数。 - 后端会根据 `sourceMessageId` 定位 `external_conversation_id`,并返回同一会话下全部邮件;如果来源消息没有外部会话 ID,会降级返回当前单封邮件。 - `messages[]` 按邮件来源接收时间正序返回,前端不要重新按创建时间或任务时间排序。 - 返回内容包含完整 `text_body`、`html_body`、`inline_images[]`、`attachments[]`、`related_orders[]`、`related_tasks[]`。 - `html_body` 是原始 HTML 兼容字段;`html_body_sanitized` 是后端第一版清洗结果,已移除脚本标签、事件属性和危险协议链接。前端生产展示必须优先使用 `html_body_sanitized`,并可用 `html_render_mode=SANITIZED_HTML` 判断渲染模式。 - 第一版仅处理 HTML 内容安全;`inline_images[]` 和 `attachments[]` 的 `externalUrl` 来自本系统 OSS 服务,暂不做额外拦截,但前端仍不得写入普通日志、错误上报、localStorage 或 URL query。 -- 会话详情接口由后端内部写原文读取审计,前端不传 `X-TH-Hotel-Source-Original-Read-Key`。 +- 会话详情接口由后端内部写原文读取审计,actor 使用当前登录用户稳定 ID;前端不传 `X-TH-Hotel-Source-Original-Read-Key`、`X-TH-Hotel-Actor` 或 `X-TH-Hotel-Access-Scene`。 - 会话详情外层字段主要是 snake_case,但媒体对象沿用原文读取接口字段,当前是 `mediaType`、`fileName`、`contentType`、`sizeBytes`、`externalUrl`、`externalMediaId` 这种 camelCase,前端类型定义需要单独处理。 ### 5.5 订单列表接入注意 @@ -336,6 +336,47 @@ run_label: 可选调试标签 - 单酒店阶段只允许一家 `ACTIVE` 酒店,后端会拒绝启用第二家 `ACTIVE`,也会拒绝禁用最后一家 `ACTIVE`。 - 系统管理写操作会写入 `platform_admin_audit_log`;审计接口 `GET /api/admin/audits` 可按 `target_type`、`target_id`、`action` 查询。 +### 5.10 Excel 转 PDF 手动上传接口接入注意 + +后端已提供 M008 CP2 Excel 转 PDF 手动上传接口: + +```text +POST /api/system/document-conversions/excel-to-pdf +Header: X-TH-Hotel-Document-Conversion-Key: <文件转换访问口令> +Content-Type: multipart/form-data + +file: .xls / .xlsx 文件 +hotel_id: 可选;用于 OSS 对象路径分组 +``` + +成功响应: + +```json +{ + "conversion_status": "SUCCEEDED", + "source_file_name": "booking-request.xlsx", + "source_size_bytes": 12345, + "pdf_file_name": "booking-request.pdf", + "pdf_url": "https://oss.example.test/document-conversions/excel-to-pdf/HOTEL-TEST/2026-07-16/.../booking-request.pdf", + "object_key": "document-conversions/excel-to-pdf/HOTEL-TEST/2026-07-16/.../booking-request.pdf", + "content_type": "application/pdf", + "pdf_size_bytes": 67890, + "duration_millis": 1200 +} +``` + +前端注意: + +- 该接口当前属于受控调试 / 后台工具能力,不是普通公开上传接口。 +- `X-TH-Hotel-Document-Conversion-Key` 不能写入 `VITE_*`、源码、构建产物、URL query、localStorage、错误上报或普通日志。 +- 只允许上传 `.xls` / `.xlsx`;`.xlsm` 第一版不支持。后端会做扩展名和文件头轻量校验,改后缀的非 Excel 文件会返回 `DOCUMENT_CONVERSION_FILE_CONTENT_INVALID`。 +- 后端默认大小限制是 20 MB,测试机可以通过环境变量调整。 +- `pdf_url` 来自本系统 OSS,可用于预览或下载,但不要写入普通日志、埋点、错误上报或 URL query。 +- `DOCUMENT_CONVERSION_DISABLED` 表示后端未开启文件转换能力,页面应提示联系管理员或切换到已开启环境。 +- `DOCUMENT_CONVERSION_BUSY` 表示后端 LibreOffice 并发已满,页面可以提示稍后重试。 +- `DOCUMENT_CONVERSION_TIMEOUT` / `DOCUMENT_CONVERSION_FAILED` 通常需要后端排查 LibreOffice、字体、文件格式或临时目录权限。 +- CP2 不会创建转换任务记录,也不会自动处理邮件附件;邮件附件自动派生 PDF 是 M008 后续 checkpoint。 + ## 6. 不给前端直接调用的接口 - `POST /api/system/reservation/demo-data` 只用于 dev/test 联调造数,不是生产业务页面接口;访问口令不能进入前端代码。 diff --git a/docs/project/go-live-notes.md b/docs/project/go-live-notes.md index 9105bf7..c712df1 100644 --- a/docs/project/go-live-notes.md +++ b/docs/project/go-live-notes.md @@ -18,6 +18,7 @@ - Reservation OPERA 模拟骨架:已确认任务固定生成两条模拟操作,支持执行、失败重试、attempt 记录和任务审计列表。 - SuperAgent 查询上下文接口 1、2:支持 HMAC 鉴权的订单上下文查询和对象详情查询。 - Debug EML 上传到 SuperAgent 调试链路:受控上传 `.eml`、转存阿里云 OSS、写入 SourceMessage Inbox、调用 SuperAgent Open API 并返回调试结果。 +- Excel 转 PDF 手动上传接口:受控上传 `.xls` / `.xlsx`,通过 LibreOffice headless 转 PDF 后上传阿里云 OSS 并返回 PDF URL。 - 登录权限底座:支持用户名密码登录、登出、当前用户上下文、数据库 session token、可访问酒店、权限码和可见菜单。 - 系统管理后台 V1:支持用户、角色权限、菜单、酒店和管理操作审计的受控维护接口与前端页面。 @@ -34,6 +35,7 @@ - 现有业务接口强制登录和强制权限拦截。 - 业务审计 actor 全量迁移到当前登录用户。 - Debug EML 上传链路不属于生产普通业务页面能力,生产默认关闭;即使已有登录权限,也不要开放给普通用户。 +- Excel 转 PDF 当前只完成手动上传后端接口;邮件附件自动转换、持久化转换任务和 worker 尚未实现。生产默认关闭,启用前必须确认 LibreOffice、字体、OSS、临时目录和访问口令。 ## 2. 上线前必须确认 @@ -91,17 +93,13 @@ ### 3.3 SourceMessage -| 变量 | 是否 Secret | 上线注意事项 | -| --- | --- | --- | -| `SOURCE_MESSAGE_DEV_ORIGINAL_READ_ACCESS_KEY` | 是 | dev 原文读取临时访问 key。未配置时可兜底使用旧通用变量。 | -| `SOURCE_MESSAGE_TEST_ORIGINAL_READ_ACCESS_KEY` | 是 | test 原文读取临时访问 key。未配置时可兜底使用旧通用变量。 | -| `SOURCE_MESSAGE_PROD_ORIGINAL_READ_ACCESS_KEY` | 是 | prod 原文读取临时访问 key。未配置时原文读取默认关闭。 | -| `SOURCE_MESSAGE_ORIGINAL_READ_ACCESS_KEY` | 是 | 旧通用原文读取 key,仅作为兼容兜底。 | +SourceMessage 原文和邮件会话完整正文已迁移到登录权限体系: -注意: - -- 原文读取 key 不是用户体系,后续接入正式登录和角色权限后应替换。 -- 任何能读取原文的调用都必须有调用方和访问场景,并写入审计表。 +- 前端请求必须携带 `Authorization: Bearer `。 +- 当前用户必须同时拥有 `SOURCE_MESSAGE_READ` 和 `SOURCE_MESSAGE_ORIGINAL_READ`。 +- 后端按 SourceMessage 实际所属酒店校验酒店访问权。 +- 后端内部写入原文读取审计,actor 使用当前登录用户稳定 ID。 +- 旧 `SOURCE_MESSAGE_DEV_ORIGINAL_READ_ACCESS_KEY`、`SOURCE_MESSAGE_TEST_ORIGINAL_READ_ACCESS_KEY`、`SOURCE_MESSAGE_PROD_ORIGINAL_READ_ACCESS_KEY`、`SOURCE_MESSAGE_ORIGINAL_READ_ACCESS_KEY` 已废弃,不再作为部署必备 Secret。 ### 3.4 AgentBus @@ -201,6 +199,31 @@ - Debug EML 和 AgentBus 自动分发复用同一个 SuperAgent Open API SSE 稳定客户端;上线前必须验证 `run.completed + end + final answer` 严格成功条件和 EOF 后 `/events` 恢复。 - 当前共享 Open API client 会自动携带临时 CSRF double-submit header / cookie;如果测试机仍返回 `CSRF token missing`,优先检查部署包版本和反向代理是否转发 `X-CSRF-Token`、`Cookie`。 +### 3.7 Excel 转 PDF / LibreOffice + +| 变量 | 是否 Secret | 上线注意事项 | +| --- | --- | --- | +| `DOCUMENT_CONVERSION_DEV_ENABLED` / `DOCUMENT_CONVERSION_TEST_ENABLED` / `DOCUMENT_CONVERSION_PROD_ENABLED` | 否 | 是否启用 Excel 转 PDF 手动上传接口;prod 默认必须保持 `false`,确认运行环境后再开启。 | +| `DOCUMENT_CONVERSION_DEV_ACCESS_KEY` / `DOCUMENT_CONVERSION_TEST_ACCESS_KEY` / `DOCUMENT_CONVERSION_PROD_ACCESS_KEY` | 是 | 文件转换访问口令;未配置时可兜底 `DOCUMENT_CONVERSION_ACCESS_KEY`。不得进入前端源码、镜像、普通日志或文档真实值。 | +| `DOCUMENT_CONVERSION_SOFFICE_PATH` / `DOCUMENT_CONVERSION_*_SOFFICE_PATH` | 否 | `soffice` 可执行文件路径,默认 `soffice`。测试机和生产机路径可能不同。 | +| `DOCUMENT_CONVERSION_TEMP_DIR` / `DOCUMENT_CONVERSION_*_TEMP_DIR` | 否 | 文件转换临时目录。后端运行用户必须有读写权限,目录应有系统清理策略。 | +| `DOCUMENT_CONVERSION_MAX_FILE_BYTES` / `DOCUMENT_CONVERSION_*_MAX_FILE_BYTES` | 否 | 单个 Excel 文件大小上限,默认 `20971520`。 | +| `DOCUMENT_CONVERSION_TIMEOUT_SECONDS` / `DOCUMENT_CONVERSION_*_TIMEOUT_SECONDS` | 否 | 单次 LibreOffice 转换超时秒数,默认 `60`。 | +| `DOCUMENT_CONVERSION_MAX_CONCURRENT` / `DOCUMENT_CONVERSION_*_MAX_CONCURRENT` | 否 | 最大并发转换数,默认 `2`。不要盲目调大,避免 LibreOffice 进程拖垮后端。 | +| `DOCUMENT_CONVERSION_OUTPUT_OSS_PREFIX` / `DOCUMENT_CONVERSION_*_OUTPUT_OSS_PREFIX` | 否 | PDF 输出 OSS 前缀,默认 `document-conversions/excel-to-pdf/`。 | +| `DOCUMENT_CONVERSION_WORKER_ENABLED` / `DOCUMENT_CONVERSION_*_WORKER_ENABLED` | 否 | 自动转换 worker 预留开关;CP2 不使用,默认关闭。 | +| `DOCUMENT_CONVERSION_MULTIPART_MAX_FILE_BYTES` / `DOCUMENT_CONVERSION_MULTIPART_MAX_REQUEST_BYTES` | 否 | Spring multipart 框架上限;默认分别为 `25165824` / `29360128`,应高于业务 `MAX_FILE_BYTES`,否则请求会在进入 Controller 前被框架拦截。 | + +注意: + +- 服务器必须安装 LibreOffice / LibreOffice Calc,并确认后端运行用户可以执行 `soffice`。 +- 服务器必须安装中文、英文、泰文等业务字体;缺少字体会导致 PDF 乱码、缺字或分页变化。 +- 每次转换会创建独立临时目录和 LibreOffice profile;正常结束、失败或超时后后端会清理,但仍建议运维配置临时目录兜底清理。 +- 当前接口路径为 `POST /api/system/document-conversions/excel-to-pdf`,Header 为 `X-TH-Hotel-Document-Conversion-Key`。 +- 当前接口只支持 `.xls` / `.xlsx`,不支持 `.xlsm`;后端会校验扩展名和文件头,改后缀的非 Excel 文件会返回受控错误。 +- PDF 上传到阿里云 OSS,返回 `pdf_url`、`object_key`、`pdf_file_name`、`pdf_size_bytes` 和 `duration_millis`。 +- CP2 不落库,不提供转换历史查询;如果需要自动处理邮件附件,应先进入 M008 后续持久化任务和 worker checkpoint。 + ## 4. 数据库上线注意事项 当前 SourceMessage 相关 migration: @@ -308,9 +331,8 @@ SourceMessage 普通列表和普通详情只能返回安全摘要: ```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 +Header: Authorization: Bearer +Required permission: SOURCE_MESSAGE_READ + SOURCE_MESSAGE_ORIGINAL_READ ``` 前端展示 `htmlBody` 前必须 sanitize。后端返回 `htmlSanitizeRequired=true` 是提醒前端不要直接信任 HTML。 @@ -418,15 +440,13 @@ GET /api/source-messages/{id}/original ```text AGENTBUS_PROBE_ENABLED=false AGENTBUS_CAPTURE_ENABLED=false -SOURCE_MESSAGE_PROD_ORIGINAL_READ_ACCESS_KEY= -SOURCE_MESSAGE_ORIGINAL_READ_ACCESS_KEY= ``` 说明: - 关闭 `AGENTBUS_PROBE_ENABLED` 可以停止 WebSocket 入站连接。 - 关闭 `AGENTBUS_CAPTURE_ENABLED` 可以保留连接但暂停写入 Inbox。 -- 清空当前 profile 对应的 `SOURCE_MESSAGE_*_ORIGINAL_READ_ACCESS_KEY`,且不配置旧通用变量,可以关闭原文读取接口。 +- 如需临时关闭前端邮件原文 / 会话完整正文读取,应从相关角色移除 `SOURCE_MESSAGE_ORIGINAL_READ` 或 `SOURCE_MESSAGE_READ`,或禁用对应用户入口;旧原文读取 key 已废弃,不能作为关闭开关。 数据库回滚注意: diff --git a/docs/project/requirements/M008-excel-to-pdf-conversion-v1.md b/docs/project/requirements/M008-excel-to-pdf-conversion-v1.md new file mode 100644 index 0000000..201e3cd --- /dev/null +++ b/docs/project/requirements/M008-excel-to-pdf-conversion-v1.md @@ -0,0 +1,318 @@ +# M008 Excel 转 PDF 文件转换能力 V1 + +| 项目 | 内容 | +| --- | --- | +| 文档状态 | 当前有效 | +| 适用范围 | 手动上传 Excel 转 PDF、邮件附件 Excel 自动派生 PDF | +| 推荐方案 | LibreOffice headless 作为后端受控转换引擎 | +| 当前目标 | CP2 已实现手动上传 Excel 转 PDF 后端接口;邮件附件自动转换、持久化转换任务和 worker 后置 | + +## 1. 背景 + +系统后续会出现两类 Excel 转 PDF 需求: + +- 前端 Debug / 后台页面手动上传 `.xls` / `.xlsx`,后端转换为 PDF 后返回下载或 OSS URL。 +- 邮件来源链路中,AgentBus 或 Debug EML 解析到 Excel 附件后,系统自动派生 PDF,便于前端预览、人工复核或后续证据沉淀。 + +这两类场景不应各自实现转换逻辑。第一版应把 Excel 转 PDF 做成平台级文件转换能力,手动上传和自动转换都调用同一套 Service / Adapter。 + +## 2. 设计原则 + +- 文件转换属于 `platform` 通用能力,不绑定 `reservation` 订单任务流程。 +- 业务代码只依赖内部稳定 Service,不直接调用 `soffice` 命令。 +- LibreOffice 作为外部运行时依赖,需要通过 Adapter 隔离。 +- 手动上传接口和邮件附件自动转换共享同一套校验、超时、并发、临时文件清理、OSS 上传和错误处理规则。 +- 转换失败不能影响 SourceMessage 原始邮件入库;自动转换失败只记录失败原因和可重试状态。 +- Excel 原文件和 PDF 派生文件必须能建立来源关系,便于审计和排查。 +- 默认不支持宏执行,不支持外部链接刷新,不支持把 Excel 内容写入普通日志。 + +## 3. 方案选择 + +### 3.1 推荐方案:LibreOffice headless + +后端把 Excel 文件写入隔离临时目录,然后调用 LibreOffice headless: + +```bash +soffice --headless --nologo --nofirststartwizard --norestore \ + -env:UserInstallation=file:///tmp/th-hotel-lo-profile-xxx \ + --convert-to pdf \ + --outdir /tmp/th-hotel-convert-output-xxx \ + /tmp/th-hotel-convert-input-xxx/source.xlsx +``` + +中文说明: + +- `soffice` 负责按照 Excel 自身页面设置、分页、合并单元格、图片和样式生成 PDF。 +- `UserInstallation` 必须为每次转换使用独立临时目录,降低并发转换时的 profile 冲突风险。 +- 后端必须设置转换超时;超时后销毁进程并清理临时文件。 + +### 3.2 不推荐第一版使用 POI 手工绘制 PDF + +Apache POI 可以读取 Excel,但要完整还原页面设置、分页、图表、图片、字体、合并单元格和打印区域,需要大量手工逻辑,维护成本高,不适合作为第一版主方案。 + +### 3.3 商业库作为备选 + +Aspose / Spire 等商业库可以降低运行时部署复杂度,但需要确认授权、费用和商用合规。除非项目明确采购,否则第一版不采用。 + +## 4. 后端模块边界 + +建议新增平台模块: + +```text +server/src/main/java/cn/nianxx/thhotel/platform/documentconversion +├── control +├── service +│ └── impl +├── domain +├── mapper +├── repository +└── common + ├── dto + ├── request + ├── result + └── enums + +server/src/main/java/cn/nianxx/thhotel/integrations/document/libreoffice +└── adapter +``` + +中文说明: + +| 模块 | 中文职责 | +| --- | --- | +| `platform.documentconversion` | 平台文件转换能力,负责请求校验、转换任务、OSS 输出、审计和结果查询 | +| `integrations.document.libreoffice.adapter` | LibreOffice 外部进程适配器,只负责调用 `soffice`、收集退出码和输出文件 | +| `integrations.storage.aliyunoss` | 继续作为 OSS 上传端口,转换服务只依赖 `ObjectStorageService` | + +后端实现时继续遵守当前项目包结构规范:`control`、`service`、`service.impl`、`domain`、`mapper`、`repository`、`common.request`、`common.result`、`common.dto`、`common.enums`。 + +## 5. 第一版数据流 + +### 5.1 手动上传转换 + +```text +前端上传 Excel + -> 后端校验文件类型和大小 + -> 写入隔离临时目录 + -> 调用 LibreOffice headless 转 PDF + -> 校验 PDF 输出存在且非空 + -> 上传 PDF 到 OSS 或直接返回文件流 + -> 返回转换结果 + -> 清理临时文件 +``` + +建议第一版优先返回 OSS URL,原因: + +- 与现有 Debug EML 附件处理和阿里云 OSS 上传能力一致。 +- PDF 可以被前端预览或下载。 +- 转换结果可以被后续审计和自动转换链路复用。 + +### 5.2 邮件附件自动转换 + +```text +SourceMessage 入库 / EML 调试链路解析附件 + -> 识别 Excel 附件 + -> 创建文件转换任务 + -> 异步 worker 执行转换 + -> PDF 上传 OSS + -> 回写转换任务状态和派生 PDF 结果 +``` + +自动转换不应阻塞邮件入库、AgentBus WebSocket 回调、SuperAgent dispatch 或任务结果入站。第一版如果转换失败,只记录 `FAILED` / `RETRYABLE_FAILED` 和安全错误摘要,不影响原附件可见。 + +## 6. 建议接口边界 + +### 6.1 手动上传接口 + +CP2 已实现: + +```text +POST /api/system/document-conversions/excel-to-pdf +``` + +中文说明: + +- 接口分类:`FRONTEND_DEBUG` 或 `FRONTEND_ADMIN`,不能作为普通公开接口。 +- 鉴权方式:CP2 使用环境开关 + 受控 access key;后续可统一迁移到登录权限体系。 +- Header:`X-TH-Hotel-Document-Conversion-Key: <文件转换访问口令>`。 +- 请求类型:`multipart/form-data`。 +- 文件字段:`file`。 +- 可选字段:`hotel_id`,用于 OSS 对象路径分组;CP2 不做酒店访问权校验,生产启用前应迁移到登录权限体系。 +- 返回:转换状态、PDF OSS URL、对象路径、文件名、大小、转换耗时、安全错误摘要。 + +请求示例: + +```bash +curl -X POST "$BASE_URL/api/system/document-conversions/excel-to-pdf" \ + -H "X-TH-Hotel-Document-Conversion-Key: $DOCUMENT_CONVERSION_ACCESS_KEY" \ + -F "hotel_id=HOTEL-TEST" \ + -F "file=@booking-request.xlsx" +``` + +成功响应示例: + +```json +{ + "conversion_status": "SUCCEEDED", + "source_file_name": "booking-request.xlsx", + "source_size_bytes": 12345, + "pdf_file_name": "booking-request.pdf", + "pdf_url": "https://oss.example.test/document-conversions/excel-to-pdf/HOTEL-TEST/2026-07-16/.../booking-request.pdf", + "object_key": "document-conversions/excel-to-pdf/HOTEL-TEST/2026-07-16/.../booking-request.pdf", + "content_type": "application/pdf", + "pdf_size_bytes": 67890, + "duration_millis": 1200 +} +``` + +错误码: + +| HTTP | 错误码 | 中文含义 | +| --- | --- | --- | +| `401` | `DOCUMENT_CONVERSION_KEY_INVALID` | 文件转换访问口令缺失或错误 | +| `404` | `DOCUMENT_CONVERSION_DISABLED` | 文件转换接口未启用 | +| `400` | `DOCUMENT_CONVERSION_FILE_REQUIRED` | 缺少上传文件或文件为空 | +| `400` | `DOCUMENT_CONVERSION_FILE_TYPE_UNSUPPORTED` | 不是 `.xls` / `.xlsx` | +| `400` | `DOCUMENT_CONVERSION_FILE_CONTENT_INVALID` | 文件扩展名是 Excel,但文件头不是 `.xlsx` ZIP / `.xls` OLE 格式 | +| `413` | `DOCUMENT_CONVERSION_FILE_TOO_LARGE` | 超过配置的文件大小限制 | +| `429` | `DOCUMENT_CONVERSION_BUSY` | 转换并发已满,稍后重试 | +| `502` | `DOCUMENT_CONVERSION_FAILED` | LibreOffice 转换失败或未生成有效 PDF | +| `502` | `DOCUMENT_CONVERSION_OSS_UPLOAD_FAILED` | PDF 上传 OSS 失败 | +| `504` | `DOCUMENT_CONVERSION_TIMEOUT` | LibreOffice 转换超时 | + +### 6.2 自动转换内部入口 + +建议后续只暴露内部 Service,不提供给前端直接调用: + +```text +DocumentConversionService.createExcelToPdfRun(...) +DocumentConversionWorker.processPendingRuns(...) +``` + +中文说明: + +- 邮件链路、Debug EML 链路和未来其他附件来源都只创建转换任务。 +- worker 统一处理重试、超时和状态流转。 + +## 7. 建议数据模型 + +如需要持久化转换记录,建议新增表: + +```text +platform_document_conversion_run +``` + +建议核心字段: + +| 字段 | 中文含义 | +| --- | --- | +| `id` | 内部转换任务 ID | +| `hotel_id` | 酒店 ID,手动上传或来源消息所属酒店 | +| `conversion_type` | 转换类型,第一版为 `EXCEL_TO_PDF` | +| `source_type` | 来源类型,例如 `MANUAL_UPLOAD`、`SOURCE_MESSAGE_ATTACHMENT`、`DEBUG_EML_ATTACHMENT` | +| `source_object_id` | 来源对象 ID,例如 SourceMessage ID 或 Debug run ID | +| `source_file_name` | 原始文件名 | +| `source_content_type` | 原始文件 MIME | +| `source_oss_url` | 原文件 OSS URL;手动上传如不保留原文件可为空 | +| `target_file_name` | 生成 PDF 文件名 | +| `target_oss_url` | 生成 PDF OSS URL | +| `status` | 转换状态 | +| `attempt_count` | 已尝试次数 | +| `safe_error_code` | 安全错误码 | +| `safe_error_summary` | 安全错误摘要,不包含文件内容和敏感 URL | +| `created_by` | 创建人或机器身份 | +| `created_at` / `updated_at` | UTC 时间 | + +建议状态: + +| 状态 | 中文含义 | +| --- | --- | +| `PENDING` | 等待转换 | +| `RUNNING` | 正在转换 | +| `SUCCEEDED` | 转换成功 | +| `RETRYABLE_FAILED` | 可重试失败 | +| `FAILED` | 不可重试失败 | +| `CANCELED` | 已取消 | + +## 8. 配置与部署要求 + +建议配置项: + +| 配置 | 是否 Secret | 中文说明 | +| --- | --- | --- | +| `DOCUMENT_CONVERSION_DEV_ENABLED` / `DOCUMENT_CONVERSION_TEST_ENABLED` / `DOCUMENT_CONVERSION_PROD_ENABLED` | 否 | 是否启用文件转换能力 | +| `DOCUMENT_CONVERSION_DEV_ACCESS_KEY` / `DOCUMENT_CONVERSION_TEST_ACCESS_KEY` / `DOCUMENT_CONVERSION_PROD_ACCESS_KEY` | 是 | 手动上传转换访问口令;未配置时可兜底 `DOCUMENT_CONVERSION_ACCESS_KEY` | +| `DOCUMENT_CONVERSION_SOFFICE_PATH` | 否 | `soffice` 可执行文件路径,默认 `soffice` | +| `DOCUMENT_CONVERSION_TEMP_DIR` | 否 | 临时文件根目录 | +| `DOCUMENT_CONVERSION_MAX_FILE_BYTES` | 否 | 单个 Excel 最大字节数 | +| `DOCUMENT_CONVERSION_TIMEOUT_SECONDS` | 否 | 单次转换超时时间 | +| `DOCUMENT_CONVERSION_MAX_CONCURRENT` | 否 | 最大并发转换数 | +| `DOCUMENT_CONVERSION_OUTPUT_OSS_PREFIX` | 否 | PDF 输出 OSS 前缀 | +| `DOCUMENT_CONVERSION_WORKER_ENABLED` | 否 | 自动转换 worker 是否启用 | + +服务器需要准备: + +- 安装 LibreOffice / LibreOffice Calc。 +- 安装中文、英文、泰文等业务需要字体,例如 Noto CJK、Noto Thai、Arial 兼容字体。 +- 确认运行后端的系统用户可以执行 `soffice`,并有临时目录读写权限。 +- 确认临时目录磁盘空间、清理策略和权限隔离。 +- 测试机和生产机 LibreOffice 版本尽量一致,避免同一 Excel 输出 PDF 差异过大。 + +## 9. 安全与稳定性要求 + +- 只允许 `.xls` / `.xlsx`;第一版不支持 `.xlsm`。后端会同时校验扩展名和文件头:`.xlsx` 必须是 ZIP / OOXML 头,`.xls` 必须是 OLE 复合文档头。 +- 文件大小必须有限制,默认建议不超过 20 MB,具体值实现前再确认。 +- 使用 `ProcessBuilder` 参数数组调用外部进程,禁止拼接 shell 字符串。 +- 每次转换使用独立输入目录、输出目录和 LibreOffice profile 目录。 +- 转换完成、失败或超时后必须清理临时文件。 +- 日志只记录转换 ID、文件类型、大小、耗时、状态和安全错误摘要,不记录 Excel 内容、PDF 内容、OSS 签名 URL 或客户敏感信息。 +- 自动转换 worker 必须限制并发,避免大量附件同时转换拖垮后端。 +- 生产环境默认关闭,待测试机验证转换质量、字体和性能后再开启。 + +## 10. Checkpoint 规划 + +### CP1:方案和部署验证 + +- 落地本文档。 +- 测试机安装 LibreOffice 和字体。 +- 用真实业务样式 Excel 手工执行 `soffice` 命令,确认 PDF 保真度。 +- 明确 PDF 输出是直接下载还是 OSS URL,建议第一版 OSS URL。 + +### CP2:手动上传转换接口 + +- 新增平台文件转换模块。 +- 实现 `POST /api/system/document-conversions/excel-to-pdf`。 +- 接入 OSS 输出。 +- 补充文件类型、大小、超时、临时文件清理和错误返回测试。 +- 更新前后端沟通文档和上线注意事项。 + +当前状态:已完成第一版后端接口。CP2 不落库、不做 worker、不自动处理邮件附件;返回的 PDF 会上传 OSS,接口受 `document-conversion.enabled` 和 `X-TH-Hotel-Document-Conversion-Key` 控制。 + +### CP3:持久化转换任务和 worker + +- 新增 `platform_document_conversion_run`。 +- 实现 PENDING / RUNNING / SUCCEEDED / RETRYABLE_FAILED / FAILED 状态流转。 +- 支持自动重试、错误摘要和结果查询。 + +### CP4:邮件附件自动转换 + +- SourceMessage / Debug EML 链路识别 Excel 附件后创建转换任务。 +- PDF 派生结果和原附件建立来源关系。 +- 自动转换失败不影响原邮件入库和业务主流程。 + +### CP5:前端展示和下载 + +- 手动上传页面展示转换进度和 PDF 链接。 +- 邮件会话或附件区域展示 Excel 原件和派生 PDF。 +- 前端不得把 OSS URL 写入普通日志、埋点或 URL query。 + +## 11. 当前未确定事项 + +实现前需要确认: + +- 手动上传第一版返回 PDF 文件流,还是上传 OSS 后返回 URL;当前建议 OSS URL。 +- 手动上传接口是放在 Debug 页面、系统管理页面,还是单独工具页面。 +- 第一版最大文件大小和最大转换时长;当前代码默认 20 MB 和 60 秒,可通过环境变量调整。 +- 自动转换是否只处理邮件附件,还是也处理用户手动上传后保存的 Excel。 +- PDF 是否需要长期保存;如果长期保存,需要确认 OSS 生命周期和清理策略。 +- 是否需要把 Excel 原文件也统一转存 OSS;自动附件场景通常已有 OSS URL,手动上传场景需要确认。 diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md index 44aa542..037b07e 100644 --- a/docs/project/security-access-control-boundary.md +++ b/docs/project/security-access-control-boundary.md @@ -61,8 +61,8 @@ | --- | --- | --- | --- | --- | | `GET /api/source-messages` | `FRONTEND_USER` | 已强制 Bearer 登录 + `SOURCE_MESSAGE_READ`;列表条件中的酒店按当前用户可访问酒店校验 | 保持登录 + `SOURCE_MESSAGE_READ` + 酒店访问权 | 只读摘要不写审计 | | `GET /api/source-messages/{id}` | `FRONTEND_USER` | 已强制 Bearer 登录 + `SOURCE_MESSAGE_READ`;按消息实际所属酒店校验访问权 | 保持登录 + `SOURCE_MESSAGE_READ` + 消息所属酒店访问权 | 只读摘要不写审计 | -| `GET /api/source-messages/{id}/conversation` | `FRONTEND_USER` | 返回会话完整 text/html 和媒体 URL;后端内部写原文读取审计 | 登录 + `SOURCE_MESSAGE_READ`,如返回完整正文则还需 `SOURCE_MESSAGE_ORIGINAL_READ` | 必须写原文读取审计 | -| `GET /api/source-messages/{id}/original` | `FRONTEND_USER` | 当前使用受控原文读取 key | 迁移为登录 + `SOURCE_MESSAGE_ORIGINAL_READ` + 酒店访问权,access key 仅作兼容或关闭 | 必须写原文读取审计 | +| `GET /api/source-messages/{id}/conversation` | `FRONTEND_USER` | 已强制 Bearer 登录 + `SOURCE_MESSAGE_READ` + `SOURCE_MESSAGE_ORIGINAL_READ`;按消息实际所属酒店校验访问权;返回会话完整 text/html 和媒体 URL | 保持登录 + `SOURCE_MESSAGE_READ` + `SOURCE_MESSAGE_ORIGINAL_READ` + 消息所属酒店访问权 | 必须写原文读取审计,actor 使用当前登录用户稳定 ID | +| `GET /api/source-messages/{id}/original` | `FRONTEND_USER` | 已强制 Bearer 登录 + `SOURCE_MESSAGE_READ` + `SOURCE_MESSAGE_ORIGINAL_READ`;按消息实际所属酒店校验访问权;不再使用原文读取 access key | 保持登录 + `SOURCE_MESSAGE_READ` + `SOURCE_MESSAGE_ORIGINAL_READ` + 消息所属酒店访问权 | 必须写原文读取审计,actor 使用当前登录用户稳定 ID | ### 3.4 系统管理后台接口 @@ -82,6 +82,7 @@ | `POST /api/system/debug/eml-superagent-runs` | `FRONTEND_DEBUG` | 环境开关 + `X-TH-Hotel-Debug-Upload-Key` | dev/test 可保留 access key;长期目标为登录 + `SYSTEM_DEBUG_EML_RUN` + 环境开关 | 写 Debug run,必要时补管理 / 调试审计 | | `GET /api/system/debug/eml-superagent-runs/{runId}` | `FRONTEND_DEBUG` | 环境开关 + access key | 登录 + `SYSTEM_DEBUG_EML_RUN`;避免向普通用户暴露 AI 原始结果 | 只读调试可记录访问日志 | | `POST /api/system/debug/eml-superagent-runs/stream` | `FRONTEND_DEBUG` | 环境开关 + access key | 登录 + `SYSTEM_DEBUG_EML_RUN`;生产默认关闭 | 写 Debug run 和安全错误摘要 | +| `POST /api/system/document-conversions/excel-to-pdf` | `FRONTEND_DEBUG` | 环境开关 + `X-TH-Hotel-Document-Conversion-Key`;只支持 `.xls` / `.xlsx`,PDF 输出到 OSS | 长期目标为登录 + 文件转换调试权限 + 环境开关;生产默认关闭 | CP2 不落库;必要时通过网关访问日志和 OSS 对象路径追踪,后续自动转换任务落库后补转换审计 | | `GET /api/system/agentbus-probe` | `FRONTEND_DEBUG` | 当前系统状态接口 | 登录 + `SYSTEM_AGENTBUS_PROBE_READ` 或系统管理入口权限 | 不返回 Token、raw frame 或邮件正文 | | `POST /api/system/reservation/demo-data` | `FRONTEND_DEBUG` | 环境开关 + demo access key | dev/test 使用;生产必须关闭 | 写入演示数据时建议记录调试审计 | @@ -122,7 +123,7 @@ | `RESERVATION_OPERA_SIM_EXECUTE` | 执行或重试 OPERA 模拟 / 未来真实操作 | OPERA execute / retry | | `RESERVATION_AUDIT_READ` | 查看业务审计流水 | 任务审计列表 | | `SOURCE_MESSAGE_READ` | 查看来源邮件安全摘要 | SourceMessage 列表、详情、会话摘要 | -| `SOURCE_MESSAGE_ORIGINAL_READ` | 查看邮件正文、HTML 和附件外链 | original / conversation 完整正文 | +| `SOURCE_MESSAGE_ORIGINAL_READ` | 查看邮件正文、HTML 和附件外链 | original / conversation 完整正文;必须叠加 `SOURCE_MESSAGE_READ` 使用 | | `SYSTEM_DEBUG_EML_RUN` | 使用 Debug EML 调试链路 | Debug EML 上传、查询、stream | | `SYSTEM_AGENTBUS_PROBE_READ` | 查看 AgentBus 安全状态 | AgentBus probe | @@ -133,6 +134,31 @@ 3. `docs/project/security-access-control-boundary.md`。 4. 前后端协作文档中对应页面按钮或菜单说明。 +### 4.1 新增接口权限固定流程 + +后续新增需要前端用户或管理员调用的接口时,必须按同一套流程维护权限,避免“接口能调用但系统设置里管不了”或“前端隐藏了但后端没拦”的不一致。 + +固定流程: + +1. **确认接口分类。** 先判断接口属于 `FRONTEND_USER`、`FRONTEND_ADMIN`、`FRONTEND_DEBUG`、第三方机器接口还是 `INTERNAL_ONLY`。只有前端用户和管理员接口进入用户角色权限模型;SuperAgent、AgentBus、MCP 继续使用机器鉴权,不使用用户 Bearer 权限码。 +2. **定义稳定权限码。** 在 `PlatformPermissionCode` 增加稳定英文权限码,例如 `RESERVATION_TASK_ASSIGN`。权限码只表达能力边界,不绑定中文文案、不绑定某个按钮样式。 +3. **补启动同步元数据。** 在权限启动同步逻辑中补权限名称、权限分组和状态,确保 `platform_permission` 能自动拥有该权限码。 +4. **补内置角色默认矩阵。** 明确 `SYSTEM_ADMIN`、业务操作员、只读角色等内置角色是否默认拥有该权限。内置角色矩阵仍以代码为准;自定义角色后续通过系统设置页面分配。 +5. **后端接口强制校验。** 在 Controller 或统一入口中显式调用对应鉴权服务,例如 `requirePermission(PlatformPermissionCode.X.name())`。只读接口同时校验对象所属酒店;写接口还要校验状态、幂等、事务和审计。 +6. **前端类型和交互同步。** 在前端权限类型中加入新权限码,路由、菜单、按钮和操作入口按 `/api/auth/me` 返回的 `permissions[]` 控制展示。前端控制只提升体验,不能替代后端权限校验。 +7. **系统设置可分配。** 权限启动同步后,系统设置的角色权限页面应能看到该权限码;需要给自定义角色授权时,通过系统设置勾选,用户重新登录或刷新上下文后生效。 +8. **补测试。** 至少覆盖无 token、无权限、有权限、跨酒店或对象归属校验。第三方接口要补“不被用户登录拦截误伤”的回归测试。 +9. **补文档。** 本文矩阵中登记接口分类、权限码、酒店隔离和审计要求;影响前端时同步 `docs/project/frontend-backend/backend-to-frontend-notes.md`;影响 SuperAgent / MCP / AgentBus 时同步对应集成契约。 + +最小验收口径: + +- 新接口没有 token 时返回该分类约定的 401。 +- 已登录但缺权限时返回该分类约定的 403。 +- 有权限但访问无权酒店或无权对象时返回酒店 / 对象访问拒绝。 +- 权限码能在系统设置角色权限页面看到并分配给自定义角色。 +- 前端只做路由、菜单、按钮显示控制,后端仍能拦截直接调用。 +- 第三方机器接口不被用户 Bearer 权限模型误拦截。 + ## 5. 酒店隔离规则 - 前端用户接口必须从当前登录用户解析可访问酒店集合。 @@ -184,7 +210,7 @@ 后续开发权限收口时建议按以下顺序推进: 1. 已完成 CP1:Reservation / SourceMessage 第一批只读查询接口已收口登录、权限码和酒店访问权,包括任务列表、订单列表、订单详情、任务详情、任务审计列表、SourceMessage 摘要列表和摘要详情。 -2. 再收口邮件原文和会话完整正文读取:迁移到 `SOURCE_MESSAGE_ORIGINAL_READ`,保留审计。 +2. 已完成 CP2:邮件原文和会话完整正文读取已迁移到 `SOURCE_MESSAGE_READ` + `SOURCE_MESSAGE_ORIGINAL_READ`,并按消息所属酒店校验访问权;原文读取审计 actor 使用当前登录用户稳定 ID。 3. 再收口 Reservation 写操作:草稿、确认、人工复核、OPERA 模拟。 4. 迁移业务审计 actor 到当前登录用户。 5. 最后处理 Debug、Demo、Replay、AgentBus Probe 等系统调试入口。 diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/document/libreoffice/adapter/LibreOfficeExcelToPdfAdapter.java b/server/src/main/java/cn/nianxx/thhotel/integrations/document/libreoffice/adapter/LibreOfficeExcelToPdfAdapter.java new file mode 100644 index 0000000..bd2ebb2 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/document/libreoffice/adapter/LibreOfficeExcelToPdfAdapter.java @@ -0,0 +1,200 @@ +package cn.nianxx.thhotel.integrations.document.libreoffice.adapter; + +import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConversionInput; +import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConvertedDocument; +import cn.nianxx.thhotel.platform.documentconversion.service.DocumentConversionException; +import cn.nianxx.thhotel.platform.documentconversion.service.ExcelToPdfConverter; +import cn.nianxx.thhotel.platform.documentconversion.service.impl.DocumentConversionProperties; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; + +/** + * LibreOffice Excel 转 PDF 适配器。负责临时目录、独立 profile、soffice 命令和 PDF 输出校验。 + */ +@Component +public class LibreOfficeExcelToPdfAdapter implements ExcelToPdfConverter { + + private final DocumentConversionProperties properties; + private final LibreOfficeProcessRunner processRunner; + + /** + * 注入文件转换配置和 LibreOffice 进程执行端口。 + */ + public LibreOfficeExcelToPdfAdapter( + DocumentConversionProperties properties, + LibreOfficeProcessRunner processRunner) { + this.properties = properties; + this.processRunner = processRunner; + } + + /** + * 将 Excel 字节写入隔离临时目录后调用 LibreOffice 生成 PDF,并在结束后清理临时文件。 + */ + @Override + public ExcelToPdfConvertedDocument convert(ExcelToPdfConversionInput input) { + Instant startedAt = Instant.now(); + Path workDirectory = null; + try { + Path tempRoot = tempRoot(); + Files.createDirectories(tempRoot); + workDirectory = Files.createTempDirectory(tempRoot, "excel-to-pdf-"); + Path inputDirectory = Files.createDirectories(workDirectory.resolve("input")); + Path outputDirectory = Files.createDirectories(workDirectory.resolve("output")); + Path profileDirectory = Files.createDirectories(workDirectory.resolve("lo-profile")); + String sourceFileName = safeFileName(input.fileName(), "source.xlsx"); + Path inputFile = inputDirectory.resolve(sourceFileName); + Files.write(inputFile, input.content() == null ? new byte[0] : input.content()); + LibreOfficeProcessResult processResult = processRunner.run(new LibreOfficeProcessRequest( + command(inputFile, outputDirectory, profileDirectory), + inputDirectory, + inputFile, + outputDirectory, + profileDirectory, + Duration.ofSeconds(Math.max(1, properties.getTimeoutSeconds())))); + validateProcessResult(processResult); + Path pdfFile = outputDirectory.resolve(pdfFileName(sourceFileName)); + validatePdfFile(pdfFile); + byte[] pdfBytes = Files.readAllBytes(pdfFile); + return new ExcelToPdfConvertedDocument( + pdfFile.getFileName().toString(), + MediaType.APPLICATION_PDF_VALUE, + (long) pdfBytes.length, + pdfBytes, + Duration.between(startedAt, Instant.now()).toMillis()); + } catch (DocumentConversionException exception) { + throw exception; + } catch (Exception exception) { + throw new DocumentConversionException( + HttpStatus.BAD_GATEWAY, + "DOCUMENT_CONVERSION_FAILED", + "Excel 转 PDF 失败。", + exception); + } finally { + cleanup(workDirectory); + } + } + + /** + * 构建 LibreOffice headless 命令。 + */ + private List command(Path inputFile, Path outputDirectory, Path profileDirectory) { + String sofficePath = properties.getSofficePath() == null || properties.getSofficePath().isBlank() + ? "soffice" + : properties.getSofficePath(); + return List.of( + sofficePath, + "--headless", + "--nologo", + "--nofirststartwizard", + "--norestore", + "-env:UserInstallation=" + profileDirectory.toUri(), + "--convert-to", + "pdf", + "--outdir", + outputDirectory.toString(), + inputFile.toString()); + } + + /** + * 校验 LibreOffice 进程结果。 + */ + private void validateProcessResult(LibreOfficeProcessResult result) { + if (result == null) { + throw new DocumentConversionException( + HttpStatus.BAD_GATEWAY, + "DOCUMENT_CONVERSION_FAILED", + "LibreOffice 未返回转换结果。"); + } + if (result.timedOut()) { + throw new DocumentConversionException( + HttpStatus.GATEWAY_TIMEOUT, + "DOCUMENT_CONVERSION_TIMEOUT", + "Excel 转 PDF 超时。"); + } + if (result.exitCode() != 0) { + throw new DocumentConversionException( + HttpStatus.BAD_GATEWAY, + "DOCUMENT_CONVERSION_FAILED", + "LibreOffice 转换失败。"); + } + } + + /** + * 校验 PDF 文件存在且非空。 + */ + private void validatePdfFile(Path pdfFile) throws IOException { + if (!Files.exists(pdfFile) || Files.size(pdfFile) <= 0) { + throw new DocumentConversionException( + HttpStatus.BAD_GATEWAY, + "DOCUMENT_CONVERSION_FAILED", + "Excel 转 PDF 未生成有效文件。"); + } + } + + /** + * 获取转换临时根目录。 + */ + private Path tempRoot() { + String configured = properties.getTempDir(); + String tempDir = configured == null || configured.isBlank() + ? System.getProperty("java.io.tmpdir") + "/th-hotel-document-conversion" + : configured; + return Path.of(tempDir); + } + + /** + * 生成 PDF 文件名。 + */ + private String pdfFileName(String sourceFileName) { + String baseName = sourceFileName; + int dotIndex = baseName.lastIndexOf('.'); + if (dotIndex > 0) { + baseName = baseName.substring(0, dotIndex); + } + return baseName + ".pdf"; + } + + /** + * 文件名安全清洗,避免路径穿越和日志污染。 + */ + private String safeFileName(String fileName, String fallback) { + String candidate = fileName == null || fileName.isBlank() ? fallback : fileName; + int slashIndex = Math.max(candidate.lastIndexOf('/'), candidate.lastIndexOf('\\')); + if (slashIndex >= 0) { + candidate = candidate.substring(slashIndex + 1); + } + String sanitized = candidate.replaceAll("[^A-Za-z0-9._-]", "_"); + if (sanitized.isBlank()) { + sanitized = fallback; + } + return sanitized.length() > 160 ? sanitized.substring(sanitized.length() - 160) : sanitized; + } + + /** + * 清理单次转换临时目录。 + */ + private void cleanup(Path workDirectory) { + if (workDirectory == null || !Files.exists(workDirectory)) { + return; + } + try (var paths = Files.walk(workDirectory)) { + paths.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + // 临时文件清理失败不覆盖原始转换结果;运维可通过系统临时目录清理策略兜底。 + } + }); + } catch (IOException ignored) { + // 临时目录遍历失败不覆盖原始转换结果。 + } + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/document/libreoffice/adapter/LibreOfficeProcessRequest.java b/server/src/main/java/cn/nianxx/thhotel/integrations/document/libreoffice/adapter/LibreOfficeProcessRequest.java new file mode 100644 index 0000000..f8d2d42 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/document/libreoffice/adapter/LibreOfficeProcessRequest.java @@ -0,0 +1,25 @@ +package cn.nianxx.thhotel.integrations.document.libreoffice.adapter; + +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; + +/** + * LibreOffice 外部进程请求。只包含执行命令、输入输出目录和超时信息。 + * + * @param command soffice 命令参数数组 + * @param workingDirectory soffice 进程工作目录 + * @param inputFile 输入 Excel 文件 + * @param outputDirectory PDF 输出目录 + * @param profileDirectory 本次转换独立 LibreOffice profile 目录 + * @param timeout 单次转换超时 + */ +public record LibreOfficeProcessRequest( + List command, + Path workingDirectory, + Path inputFile, + Path outputDirectory, + Path profileDirectory, + Duration timeout +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/document/libreoffice/adapter/LibreOfficeProcessResult.java b/server/src/main/java/cn/nianxx/thhotel/integrations/document/libreoffice/adapter/LibreOfficeProcessResult.java new file mode 100644 index 0000000..205dfa5 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/document/libreoffice/adapter/LibreOfficeProcessResult.java @@ -0,0 +1,17 @@ +package cn.nianxx.thhotel.integrations.document.libreoffice.adapter; + +/** + * LibreOffice 外部进程结果。日志和响应不得直接暴露原始 stdout / stderr 给前端。 + * + * @param exitCode 进程退出码 + * @param timedOut 是否超时 + * @param stdout 标准输出摘要 + * @param stderr 标准错误摘要 + */ +public record LibreOfficeProcessResult( + int exitCode, + boolean timedOut, + String stdout, + String stderr +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/document/libreoffice/adapter/LibreOfficeProcessRunner.java b/server/src/main/java/cn/nianxx/thhotel/integrations/document/libreoffice/adapter/LibreOfficeProcessRunner.java new file mode 100644 index 0000000..c500f99 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/document/libreoffice/adapter/LibreOfficeProcessRunner.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.integrations.document.libreoffice.adapter; + +/** + * LibreOffice 进程执行端口。便于测试替换,不让单元测试依赖真实 soffice。 + */ +public interface LibreOfficeProcessRunner { + + /** + * 执行 LibreOffice 转换命令并返回安全进程结果。 + */ + LibreOfficeProcessResult run(LibreOfficeProcessRequest request); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/document/libreoffice/adapter/ProcessBuilderLibreOfficeProcessRunner.java b/server/src/main/java/cn/nianxx/thhotel/integrations/document/libreoffice/adapter/ProcessBuilderLibreOfficeProcessRunner.java new file mode 100644 index 0000000..6a4433b --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/document/libreoffice/adapter/ProcessBuilderLibreOfficeProcessRunner.java @@ -0,0 +1,129 @@ +package cn.nianxx.thhotel.integrations.document.libreoffice.adapter; + +import cn.nianxx.thhotel.platform.documentconversion.service.DocumentConversionException; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +/** + * 基于 ProcessBuilder 的 LibreOffice 进程执行器。只由 LibreOffice Adapter 使用。 + */ +@Component +public class ProcessBuilderLibreOfficeProcessRunner implements LibreOfficeProcessRunner { + + /** + * 执行 soffice 命令并处理超时;调用方负责解释退出码和输出文件。 + */ + @Override + public LibreOfficeProcessResult run(LibreOfficeProcessRequest request) { + ProcessBuilder processBuilder = new ProcessBuilder(request.command()); + if (request.workingDirectory() != null) { + processBuilder.directory(request.workingDirectory().toFile()); + } + processBuilder.redirectErrorStream(true); + try { + Process process = processBuilder.start(); + CompletableFuture outputFuture = CompletableFuture.supplyAsync(() -> readOutput(process.getInputStream())); + boolean completed = process.waitFor(request.timeout().toMillis(), TimeUnit.MILLISECONDS); + if (!completed) { + destroyProcessTree(process); + return new LibreOfficeProcessResult(-1, true, safeGetOutput(outputFuture), ""); + } + return new LibreOfficeProcessResult(process.exitValue(), false, safeGetOutput(outputFuture), ""); + } catch (Exception exception) { + throw new DocumentConversionException( + HttpStatus.BAD_GATEWAY, + "DOCUMENT_CONVERSION_FAILED", + "调用 LibreOffice 转换失败。", + exception); + } + } + + /** + * 超时时销毁 LibreOffice 进程树,避免 soffice 派生子进程继续占用临时目录或 CPU。 + */ + private void destroyProcessTree(Process process) { + ProcessHandle root = process.toHandle(); + List processTree = new ArrayList<>(); + root.descendants().forEach(processTree::add); + processTree.sort(Comparator.comparingLong(ProcessHandle::pid).reversed()); + processTree.forEach(ProcessHandle::destroy); + root.destroy(); + waitForProcessTree(root, processTree, Duration.ofMillis(500)); + processTree.stream().filter(ProcessHandle::isAlive).forEach(ProcessHandle::destroyForcibly); + if (root.isAlive()) { + root.destroyForcibly(); + } + waitForProcessTree(root, processTree, Duration.ofSeconds(1)); + } + + /** + * 等待进程树退出;等待失败不抛出,调用方已经返回安全超时错误。 + */ + private void waitForProcessTree(ProcessHandle root, List descendants, Duration timeout) { + long deadline = System.nanoTime() + timeout.toNanos(); + for (ProcessHandle descendant : descendants) { + waitForHandle(descendant, deadline); + } + waitForHandle(root, deadline); + } + + /** + * 等待单个进程退出。 + */ + private void waitForHandle(ProcessHandle handle, long deadlineNanos) { + if (!handle.isAlive()) { + return; + } + long remainingNanos = deadlineNanos - System.nanoTime(); + if (remainingNanos <= 0) { + return; + } + try { + handle.onExit().get(remainingNanos, TimeUnit.NANOSECONDS); + } catch (Exception ignored) { + // 超时或等待失败时继续执行后续 destroyForcibly / 清理流程。 + } + } + + /** + * 读取进程输出,最多保留前 4000 个字符,避免日志或内存被异常输出撑爆。 + */ + private String readOutput(InputStream inputStream) { + try (InputStream stream = inputStream) { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] chunk = new byte[512]; + int totalKept = 0; + int read; + while ((read = stream.read(chunk)) >= 0) { + if (totalKept < 4000) { + int keep = Math.min(read, 4000 - totalKept); + buffer.write(chunk, 0, keep); + totalKept += keep; + } + } + return buffer.toString(StandardCharsets.UTF_8); + } catch (Exception exception) { + return ""; + } + } + + /** + * 获取异步输出;读取失败时返回空字符串。 + */ + private String safeGetOutput(CompletableFuture outputFuture) { + try { + return outputFuture.get(1, TimeUnit.SECONDS); + } catch (Exception exception) { + return ""; + } + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/common/dto/ExcelToPdfConversionInput.java b/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/common/dto/ExcelToPdfConversionInput.java new file mode 100644 index 0000000..2d0cd10 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/common/dto/ExcelToPdfConversionInput.java @@ -0,0 +1,17 @@ +package cn.nianxx.thhotel.platform.documentconversion.common.dto; + +/** + * Excel 转 PDF 输入文档。由平台转换服务完成上传校验后传给转换引擎。 + * + * @param fileName 安全文件名 + * @param contentType 上传文件 MIME 类型 + * @param sizeBytes 上传文件大小字节数 + * @param content 上传文件字节内容 + */ +public record ExcelToPdfConversionInput( + String fileName, + String contentType, + Long sizeBytes, + byte[] content +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/common/dto/ExcelToPdfConvertedDocument.java b/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/common/dto/ExcelToPdfConvertedDocument.java new file mode 100644 index 0000000..049d8f6 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/common/dto/ExcelToPdfConvertedDocument.java @@ -0,0 +1,19 @@ +package cn.nianxx.thhotel.platform.documentconversion.common.dto; + +/** + * Excel 转 PDF 输出文档。转换引擎只返回 PDF 字节和安全元数据,不负责 OSS 上传。 + * + * @param fileName 生成的 PDF 文件名 + * @param contentType PDF MIME 类型 + * @param sizeBytes PDF 文件大小字节数 + * @param content PDF 文件字节内容 + * @param durationMillis 转换耗时毫秒 + */ +public record ExcelToPdfConvertedDocument( + String fileName, + String contentType, + Long sizeBytes, + byte[] content, + Long durationMillis +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/common/enums/DocumentConversionStatus.java b/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/common/enums/DocumentConversionStatus.java new file mode 100644 index 0000000..09b5dc8 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/common/enums/DocumentConversionStatus.java @@ -0,0 +1,10 @@ +package cn.nianxx.thhotel.platform.documentconversion.common.enums; + +/** + * 文件转换状态。CP2 手动上传接口只返回成功状态,失败通过受控错误响应表达。 + */ +public enum DocumentConversionStatus { + + /** 转换并上传 PDF 成功。 */ + SUCCEEDED +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/common/result/DocumentConversionErrorResponse.java b/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/common/result/DocumentConversionErrorResponse.java new file mode 100644 index 0000000..4bee0e6 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/common/result/DocumentConversionErrorResponse.java @@ -0,0 +1,16 @@ +package cn.nianxx.thhotel.platform.documentconversion.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 文件转换接口错误响应。只返回安全错误码和摘要,不暴露文件内容、临时路径或 Secret。 + * + * @param errorCode 错误码 + * @param message 安全错误摘要 + */ +public record DocumentConversionErrorResponse( + @JsonProperty("error_code") + String errorCode, + String message +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/common/result/ExcelToPdfConversionResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/common/result/ExcelToPdfConversionResult.java new file mode 100644 index 0000000..1448e52 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/common/result/ExcelToPdfConversionResult.java @@ -0,0 +1,43 @@ +package cn.nianxx.thhotel.platform.documentconversion.common.result; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Excel 转 PDF 手动上传接口结果。返回 PDF 的 OSS 访问信息和安全转换摘要。 + * + * @param conversionStatus 转换状态 + * @param sourceFileName 上传 Excel 文件名 + * @param sourceSizeBytes 上传 Excel 文件大小字节数 + * @param pdfFileName 生成 PDF 文件名 + * @param pdfUrl PDF OSS 访问 URL + * @param objectKey PDF OSS 对象路径 + * @param contentType PDF MIME 类型 + * @param pdfSizeBytes PDF 文件大小字节数 + * @param durationMillis 转换耗时毫秒 + * @param safeErrorSummary 安全错误摘要,成功时为空 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ExcelToPdfConversionResult( + @JsonProperty("conversion_status") + String conversionStatus, + @JsonProperty("source_file_name") + String sourceFileName, + @JsonProperty("source_size_bytes") + Long sourceSizeBytes, + @JsonProperty("pdf_file_name") + String pdfFileName, + @JsonProperty("pdf_url") + String pdfUrl, + @JsonProperty("object_key") + String objectKey, + @JsonProperty("content_type") + String contentType, + @JsonProperty("pdf_size_bytes") + Long pdfSizeBytes, + @JsonProperty("duration_millis") + Long durationMillis, + @JsonProperty("safe_error_summary") + String safeErrorSummary +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/control/DocumentConversionController.java b/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/control/DocumentConversionController.java new file mode 100644 index 0000000..8d6b443 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/control/DocumentConversionController.java @@ -0,0 +1,43 @@ +package cn.nianxx.thhotel.platform.documentconversion.control; + +import cn.nianxx.thhotel.platform.documentconversion.common.result.ExcelToPdfConversionResult; +import cn.nianxx.thhotel.platform.documentconversion.service.DocumentConversionService; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +/** + * 文件转换 Controller。当前只开放受控 Excel 转 PDF 手动上传接口。 + */ +@RestController +@RequestMapping("/api/system/document-conversions") +public class DocumentConversionController { + + private final DocumentConversionService documentConversionService; + + /** + * 注入文件转换服务,Controller 不直接调用 LibreOffice 或 OSS。 + */ + public DocumentConversionController(DocumentConversionService documentConversionService) { + this.documentConversionService = documentConversionService; + } + + /** + * 上传 Excel 并转换为 PDF,成功后返回 PDF OSS URL。 + */ + @PostMapping(path = "/excel-to-pdf", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity convertExcelToPdf( + @RequestHeader(name = "X-TH-Hotel-Document-Conversion-Key", required = false) String accessKey, + @RequestParam("file") MultipartFile file, + @RequestParam(name = "hotel_id", required = false) String hotelId) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(documentConversionService.convertExcelToPdf(accessKey, file, hotelId)); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/control/DocumentConversionControllerAdvice.java b/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/control/DocumentConversionControllerAdvice.java new file mode 100644 index 0000000..0501ae1 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/control/DocumentConversionControllerAdvice.java @@ -0,0 +1,37 @@ +package cn.nianxx.thhotel.platform.documentconversion.control; + +import cn.nianxx.thhotel.platform.documentconversion.common.result.DocumentConversionErrorResponse; +import cn.nianxx.thhotel.platform.documentconversion.service.DocumentConversionException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.support.MissingServletRequestPartException; + +/** + * 文件转换接口异常转换。只返回安全错误摘要,不暴露 Excel 内容、PDF 内容、临时路径或 Secret。 + */ +@RestControllerAdvice(assignableTypes = DocumentConversionController.class) +public class DocumentConversionControllerAdvice { + + /** + * 转换文件转换业务异常。 + */ + @ExceptionHandler(DocumentConversionException.class) + public ResponseEntity handleDocumentConversionException( + DocumentConversionException exception) { + return ResponseEntity.status(exception.getStatus()) + .body(new DocumentConversionErrorResponse(exception.getErrorCode(), exception.getMessage())); + } + + /** + * 转换缺少 multipart 文件或必填参数的异常。 + */ + @ExceptionHandler({MissingServletRequestPartException.class, MissingServletRequestParameterException.class}) + public ResponseEntity handleMissingRequestPart(Exception exception) { + return ResponseEntity.badRequest() + .body(new DocumentConversionErrorResponse( + "DOCUMENT_CONVERSION_FILE_REQUIRED", + "请上传 Excel 文件。")); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/service/DocumentConversionException.java b/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/service/DocumentConversionException.java new file mode 100644 index 0000000..bc6c050 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/service/DocumentConversionException.java @@ -0,0 +1,38 @@ +package cn.nianxx.thhotel.platform.documentconversion.service; + +import org.springframework.http.HttpStatus; + +/** + * 文件转换受控异常。HTTP 层只返回错误码和安全摘要,不暴露文件内容、临时路径或外部命令细节。 + */ +public class DocumentConversionException extends RuntimeException { + + private final HttpStatus status; + private final String errorCode; + + /** + * 创建文件转换受控异常。 + */ + public DocumentConversionException(HttpStatus status, String errorCode, String message) { + super(message); + this.status = status; + this.errorCode = errorCode; + } + + /** + * 创建带内部原因的文件转换受控异常。 + */ + public DocumentConversionException(HttpStatus status, String errorCode, String message, Throwable cause) { + super(message, cause); + this.status = status; + this.errorCode = errorCode; + } + + public HttpStatus getStatus() { + return status; + } + + public String getErrorCode() { + return errorCode; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/service/DocumentConversionService.java b/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/service/DocumentConversionService.java new file mode 100644 index 0000000..454348b --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/service/DocumentConversionService.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.platform.documentconversion.service; + +import cn.nianxx.thhotel.platform.documentconversion.common.result.ExcelToPdfConversionResult; +import org.springframework.web.multipart.MultipartFile; + +/** + * 平台文件转换服务。对 Controller 提供受控上传转换能力,不暴露 LibreOffice 或 OSS SDK 细节。 + */ +public interface DocumentConversionService { + + /** + * 上传 Excel 并转换为 PDF 后上传 OSS。调用方必须提供受控访问口令。 + */ + ExcelToPdfConversionResult convertExcelToPdf(String accessKey, MultipartFile file, String hotelId); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/service/ExcelToPdfConverter.java b/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/service/ExcelToPdfConverter.java new file mode 100644 index 0000000..27e2a28 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/service/ExcelToPdfConverter.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.platform.documentconversion.service; + +import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConversionInput; +import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConvertedDocument; + +/** + * Excel 转 PDF 转换端口。平台服务依赖该端口,具体实现可由 LibreOffice 或其他引擎提供。 + */ +public interface ExcelToPdfConverter { + + /** + * 将 Excel 字节转换为 PDF 字节;实现类负责隔离外部转换引擎和临时文件。 + */ + ExcelToPdfConvertedDocument convert(ExcelToPdfConversionInput input); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/service/impl/DocumentConversionProperties.java b/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/service/impl/DocumentConversionProperties.java new file mode 100644 index 0000000..b75c026 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/service/impl/DocumentConversionProperties.java @@ -0,0 +1,103 @@ +package cn.nianxx.thhotel.platform.documentconversion.service.impl; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 平台文件转换配置。Secret 只能通过环境变量或部署平台注入,不得写入前端。 + */ +@Component +@ConfigurationProperties(prefix = "document-conversion") +public class DocumentConversionProperties { + + /** 是否启用文件转换接口。 */ + private boolean enabled = false; + /** 手动上传转换访问口令。 */ + private String accessKey = ""; + /** LibreOffice soffice 可执行文件路径。 */ + private String sofficePath = "soffice"; + /** 文件转换临时目录根路径。 */ + private String tempDir = System.getProperty("java.io.tmpdir") + "/th-hotel-document-conversion"; + /** 单个 Excel 最大字节数。 */ + private long maxFileBytes = 20 * 1024 * 1024L; + /** 单次转换超时时间秒。 */ + private long timeoutSeconds = 60L; + /** 最大并发转换数。 */ + private int maxConcurrent = 2; + /** PDF 输出 OSS 前缀。 */ + private String outputOssPrefix = "document-conversions/excel-to-pdf/"; + /** 自动转换 worker 开关,CP2 暂不使用,预留给后续邮件附件自动转换。 */ + private boolean workerEnabled = false; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getAccessKey() { + return accessKey; + } + + public void setAccessKey(String accessKey) { + this.accessKey = accessKey; + } + + public String getSofficePath() { + return sofficePath; + } + + public void setSofficePath(String sofficePath) { + this.sofficePath = sofficePath; + } + + public String getTempDir() { + return tempDir; + } + + public void setTempDir(String tempDir) { + this.tempDir = tempDir; + } + + public long getMaxFileBytes() { + return maxFileBytes; + } + + public void setMaxFileBytes(long maxFileBytes) { + this.maxFileBytes = maxFileBytes; + } + + public long getTimeoutSeconds() { + return timeoutSeconds; + } + + public void setTimeoutSeconds(long timeoutSeconds) { + this.timeoutSeconds = timeoutSeconds; + } + + public int getMaxConcurrent() { + return maxConcurrent; + } + + public void setMaxConcurrent(int maxConcurrent) { + this.maxConcurrent = maxConcurrent; + } + + public String getOutputOssPrefix() { + return outputOssPrefix; + } + + public void setOutputOssPrefix(String outputOssPrefix) { + this.outputOssPrefix = outputOssPrefix; + } + + public boolean isWorkerEnabled() { + return workerEnabled; + } + + public void setWorkerEnabled(boolean workerEnabled) { + this.workerEnabled = workerEnabled; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/service/impl/DocumentConversionServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/service/impl/DocumentConversionServiceImpl.java new file mode 100644 index 0000000..95195cc --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/service/impl/DocumentConversionServiceImpl.java @@ -0,0 +1,279 @@ +package cn.nianxx.thhotel.platform.documentconversion.service.impl; + +import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.request.ObjectStoragePutRequest; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result.ObjectStoragePutResult; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.ObjectStorageService; +import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConversionInput; +import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConvertedDocument; +import cn.nianxx.thhotel.platform.documentconversion.common.enums.DocumentConversionStatus; +import cn.nianxx.thhotel.platform.documentconversion.common.result.ExcelToPdfConversionResult; +import cn.nianxx.thhotel.platform.documentconversion.service.DocumentConversionException; +import cn.nianxx.thhotel.platform.documentconversion.service.DocumentConversionService; +import cn.nianxx.thhotel.platform.documentconversion.service.ExcelToPdfConverter; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.Semaphore; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +/** + * 平台文件转换服务实现。负责访问口令、文件校验、转换编排、OSS 输出和安全错误转换。 + */ +@Service +public class DocumentConversionServiceImpl implements DocumentConversionService { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE; + + private final DocumentConversionProperties properties; + private final ExcelToPdfConverter excelToPdfConverter; + private final ObjectStorageService objectStorageService; + private final Semaphore conversionSemaphore; + + /** + * 注入转换配置、Excel 转 PDF 端口和 OSS 上传端口。 + */ + public DocumentConversionServiceImpl( + DocumentConversionProperties properties, + ExcelToPdfConverter excelToPdfConverter, + ObjectStorageService objectStorageService) { + this.properties = properties; + this.excelToPdfConverter = excelToPdfConverter; + this.objectStorageService = objectStorageService; + this.conversionSemaphore = new Semaphore(Math.max(1, properties.getMaxConcurrent())); + } + + /** + * 上传 Excel 并转换为 PDF 后上传 OSS;当前 CP2 不落库,只返回本次转换结果。 + */ + @Override + public ExcelToPdfConversionResult convertExcelToPdf(String accessKey, MultipartFile file, String hotelId) { + validateEnabled(); + validateAccessKey(accessKey); + validateFile(file); + String sourceFileName = safeFileName(file.getOriginalFilename(), "source.xlsx"); + acquirePermit(); + try { + byte[] excelBytes = readFileBytes(file); + validateFileSignature(sourceFileName, excelBytes); + ExcelToPdfConvertedDocument converted = excelToPdfConverter.convert(new ExcelToPdfConversionInput( + sourceFileName, + file.getContentType(), + file.getSize(), + excelBytes)); + validateConvertedDocument(converted); + ObjectStoragePutResult putResult = uploadPdf(hotelId, sourceFileName, converted); + return new ExcelToPdfConversionResult( + DocumentConversionStatus.SUCCEEDED.name(), + sourceFileName, + file.getSize(), + converted.fileName(), + putResult.publicUrl(), + putResult.objectKey(), + putResult.contentType(), + putResult.sizeBytes(), + converted.durationMillis(), + null); + } finally { + conversionSemaphore.release(); + } + } + + /** + * 校验文件转换总开关。 + */ + private void validateEnabled() { + if (!properties.isEnabled()) { + throw new DocumentConversionException( + HttpStatus.NOT_FOUND, + "DOCUMENT_CONVERSION_DISABLED", + "文件转换接口未启用。"); + } + } + + /** + * 校验手动上传转换访问口令。 + */ + private void validateAccessKey(String accessKey) { + if (properties.getAccessKey() == null || properties.getAccessKey().isBlank() + || accessKey == null || !properties.getAccessKey().equals(accessKey)) { + throw new DocumentConversionException( + HttpStatus.UNAUTHORIZED, + "DOCUMENT_CONVERSION_KEY_INVALID", + "文件转换访问口令缺失或错误。"); + } + } + + /** + * 校验上传文件类型、大小和扩展名。 + */ + private void validateFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new DocumentConversionException( + HttpStatus.BAD_REQUEST, + "DOCUMENT_CONVERSION_FILE_REQUIRED", + "请上传 Excel 文件。"); + } + if (file.getSize() > properties.getMaxFileBytes()) { + throw new DocumentConversionException( + HttpStatus.PAYLOAD_TOO_LARGE, + "DOCUMENT_CONVERSION_FILE_TOO_LARGE", + "上传的 Excel 文件超过大小限制。"); + } + String fileName = safeFileName(file.getOriginalFilename(), ""); + String lowerName = fileName.toLowerCase(Locale.ROOT); + if (!lowerName.endsWith(".xlsx") && !lowerName.endsWith(".xls")) { + throw new DocumentConversionException( + HttpStatus.BAD_REQUEST, + "DOCUMENT_CONVERSION_FILE_TYPE_UNSUPPORTED", + "只支持上传 .xls 或 .xlsx 文件。"); + } + } + + /** + * 读取上传文件字节。 + */ + private byte[] readFileBytes(MultipartFile file) { + try { + return file.getBytes(); + } catch (Exception exception) { + throw new DocumentConversionException( + HttpStatus.BAD_REQUEST, + "DOCUMENT_CONVERSION_FILE_READ_FAILED", + "读取上传 Excel 文件失败。", + exception); + } + } + + /** + * 校验 Excel 文件头,避免只改扩展名的任意内容进入 LibreOffice 解析器。 + */ + private void validateFileSignature(String sourceFileName, byte[] content) { + String lowerName = sourceFileName.toLowerCase(Locale.ROOT); + boolean valid; + if (lowerName.endsWith(".xlsx")) { + valid = content.length >= 4 && content[0] == 0x50 && content[1] == 0x4B; + } else { + valid = content.length >= 8 + && (content[0] & 0xFF) == 0xD0 + && (content[1] & 0xFF) == 0xCF + && (content[2] & 0xFF) == 0x11 + && (content[3] & 0xFF) == 0xE0 + && (content[4] & 0xFF) == 0xA1 + && (content[5] & 0xFF) == 0xB1 + && (content[6] & 0xFF) == 0x1A + && (content[7] & 0xFF) == 0xE1; + } + if (!valid) { + throw new DocumentConversionException( + HttpStatus.BAD_REQUEST, + "DOCUMENT_CONVERSION_FILE_CONTENT_INVALID", + "上传文件内容不是有效的 Excel 文件。"); + } + } + + /** + * 获取转换并发许可,避免大量文件同时触发 LibreOffice 进程。 + */ + private void acquirePermit() { + if (!conversionSemaphore.tryAcquire()) { + throw new DocumentConversionException( + HttpStatus.TOO_MANY_REQUESTS, + "DOCUMENT_CONVERSION_BUSY", + "文件转换任务繁忙,请稍后重试。"); + } + } + + /** + * 校验转换器返回的 PDF 内容。 + */ + private void validateConvertedDocument(ExcelToPdfConvertedDocument converted) { + if (converted == null || converted.content() == null || converted.content().length == 0) { + throw new DocumentConversionException( + HttpStatus.BAD_GATEWAY, + "DOCUMENT_CONVERSION_FAILED", + "Excel 转 PDF 未生成有效文件。"); + } + } + + /** + * 上传 PDF 到 OSS,并把 OSS 失败转换为安全错误。 + */ + private ObjectStoragePutResult uploadPdf( + String hotelId, + String sourceFileName, + ExcelToPdfConvertedDocument converted) { + String pdfFileName = safeFileName(converted.fileName(), pdfFileName(sourceFileName)); + String objectKey = objectKey(hotelId, pdfFileName); + try { + return objectStorageService.putObject(new ObjectStoragePutRequest( + objectKey, + pdfFileName, + MediaType.APPLICATION_PDF_VALUE, + (long) converted.content().length, + converted.content())); + } catch (RuntimeException exception) { + throw new DocumentConversionException( + HttpStatus.BAD_GATEWAY, + "DOCUMENT_CONVERSION_OSS_UPLOAD_FAILED", + "PDF 上传 OSS 失败。", + exception); + } + } + + /** + * 生成 PDF OSS 对象路径。 + */ + private String objectKey(String hotelId, String pdfFileName) { + String prefix = properties.getOutputOssPrefix(); + String normalizedPrefix = prefix == null || prefix.isBlank() + ? "document-conversions/excel-to-pdf/" + : prefix; + if (!normalizedPrefix.endsWith("/")) { + normalizedPrefix = normalizedPrefix + "/"; + } + String normalizedHotelId = safePathPart(hotelId == null || hotelId.isBlank() ? "UNKNOWN-HOTEL" : hotelId); + String today = DATE_FORMATTER.format(LocalDate.now(ZoneOffset.UTC)); + return normalizedPrefix + normalizedHotelId + "/" + today + "/" + UUID.randomUUID() + "/" + pdfFileName; + } + + /** + * 根据 Excel 文件名生成 PDF 文件名。 + */ + private String pdfFileName(String sourceFileName) { + String baseName = sourceFileName; + int dotIndex = baseName.lastIndexOf('.'); + if (dotIndex > 0) { + baseName = baseName.substring(0, dotIndex); + } + return baseName + ".pdf"; + } + + /** + * 文件名安全清洗,避免路径穿越和日志污染。 + */ + private String safeFileName(String fileName, String fallback) { + String candidate = fileName == null || fileName.isBlank() ? fallback : fileName; + int slashIndex = Math.max(candidate.lastIndexOf('/'), candidate.lastIndexOf('\\')); + if (slashIndex >= 0) { + candidate = candidate.substring(slashIndex + 1); + } + String sanitized = candidate.replaceAll("[^A-Za-z0-9._-]", "_"); + if (sanitized.isBlank()) { + sanitized = fallback; + } + return sanitized.length() > 160 ? sanitized.substring(sanitized.length() - 160) : sanitized; + } + + /** + * OSS 路径片段安全清洗。 + */ + private String safePathPart(String value) { + String sanitized = value.replaceAll("[^A-Za-z0-9._-]", "_"); + return sanitized.isBlank() ? "UNKNOWN" : sanitized; + } +} diff --git a/server/src/main/resources/application-dev.yml b/server/src/main/resources/application-dev.yml index 980418a..2cc9395 100644 --- a/server/src/main/resources/application-dev.yml +++ b/server/src/main/resources/application-dev.yml @@ -9,14 +9,9 @@ spring: enabled: true servlet: multipart: - # dev multipart 上限高于业务上限,超限文件由 Debug EML 服务层返回受控 JSON 错误。 - max-file-size: ${DEBUG_EML_UPLOAD_DEV_MULTIPART_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_FILE_BYTES:20971520}} - max-request-size: ${DEBUG_EML_UPLOAD_DEV_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_DEV_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:25165824}}}} - -source-message: - original-read: - # dev 邮件原文读取访问密钥;优先使用 dev 专属变量,兼容旧通用变量。 - access-key: ${SOURCE_MESSAGE_DEV_ORIGINAL_READ_ACCESS_KEY:${SOURCE_MESSAGE_ORIGINAL_READ_ACCESS_KEY:}} + # dev multipart 上限高于 Debug EML / 文件转换业务上限,超限文件由服务层返回受控 JSON 错误。 + max-file-size: ${DOCUMENT_CONVERSION_DEV_MULTIPART_MAX_FILE_BYTES:${DOCUMENT_CONVERSION_MULTIPART_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_DEV_MULTIPART_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_FILE_BYTES:25165824}}}} + max-request-size: ${DOCUMENT_CONVERSION_DEV_MULTIPART_MAX_REQUEST_BYTES:${DOCUMENT_CONVERSION_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_DEV_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_DEV_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:29360128}}}}} agentbus: probe: @@ -73,6 +68,18 @@ debug: max-file-bytes: ${DEBUG_EML_UPLOAD_DEV_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MAX_FILE_BYTES:10485760}} sse-heartbeat-interval: ${DEBUG_EML_UPLOAD_DEV_SSE_HEARTBEAT_INTERVAL:${DEBUG_EML_UPLOAD_SSE_HEARTBEAT_INTERVAL:15s}} +document-conversion: + # dev 默认关闭;本地安装 LibreOffice 和字体后再显式开启。 + enabled: ${DOCUMENT_CONVERSION_DEV_ENABLED:${DOCUMENT_CONVERSION_ENABLED:false}} + access-key: ${DOCUMENT_CONVERSION_DEV_ACCESS_KEY:${DOCUMENT_CONVERSION_ACCESS_KEY:}} + soffice-path: ${DOCUMENT_CONVERSION_DEV_SOFFICE_PATH:${DOCUMENT_CONVERSION_SOFFICE_PATH:soffice}} + temp-dir: ${DOCUMENT_CONVERSION_DEV_TEMP_DIR:${DOCUMENT_CONVERSION_TEMP_DIR:${java.io.tmpdir}/th-hotel-document-conversion}} + max-file-bytes: ${DOCUMENT_CONVERSION_DEV_MAX_FILE_BYTES:${DOCUMENT_CONVERSION_MAX_FILE_BYTES:20971520}} + timeout-seconds: ${DOCUMENT_CONVERSION_DEV_TIMEOUT_SECONDS:${DOCUMENT_CONVERSION_TIMEOUT_SECONDS:60}} + max-concurrent: ${DOCUMENT_CONVERSION_DEV_MAX_CONCURRENT:${DOCUMENT_CONVERSION_MAX_CONCURRENT:2}} + output-oss-prefix: ${DOCUMENT_CONVERSION_DEV_OUTPUT_OSS_PREFIX:${DOCUMENT_CONVERSION_OUTPUT_OSS_PREFIX:document-conversions/excel-to-pdf/}} + worker-enabled: ${DOCUMENT_CONVERSION_DEV_WORKER_ENABLED:${DOCUMENT_CONVERSION_WORKER_ENABLED:false}} + auth: bootstrap: admin: diff --git a/server/src/main/resources/application-prod.yml b/server/src/main/resources/application-prod.yml index 7809594..33aede3 100644 --- a/server/src/main/resources/application-prod.yml +++ b/server/src/main/resources/application-prod.yml @@ -9,14 +9,9 @@ spring: enabled: true servlet: multipart: - # prod Debug EML 如被显式开启,multipart 上限应高于业务上限,便于服务层返回受控错误。 - max-file-size: ${DEBUG_EML_UPLOAD_PROD_MULTIPART_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_FILE_BYTES:20971520}} - max-request-size: ${DEBUG_EML_UPLOAD_PROD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_PROD_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:25165824}}}} - -source-message: - original-read: - # prod 邮件原文读取访问密钥;只能通过生产 Secret / 环境变量注入。 - access-key: ${SOURCE_MESSAGE_PROD_ORIGINAL_READ_ACCESS_KEY:${SOURCE_MESSAGE_ORIGINAL_READ_ACCESS_KEY:}} + # prod Debug EML / 文件转换如被显式开启,multipart 上限应高于业务上限,便于服务层返回受控错误。 + max-file-size: ${DOCUMENT_CONVERSION_PROD_MULTIPART_MAX_FILE_BYTES:${DOCUMENT_CONVERSION_MULTIPART_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_PROD_MULTIPART_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_FILE_BYTES:25165824}}}} + max-request-size: ${DOCUMENT_CONVERSION_PROD_MULTIPART_MAX_REQUEST_BYTES:${DOCUMENT_CONVERSION_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_PROD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_PROD_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:29360128}}}}} agentbus: probe: @@ -71,6 +66,18 @@ debug: max-file-bytes: ${DEBUG_EML_UPLOAD_PROD_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MAX_FILE_BYTES:10485760}} sse-heartbeat-interval: ${DEBUG_EML_UPLOAD_PROD_SSE_HEARTBEAT_INTERVAL:${DEBUG_EML_UPLOAD_SSE_HEARTBEAT_INTERVAL:15s}} +document-conversion: + # prod 默认关闭;启用前必须确认 LibreOffice、字体、OSS、访问口令、监控和临时目录清理策略。 + enabled: ${DOCUMENT_CONVERSION_PROD_ENABLED:false} + access-key: ${DOCUMENT_CONVERSION_PROD_ACCESS_KEY:${DOCUMENT_CONVERSION_ACCESS_KEY}} + soffice-path: ${DOCUMENT_CONVERSION_PROD_SOFFICE_PATH:${DOCUMENT_CONVERSION_SOFFICE_PATH:soffice}} + temp-dir: ${DOCUMENT_CONVERSION_PROD_TEMP_DIR:${DOCUMENT_CONVERSION_TEMP_DIR:${java.io.tmpdir}/th-hotel-document-conversion}} + max-file-bytes: ${DOCUMENT_CONVERSION_PROD_MAX_FILE_BYTES:${DOCUMENT_CONVERSION_MAX_FILE_BYTES:20971520}} + timeout-seconds: ${DOCUMENT_CONVERSION_PROD_TIMEOUT_SECONDS:${DOCUMENT_CONVERSION_TIMEOUT_SECONDS:60}} + max-concurrent: ${DOCUMENT_CONVERSION_PROD_MAX_CONCURRENT:${DOCUMENT_CONVERSION_MAX_CONCURRENT:2}} + output-oss-prefix: ${DOCUMENT_CONVERSION_PROD_OUTPUT_OSS_PREFIX:${DOCUMENT_CONVERSION_OUTPUT_OSS_PREFIX:document-conversions/excel-to-pdf/}} + worker-enabled: ${DOCUMENT_CONVERSION_PROD_WORKER_ENABLED:${DOCUMENT_CONVERSION_WORKER_ENABLED:false}} + auth: bootstrap: admin: diff --git a/server/src/main/resources/application-test.yml b/server/src/main/resources/application-test.yml index e2aad1d..9319724 100644 --- a/server/src/main/resources/application-test.yml +++ b/server/src/main/resources/application-test.yml @@ -9,14 +9,9 @@ spring: enabled: true servlet: multipart: - # test multipart 上限高于业务上限,超限文件由 Debug EML 服务层返回受控 JSON 错误。 - max-file-size: ${DEBUG_EML_UPLOAD_TEST_MULTIPART_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_FILE_BYTES:20971520}} - max-request-size: ${DEBUG_EML_UPLOAD_TEST_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_TEST_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:25165824}}}} - -source-message: - original-read: - # test 邮件原文读取访问密钥;优先使用 test 专属变量,兼容旧通用变量。 - access-key: ${SOURCE_MESSAGE_TEST_ORIGINAL_READ_ACCESS_KEY:${SOURCE_MESSAGE_ORIGINAL_READ_ACCESS_KEY:}} + # test multipart 上限高于 Debug EML / 文件转换业务上限,超限文件由服务层返回受控 JSON 错误。 + max-file-size: ${DOCUMENT_CONVERSION_TEST_MULTIPART_MAX_FILE_BYTES:${DOCUMENT_CONVERSION_MULTIPART_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_TEST_MULTIPART_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_FILE_BYTES:25165824}}}} + max-request-size: ${DOCUMENT_CONVERSION_TEST_MULTIPART_MAX_REQUEST_BYTES:${DOCUMENT_CONVERSION_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_TEST_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_TEST_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:29360128}}}}} agentbus: probe: @@ -75,6 +70,18 @@ debug: max-file-bytes: ${DEBUG_EML_UPLOAD_TEST_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MAX_FILE_BYTES:10485760}} sse-heartbeat-interval: ${DEBUG_EML_UPLOAD_TEST_SSE_HEARTBEAT_INTERVAL:${DEBUG_EML_UPLOAD_SSE_HEARTBEAT_INTERVAL:15s}} +document-conversion: + # test 默认关闭;联调时确认 LibreOffice、字体和 OSS 后开启。 + enabled: ${DOCUMENT_CONVERSION_TEST_ENABLED:${DOCUMENT_CONVERSION_ENABLED:false}} + access-key: ${DOCUMENT_CONVERSION_TEST_ACCESS_KEY:${DOCUMENT_CONVERSION_ACCESS_KEY:}} + soffice-path: ${DOCUMENT_CONVERSION_TEST_SOFFICE_PATH:${DOCUMENT_CONVERSION_SOFFICE_PATH:soffice}} + temp-dir: ${DOCUMENT_CONVERSION_TEST_TEMP_DIR:${DOCUMENT_CONVERSION_TEMP_DIR:${java.io.tmpdir}/th-hotel-document-conversion}} + max-file-bytes: ${DOCUMENT_CONVERSION_TEST_MAX_FILE_BYTES:${DOCUMENT_CONVERSION_MAX_FILE_BYTES:20971520}} + timeout-seconds: ${DOCUMENT_CONVERSION_TEST_TIMEOUT_SECONDS:${DOCUMENT_CONVERSION_TIMEOUT_SECONDS:60}} + max-concurrent: ${DOCUMENT_CONVERSION_TEST_MAX_CONCURRENT:${DOCUMENT_CONVERSION_MAX_CONCURRENT:2}} + output-oss-prefix: ${DOCUMENT_CONVERSION_TEST_OUTPUT_OSS_PREFIX:${DOCUMENT_CONVERSION_OUTPUT_OSS_PREFIX:document-conversions/excel-to-pdf/}} + worker-enabled: ${DOCUMENT_CONVERSION_TEST_WORKER_ENABLED:${DOCUMENT_CONVERSION_WORKER_ENABLED:false}} + auth: bootstrap: admin: diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 2500556..4ab3935 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -8,9 +8,9 @@ spring: enabled: true servlet: multipart: - # multipart 需要高于 Debug EML 业务文件上限,避免超限文件在进入 Controller 前被框架直接 413 拦截。 - max-file-size: ${DEBUG_EML_UPLOAD_MULTIPART_MAX_FILE_BYTES:20971520} - max-request-size: ${DEBUG_EML_UPLOAD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:25165824}} + # multipart 需要高于 Debug EML / 文件转换业务上限,避免超限文件在进入 Controller 前被框架直接 413 拦截。 + max-file-size: ${DOCUMENT_CONVERSION_MULTIPART_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_FILE_BYTES:25165824}} + max-request-size: ${DOCUMENT_CONVERSION_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:29360128}}} mvc: async: # Spring MVC async timeout 是应用级全局值;当前主要用于避免 Debug EML SSE 先于 SuperAgent 调试调用关闭。 @@ -50,6 +50,18 @@ auth: # 登录 session 默认 12 小时;各环境可通过 AUTH_*_SESSION_TTL_MINUTES 覆盖。 ttl-minutes: ${AUTH_SESSION_TTL_MINUTES:720} +document-conversion: + # M008 Excel 转 PDF 文件转换能力,默认关闭;启用前必须确认 LibreOffice、字体、OSS 和访问口令。 + enabled: ${DOCUMENT_CONVERSION_ENABLED:false} + access-key: ${DOCUMENT_CONVERSION_ACCESS_KEY:} + soffice-path: ${DOCUMENT_CONVERSION_SOFFICE_PATH:soffice} + temp-dir: ${DOCUMENT_CONVERSION_TEMP_DIR:${java.io.tmpdir}/th-hotel-document-conversion} + max-file-bytes: ${DOCUMENT_CONVERSION_MAX_FILE_BYTES:20971520} + timeout-seconds: ${DOCUMENT_CONVERSION_TIMEOUT_SECONDS:60} + max-concurrent: ${DOCUMENT_CONVERSION_MAX_CONCURRENT:2} + output-oss-prefix: ${DOCUMENT_CONVERSION_OUTPUT_OSS_PREFIX:document-conversions/excel-to-pdf/} + worker-enabled: ${DOCUMENT_CONVERSION_WORKER_ENABLED:false} + mcp: # SuperAgent MCP 默认关闭;启用时必须通过部署环境配置高熵 Bearer Token。 enabled: ${MCP_ENABLED:false} diff --git a/server/src/test/java/cn/nianxx/thhotel/integrations/document/libreoffice/adapter/LibreOfficeExcelToPdfAdapterTest.java b/server/src/test/java/cn/nianxx/thhotel/integrations/document/libreoffice/adapter/LibreOfficeExcelToPdfAdapterTest.java new file mode 100644 index 0000000..68aba14 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/integrations/document/libreoffice/adapter/LibreOfficeExcelToPdfAdapterTest.java @@ -0,0 +1,128 @@ +package cn.nianxx.thhotel.integrations.document.libreoffice.adapter; + +import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConversionInput; +import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConvertedDocument; +import cn.nianxx.thhotel.platform.documentconversion.service.DocumentConversionException; +import cn.nianxx.thhotel.platform.documentconversion.service.impl.DocumentConversionProperties; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.http.HttpStatus; + +class LibreOfficeExcelToPdfAdapterTest { + + @TempDir + private Path tempDir; + + @Test + void shouldInvokeLibreOfficeHeadlessAndCleanTemporaryFiles() throws Exception { + DocumentConversionProperties properties = properties(); + AtomicReference processRequestRef = new AtomicReference<>(); + LibreOfficeProcessRunner processRunner = request -> { + processRequestRef.set(request); + org.assertj.core.api.Assertions.assertThat(request.command()) + .contains("soffice-test") + .contains("--headless") + .contains("--convert-to") + .contains("pdf"); + org.assertj.core.api.Assertions.assertThat(Files.exists(request.inputFile())).isTrue(); + writePdf(request.outputDirectory().resolve("booking-request.pdf")); + return new LibreOfficeProcessResult(0, false, "convert ok", ""); + }; + LibreOfficeExcelToPdfAdapter adapter = new LibreOfficeExcelToPdfAdapter(properties, processRunner); + + ExcelToPdfConvertedDocument result = adapter.convert(new ExcelToPdfConversionInput( + "booking-request.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + 10L, + "xlsx-bytes".getBytes(StandardCharsets.UTF_8))); + + org.assertj.core.api.Assertions.assertThat(result.fileName()).isEqualTo("booking-request.pdf"); + org.assertj.core.api.Assertions.assertThat(result.contentType()).isEqualTo("application/pdf"); + org.assertj.core.api.Assertions.assertThat(result.content()).startsWith("%PDF".getBytes(StandardCharsets.UTF_8)); + org.assertj.core.api.Assertions.assertThat(processRequestRef.get().profileDirectory().toString()) + .contains("lo-profile"); + org.assertj.core.api.Assertions.assertThat(processRequestRef.get().workingDirectory()) + .isEqualTo(processRequestRef.get().inputFile().getParent()); + try (var children = Files.list(tempDir)) { + org.assertj.core.api.Assertions.assertThat(children).isEmpty(); + } + } + + @Test + void shouldReturnTimeoutErrorAndCleanTemporaryFiles() { + DocumentConversionProperties properties = properties(); + LibreOfficeProcessRunner processRunner = request -> new LibreOfficeProcessResult( + -1, + true, + "", + "timeout"); + LibreOfficeExcelToPdfAdapter adapter = new LibreOfficeExcelToPdfAdapter(properties, processRunner); + + org.assertj.core.api.Assertions.assertThatThrownBy(() -> adapter.convert(new ExcelToPdfConversionInput( + "booking-request.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + 10L, + "xlsx-bytes".getBytes(StandardCharsets.UTF_8)))) + .isInstanceOf(DocumentConversionException.class) + .satisfies(exception -> { + DocumentConversionException conversionException = (DocumentConversionException) exception; + org.assertj.core.api.Assertions.assertThat(conversionException.getStatus()) + .isEqualTo(HttpStatus.GATEWAY_TIMEOUT); + org.assertj.core.api.Assertions.assertThat(conversionException.getErrorCode()) + .isEqualTo("DOCUMENT_CONVERSION_TIMEOUT"); + }); + assertTempDirEmpty(); + } + + @Test + void shouldReturnFailedErrorWhenLibreOfficeDoesNotProducePdf() { + DocumentConversionProperties properties = properties(); + LibreOfficeProcessRunner processRunner = request -> new LibreOfficeProcessResult(0, false, "ok", ""); + LibreOfficeExcelToPdfAdapter adapter = new LibreOfficeExcelToPdfAdapter(properties, processRunner); + + org.assertj.core.api.Assertions.assertThatThrownBy(() -> adapter.convert(new ExcelToPdfConversionInput( + "booking-request.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + 10L, + "xlsx-bytes".getBytes(StandardCharsets.UTF_8)))) + .isInstanceOf(DocumentConversionException.class) + .satisfies(exception -> { + DocumentConversionException conversionException = (DocumentConversionException) exception; + org.assertj.core.api.Assertions.assertThat(conversionException.getStatus()) + .isEqualTo(HttpStatus.BAD_GATEWAY); + org.assertj.core.api.Assertions.assertThat(conversionException.getErrorCode()) + .isEqualTo("DOCUMENT_CONVERSION_FAILED"); + }); + assertTempDirEmpty(); + } + + private DocumentConversionProperties properties() { + DocumentConversionProperties properties = new DocumentConversionProperties(); + properties.setEnabled(true); + properties.setSofficePath("soffice-test"); + properties.setTempDir(tempDir.toString()); + properties.setTimeoutSeconds(3); + properties.setOutputOssPrefix("document-conversions/excel-to-pdf/"); + return properties; + } + + private void writePdf(Path pdfFile) { + try { + Files.write(pdfFile, "%PDF-1.7\nconverted".getBytes(StandardCharsets.UTF_8)); + } catch (Exception exception) { + throw new AssertionError("写入模拟 PDF 失败。", exception); + } + } + + private void assertTempDirEmpty() { + try (var children = Files.list(tempDir)) { + org.assertj.core.api.Assertions.assertThat(children).isEmpty(); + } catch (Exception exception) { + throw new AssertionError("临时目录检查失败。", exception); + } + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/documentconversion/control/DocumentConversionControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/documentconversion/control/DocumentConversionControllerTest.java new file mode 100644 index 0000000..859cea3 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/platform/documentconversion/control/DocumentConversionControllerTest.java @@ -0,0 +1,195 @@ +package cn.nianxx.thhotel.platform.documentconversion.control; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +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.integrations.storage.aliyunoss.common.request.ObjectStoragePutRequest; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result.ObjectStoragePutResult; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.ObjectStorageService; +import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConversionInput; +import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConvertedDocument; +import cn.nianxx.thhotel.platform.documentconversion.service.ExcelToPdfConverter; +import cn.nianxx.thhotel.platform.documentconversion.service.DocumentConversionException; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +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.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest( + classes = ThHotelApplication.class, + properties = { + "document-conversion.enabled=true", + "document-conversion.access-key=test-document-key", + "document-conversion.max-file-bytes=1024", + "document-conversion.output-oss-prefix=document-conversions/excel-to-pdf/" + }) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class DocumentConversionControllerTest { + + private static final String ENDPOINT = "/api/system/document-conversions/excel-to-pdf"; + + @Autowired + private MockMvc mockMvc; + + @MockBean + private ExcelToPdfConverter excelToPdfConverter; + + @MockBean + private ObjectStorageService objectStorageService; + + @Test + void shouldConvertUploadedExcelToPdfAndUploadPdfToOss() throws Exception { + byte[] excelBytes = xlsxBytes("xlsx-bytes"); + byte[] pdfBytes = "%PDF-1.7\nconverted".getBytes(StandardCharsets.UTF_8); + when(excelToPdfConverter.convert(any())).thenReturn(new ExcelToPdfConvertedDocument( + "booking-request.pdf", + MediaType.APPLICATION_PDF_VALUE, + (long) pdfBytes.length, + pdfBytes, + 123L)); + when(objectStorageService.putObject(any())).thenAnswer(invocation -> { + ObjectStoragePutRequest request = invocation.getArgument(0); + return new ObjectStoragePutResult( + request.objectKey(), + "https://oss.example.test/" + request.objectKey(), + request.contentType(), + request.sizeBytes()); + }); + + mockMvc.perform(multipart(ENDPOINT) + .file(excelFile("booking-request.xlsx", excelBytes)) + .param("hotel_id", "HOTEL-TEST") + .header("X-TH-Hotel-Document-Conversion-Key", "test-document-key")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.conversion_status").value("SUCCEEDED")) + .andExpect(jsonPath("$.source_file_name").value("booking-request.xlsx")) + .andExpect(jsonPath("$.pdf_file_name").value("booking-request.pdf")) + .andExpect(jsonPath("$.pdf_url", containsString("https://oss.example.test/"))) + .andExpect(jsonPath("$.object_key", containsString("document-conversions/excel-to-pdf/"))) + .andExpect(jsonPath("$.content_type").value(MediaType.APPLICATION_PDF_VALUE)) + .andExpect(jsonPath("$.pdf_size_bytes").value(pdfBytes.length)) + .andExpect(jsonPath("$.safe_error_summary").doesNotExist()) + .andExpect(content().string(not(containsString("test-document-key")))); + + ArgumentCaptor converterInputCaptor = + ArgumentCaptor.forClass(ExcelToPdfConversionInput.class); + verify(excelToPdfConverter).convert(converterInputCaptor.capture()); + org.assertj.core.api.Assertions.assertThat(converterInputCaptor.getValue().fileName()) + .isEqualTo("booking-request.xlsx"); + org.assertj.core.api.Assertions.assertThat(converterInputCaptor.getValue().content()) + .containsExactly(excelBytes); + + ArgumentCaptor storageRequestCaptor = + ArgumentCaptor.forClass(ObjectStoragePutRequest.class); + verify(objectStorageService).putObject(storageRequestCaptor.capture()); + org.assertj.core.api.Assertions.assertThat(storageRequestCaptor.getValue().objectKey()) + .startsWith("document-conversions/excel-to-pdf/HOTEL-TEST/") + .endsWith("/booking-request.pdf"); + org.assertj.core.api.Assertions.assertThat(storageRequestCaptor.getValue().contentType()) + .isEqualTo(MediaType.APPLICATION_PDF_VALUE); + org.assertj.core.api.Assertions.assertThat(storageRequestCaptor.getValue().content()) + .containsExactly(pdfBytes); + } + + @Test + void shouldRejectUploadWhenDocumentConversionKeyMissing() throws Exception { + mockMvc.perform(multipart(ENDPOINT) + .file(excelFile("booking-request.xlsx", xlsxBytes("xlsx-bytes")))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error_code").value("DOCUMENT_CONVERSION_KEY_INVALID")) + .andExpect(content().string(not(containsString("test-document-key")))); + + verifyNoInteractions(excelToPdfConverter, objectStorageService); + } + + @Test + void shouldRejectUnsupportedExcelFileType() throws Exception { + mockMvc.perform(multipart(ENDPOINT) + .file(excelFile("booking-request.txt", "not-excel".getBytes(StandardCharsets.UTF_8))) + .header("X-TH-Hotel-Document-Conversion-Key", "test-document-key")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("DOCUMENT_CONVERSION_FILE_TYPE_UNSUPPORTED")); + + verifyNoInteractions(excelToPdfConverter, objectStorageService); + } + + @Test + void shouldRejectExcelExtensionWhenContentHeaderInvalid() throws Exception { + mockMvc.perform(multipart(ENDPOINT) + .file(excelFile("booking-request.xlsx", "not-excel".getBytes(StandardCharsets.UTF_8))) + .header("X-TH-Hotel-Document-Conversion-Key", "test-document-key")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("DOCUMENT_CONVERSION_FILE_CONTENT_INVALID")); + + verifyNoInteractions(excelToPdfConverter, objectStorageService); + } + + @Test + void shouldRejectFileLargerThanConfiguredLimit() throws Exception { + mockMvc.perform(multipart(ENDPOINT) + .file(excelFile("booking-request.xlsx", "x".repeat(2048).getBytes(StandardCharsets.UTF_8))) + .header("X-TH-Hotel-Document-Conversion-Key", "test-document-key")) + .andExpect(status().isPayloadTooLarge()) + .andExpect(jsonPath("$.error_code").value("DOCUMENT_CONVERSION_FILE_TOO_LARGE")); + + verifyNoInteractions(excelToPdfConverter, objectStorageService); + } + + @Test + void shouldReturnSafeErrorWhenLibreOfficeConversionFails() throws Exception { + when(excelToPdfConverter.convert(any())).thenThrow(new DocumentConversionException( + HttpStatus.GATEWAY_TIMEOUT, + "DOCUMENT_CONVERSION_TIMEOUT", + "Excel 转 PDF 超时。")); + + mockMvc.perform(multipart(ENDPOINT) + .file(excelFile("booking-request.xlsx", xlsxBytes("xlsx-bytes"))) + .header("X-TH-Hotel-Document-Conversion-Key", "test-document-key")) + .andExpect(status().isGatewayTimeout()) + .andExpect(jsonPath("$.error_code").value("DOCUMENT_CONVERSION_TIMEOUT")) + .andExpect(jsonPath("$.message").value("Excel 转 PDF 超时。")) + .andExpect(content().string(not(containsString("xlsx-bytes")))); + + verifyNoInteractions(objectStorageService); + } + + private MockMultipartFile excelFile(String fileName, byte[] content) { + return new MockMultipartFile( + "file", + fileName, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + content); + } + + private byte[] xlsxBytes(String content) { + try { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + outputStream.write(new byte[]{0x50, 0x4B, 0x03, 0x04}); + outputStream.write(content.getBytes(StandardCharsets.UTF_8)); + return outputStream.toByteArray(); + } catch (IOException exception) { + throw new AssertionError("构造测试 xlsx 字节失败。", exception); + } + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/documentconversion/control/DocumentConversionDisabledControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/documentconversion/control/DocumentConversionDisabledControllerTest.java new file mode 100644 index 0000000..fc951c6 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/platform/documentconversion/control/DocumentConversionDisabledControllerTest.java @@ -0,0 +1,56 @@ +package cn.nianxx.thhotel.platform.documentconversion.control; + +import static org.mockito.Mockito.verifyNoInteractions; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +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.integrations.storage.aliyunoss.service.ObjectStorageService; +import cn.nianxx.thhotel.platform.documentconversion.service.ExcelToPdfConverter; +import java.nio.charset.StandardCharsets; +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.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest( + classes = ThHotelApplication.class, + properties = { + "document-conversion.enabled=false", + "document-conversion.access-key=test-document-key" + }) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class DocumentConversionDisabledControllerTest { + + private static final String ENDPOINT = "/api/system/document-conversions/excel-to-pdf"; + + @Autowired + private MockMvc mockMvc; + + @MockBean + private ExcelToPdfConverter excelToPdfConverter; + + @MockBean + private ObjectStorageService objectStorageService; + + @Test + void shouldRejectUploadWhenDocumentConversionDisabled() throws Exception { + mockMvc.perform(multipart(ENDPOINT) + .file(new MockMultipartFile( + "file", + "booking-request.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "xlsx-bytes".getBytes(StandardCharsets.UTF_8))) + .header("X-TH-Hotel-Document-Conversion-Key", "test-document-key")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error_code").value("DOCUMENT_CONVERSION_DISABLED")); + + verifyNoInteractions(excelToPdfConverter, objectStorageService); + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/documentconversion/service/impl/DocumentConversionServiceImplTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/documentconversion/service/impl/DocumentConversionServiceImplTest.java new file mode 100644 index 0000000..7d04430 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/platform/documentconversion/service/impl/DocumentConversionServiceImplTest.java @@ -0,0 +1,105 @@ +package cn.nianxx.thhotel.platform.documentconversion.service.impl; + +import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.request.ObjectStoragePutRequest; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result.ObjectStoragePutResult; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.ObjectStorageService; +import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConvertedDocument; +import cn.nianxx.thhotel.platform.documentconversion.service.DocumentConversionException; +import cn.nianxx.thhotel.platform.documentconversion.service.DocumentConversionService; +import cn.nianxx.thhotel.platform.documentconversion.service.ExcelToPdfConverter; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; + +class DocumentConversionServiceImplTest { + + @Test + void shouldRejectSecondConversionWhenConcurrencyLimitReachedBeforeReadingBytes() throws Exception { + byte[] pdfBytes = "%PDF-1.7".getBytes(StandardCharsets.UTF_8); + CountDownLatch converterEntered = new CountDownLatch(1); + CountDownLatch releaseConverter = new CountDownLatch(1); + ExcelToPdfConverter converter = input -> { + converterEntered.countDown(); + await(releaseConverter); + return new ExcelToPdfConvertedDocument( + "booking-request.pdf", + MediaType.APPLICATION_PDF_VALUE, + (long) pdfBytes.length, + pdfBytes, + 10L); + }; + ObjectStorageService storageService = request -> putResult(request); + DocumentConversionService service = new DocumentConversionServiceImpl(properties(), converter, storageService); + + Thread firstThread = new Thread(() -> service.convertExcelToPdf( + "test-document-key", + excelFile("booking-request.xlsx"), + "HOTEL-TEST")); + firstThread.start(); + org.assertj.core.api.Assertions.assertThat(converterEntered.await(2, TimeUnit.SECONDS)).isTrue(); + + org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.convertExcelToPdf( + "test-document-key", + excelFile("second-request.xlsx"), + "HOTEL-TEST")) + .isInstanceOf(DocumentConversionException.class) + .satisfies(exception -> { + DocumentConversionException conversionException = (DocumentConversionException) exception; + org.assertj.core.api.Assertions.assertThat(conversionException.getErrorCode()) + .isEqualTo("DOCUMENT_CONVERSION_BUSY"); + }); + + releaseConverter.countDown(); + firstThread.join(2000); + } + + private DocumentConversionProperties properties() { + DocumentConversionProperties properties = new DocumentConversionProperties(); + properties.setEnabled(true); + properties.setAccessKey("test-document-key"); + properties.setMaxConcurrent(1); + properties.setMaxFileBytes(1024); + properties.setOutputOssPrefix("document-conversions/excel-to-pdf/"); + return properties; + } + + private MockMultipartFile excelFile(String fileName) { + return new MockMultipartFile( + "file", + fileName, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + xlsxBytes(fileName)); + } + + private byte[] xlsxBytes(String content) { + try { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + outputStream.write(new byte[]{0x50, 0x4B, 0x03, 0x04}); + outputStream.write(content.getBytes(StandardCharsets.UTF_8)); + return outputStream.toByteArray(); + } catch (Exception exception) { + throw new AssertionError("构造测试 xlsx 字节失败。", exception); + } + } + + private ObjectStoragePutResult putResult(ObjectStoragePutRequest request) { + return new ObjectStoragePutResult( + request.objectKey(), + "https://oss.example.test/" + request.objectKey(), + request.contentType(), + request.sizeBytes()); + } + + private void await(CountDownLatch latch) { + try { + org.assertj.core.api.Assertions.assertThat(latch.await(2, TimeUnit.SECONDS)).isTrue(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new AssertionError("等待转换器释放失败。", exception); + } + } +}