diff --git a/AGENTS.md b/AGENTS.md index b106a11..03dd9e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,15 +5,21 @@ 本文件是当前项目的协作入口,供开发者和 coding agent 在动手前阅读。 - 可复用迁移规范统一位于 `docs/import/reusable/`。 +- AI-NSES 通用标准位于 `docs/import/reusable/ai-native-software-engineering-standard.md`。 +- AI-NSES 模板目录位于 `docs/import/reusable/ai-native-templates/`。 - 通用可复用规范位于 `docs/import/reusable/general-development-guidelines.md`。 - 前端细则参考 `docs/import/reusable/frontend-development-guidelines.md`。 - 后端细则参考 `docs/import/reusable/backend-development-guidelines.md`。 - 后端基础结构、ID、审计字段、分页和 Mapper 规范参考 `docs/import/reusable/backend-base-structure-pagination-guidelines.md`。 - Java 代码规范参考 `docs/import/reusable/alibaba-java-coding-guidelines-summary.md`。 - SuperAgent 与 AgentBus 可移植集成经验参考 `docs/import/reusable/integrations/superagent-agentbus-portable-integration-guide.md`。 +- 项目长期上下文入口位于 `CONTEXT.md`。 +- 当前项目状态入口位于 `PROJECT_STATE.md`。 - 当前项目专属文档总索引位于 `docs/project/README.md`。 +- 当前项目 AI-NSES 落地说明位于 `docs/project/ai-native-adoption.md`。 - 当前项目专属后端规范位于 `docs/project/backend-development-guidelines.md`。 - 当前项目时间设计说明位于 `docs/project/backend-time-design.md`。 +- 当前项目接口暴露、权限和审计边界位于 `docs/project/security-access-control-boundary.md`。 - 当前项目专属前端规范位于 `docs/project/frontend-development-guidelines.md`。 - 当前项目前后端协作入口位于 `docs/project/frontend-backend/README.md`。 - 当前项目 SuperAgent 与 AgentBus 接入记录位于 `docs/project/integrations/superagent-agentbus-project-integration-guide.md`。 @@ -32,6 +38,7 @@ - 遇到不确定的技术栈、目录、接口契约或数据模型,先确认再继续。 - 新项目初始化或技术栈升级前,必须检查前端、后端、构建工具、测试工具和运行时版本兼容性。 - 输出分层结构、目录树、数据模型、字段映射或接口示例时,必须补充中文说明,不能只依赖英文命名表达业务含义。 +- 完成 Feature 或 checkpoint 后,必须按 AI-NSES 检查 Domain、Architecture、Workflow、ADR、Spec、Project State 和安全边界文档是否需要更新;如果没有文档变化,明确说明 `No documentation changes required.`。 ## 3. 分支与提交 @@ -46,6 +53,8 @@ 本项目是前后端都有的全栈项目,目录应保持职责清晰: +- `CONTEXT.md`:项目长期上下文入口。 +- `PROJECT_STATE.md`:当前项目状态入口,允许高频更新。 - `client/`:前端应用。 - `server/`:后端服务。 - `mcp-server/`:SuperAgent MCP 方案入口指针;当前不单独部署 MCP 服务,运行时代码内嵌在 `server/`,对外资料包位于 `docs/project/integrations/superagent-mcp/`。 @@ -78,6 +87,7 @@ - 后端负责数据库、Secret、外部系统适配、业务规则、审计和安全脱敏。 - 接口字段使用稳定代码,不使用中文或英文显示文案做业务判断。 - 接口变更前先确认字段映射、前后端影响和测试范围。 +- 新增或修改接口时,必须同步确认调用方类型、鉴权方式、权限码、酒店隔离、敏感数据返回和审计要求,并更新 `docs/project/security-access-control-boundary.md` 及相关前后端或第三方契约文档。 ## 7. 后端架构边界 @@ -121,6 +131,7 @@ - 仓库只提交无真实值的 `.env.example`。 - 禁止提交真实酒店凭证、客户数据、Token、Cookie、API Key、数据库密码或支付信息。 - 日志、错误响应和测试夹具不得暴露 Token、Secret、原始邮件正文、附件 URL 或个人敏感信息。 +- Debug、Demo、Replay、Probe 等系统调试接口必须默认关闭或受控开放,不得因为前端页面存在就作为普通业务能力暴露。 - `.DS_Store`、构建产物、依赖目录、IDE 临时文件和本地样本目录应忽略。 ## 10. 测试与验证 @@ -135,9 +146,14 @@ Coding agent 开始任务前应先读取: 1. `AGENTS.md` -2. `README.md`,如果存在 -3. `docs/` 中与当前任务相关的文档 -4. 当前代码结构和最近 Git 状态 +2. `CONTEXT.md` +3. `PROJECT_STATE.md` +4. `README.md`,如果存在 +5. `docs/project/README.md` +6. `docs/` 中与当前任务相关的文档 +7. 当前代码结构和最近 Git 状态 + +涉及接口、权限、审计、酒店隔离或敏感数据返回的改动,开始前必须阅读 `docs/project/security-access-control-boundary.md`,完成后同步更新该文档和相关前后端或第三方契约。 改文件前要说明计划;完成后要说明改了什么、如何验证、还有哪些风险或未完成项。 diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..b9ec257 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,81 @@ +# TH Hotel Simple 项目上下文 + +## 1. 产品目标 + +TH Hotel Simple 是一个前后端分离的酒店业务协同项目。 + +当前核心目标是把来自 AgentBus、SuperAgent 和后续酒店系统的业务输入,沉淀为可追溯、可查询、可人工复核的结构化数据,为酒店预订、任务处理、邮件原文读取、外部 AI 协作和后续 PMS / OHIP 集成打基础。 + +## 2. 当前系统组成 + +| 目录 | 中文说明 | +| --- | --- | +| `client/` | 前端应用。负责页面展示、人工操作、调试入口和调用本项目后端。前端不直接访问 AgentBus、SuperAgent、OHIP、数据库或任何 Secret。 | +| `server/` | 后端 Spring Boot 服务。负责数据库、业务规则、外部系统适配、权限、安全脱敏、审计和接口契约。 | +| `mcp-server/` | SuperAgent MCP 方案入口指针。当前不单独部署 MCP 服务,MCP 运行时代码内嵌在 `server/`。 | +| `docs/` | 项目文档、需求、架构边界、导入资料、可复用规范和第三方契约。 | + +## 3. 技术栈 + +### 后端 + +- Java 17,语法和编译 release 均按 17。 +- Spring Boot 3.5.15。 +- Maven Wrapper。 +- MyBatis-Plus 3.5.16。 +- Flyway。 +- MySQL,测试场景可使用 H2 MySQL Mode 或 test-mysql profile。 +- Spring Validation、springdoc-openapi、Angus Mail、Aliyun OSS、Apache POI。 + +### 前端 + +- Vue 3.5。 +- TypeScript 6。 +- Vite 8。 +- Pinia 3。 +- PrimeVue 4、PrimeIcons 7。 +- Vue Router 5、Vue I18n 11。 +- Vitest 4、ESLint 10、vue-tsc 3。 +- Node.js >= 22.13.0。 + +## 4. 当前业务领域 + +- SourceMessage:外部消息来源事实,当前主要承接 AgentBus 邮件 JSON。 +- Email / Message Conversation:邮件和邮件会话,用于历史邮件查询、正文读取和会话级任务查询。 +- Reservation Case / Task:预订相关订单、任务卡、人工复核和任务结果。 +- Identity / Access / Hotel / Menu:登录、用户、角色、权限、酒店授权和动态菜单底座。 +- Debug EML:受控调试入口,用于上传 EML 并触发 SuperAgent 调试链路。 +- Manual Invoice:手工开票生成能力,包含 Excel 模板、PDF 转换和 OSS 输出。 + +## 5. 外部系统与边界 + +| 外部系统 | 当前定位 | 契约入口 | +| --- | --- | --- | +| AgentBus | 消息入口适配器。实时入口必须先落 SourceMessage Inbox。 | `docs/project/integrations/superagent-agentbus-project-integration-guide.md` | +| SuperAgent | 外部 AI / Agent 能力提供方。返回内容只能作为建议、证据或审计结果。 | `docs/project/integrations/superagent-api-contract.md` | +| SuperAgent MCP | SuperAgent 调用本项目能力的 MCP 映射,当前内嵌在后端服务。 | `docs/project/integrations/superagent-mcp/README.md` | +| Aliyun OSS | 调试 EML、附件或生成文件的对象存储。 | 相关配置和安全边界见项目文档与后端配置 | +| OHIP / PMS | 后续酒店系统集成方向。当前不允许前端直接访问。 | 后续 Spec / ADR 明确 | + +## 6. 当前开发方向 + +当前项目正在从普通项目文档结构,逐步落地 AI-NSES 文档入口: + +- `AGENTS.md` 规定 Agent 如何工作。 +- `CONTEXT.md` 说明项目长期背景。 +- `PROJECT_STATE.md` 记录当前阶段状态。 +- `docs/project/README.md` 作为当前项目专属文档索引。 +- `docs/import/reusable/` 保存可迁移到其他项目的通用规范。 + +业务开发仍以 `docs/project/requirements/` 和 `docs/project/integrations/` 下的当前有效文档为准。 + +## 7. 新 Agent 阅读顺序 + +1. `AGENTS.md` +2. `CONTEXT.md` +3. `PROJECT_STATE.md` +4. `README.md` +5. `docs/project/README.md` +6. 与当前任务相关的需求、接口、安全、前后端协作或集成文档 + +涉及接口、权限、审计、酒店隔离或敏感数据返回时,必须额外阅读 `docs/project/security-access-control-boundary.md`。 diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md new file mode 100644 index 0000000..326abfc --- /dev/null +++ b/PROJECT_STATE.md @@ -0,0 +1,59 @@ +# TH Hotel Simple 项目当前状态 + +| 项 | 内容 | +| --- | --- | +| 最近更新 | 2026-07-17 | +| 当前分支 | `feature/huangting` | +| 当前阶段 | 项目文档标准化与业务能力迭代并行 | +| 当前重点 | 按 AI-NSES 建立新 Agent 可快速进入项目的文档入口 | + +## 1. 当前 Checkpoint + +- 名称:`checkpoint-docs-ai-native-entry` +- 状态:In Progress,等待本次文档改动 Review。 +- 目标:新增 AI-NSES 通用标准、模板目录、项目上下文、项目当前状态和本项目采用说明。 +- 边界:只改文档,不搬迁历史文档,不删除文档,不改代码。 + +## 2. 当前优先级 + +1. 先把 AI-NSES 的入口文档落地,让新 Agent 不依赖聊天记录也能理解项目。 +2. 保持 `docs/project/README.md`、`CONTEXT.md`、`PROJECT_STATE.md` 三个入口之间一致。 +3. 后续开发继续以当前有效的 M002 V3 / P0.1、字段控件契约、SuperAgent 契约和安全边界文档为准。 + +## 3. 已确认事实 + +- 本项目是前后端分离项目,根目录使用 `client/` 和 `server/`,不是 AI-NSES 示例里的通用 `src/` 单目录结构。 +- 可复用规范放在 `docs/import/reusable/`,当前项目专属文档放在 `docs/project/`。 +- AgentBus 是消息入口适配器,SuperAgent 是外部 AI / Agent 能力提供方,二者不能直接成为业务事实来源。 +- 后端时间点按 UTC 存储和返回,页面再按酒店或用户时区展示。 +- 新增 MySQL 表默认要求 `utf8mb4_bin`,避免外部 opaque id、Token、状态码和业务代码被大小写不敏感比较误判。 + +## 4. Known Issues + +- 现有历史文档暂不按 AI-NSES 目录大搬迁,先通过索引和采用说明建立映射关系。 +- `docs/import/` 下按日期导入的资料是输入材料,不等同于当前权威开发契约;当前开发应优先看 `docs/project/README.md` 标记为当前有效或权威契约的文档。 +- 后续每完成一个 Feature 或 Checkpoint,需要更新本文件,避免项目状态继续沉淀在聊天记录里。 + +## 5. Next Steps + +- Review 本次 AI-NSES 文档入口改动。 +- 确认通过后,可按后续具体需求继续进入业务开发。 +- 后续新增重要功能时,优先在 `docs/project/requirements/` 或未来 `docs/specs/` 中形成 Spec,再实现代码。 + +## 6. 文档同步提醒 + +每次 Feature 完成后检查: + +- Domain 是否需要更新。 +- Architecture 是否需要更新。 +- Workflow 是否需要更新。 +- ADR 是否需要新增。 +- Spec 或需求文档是否需要更新状态。 +- `PROJECT_STATE.md` 是否需要更新。 +- 接口、安全、权限、审计和酒店隔离文档是否需要同步。 + +如果没有文档变化,明确说明: + +```text +No documentation changes required. +``` diff --git a/README.md b/README.md index b7e79c1..740afd9 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,27 @@ TH Hotel Simple 是一个前后端分离的酒店业务协同项目。当前后端优先建设平台级 SourceMessage Inbox,用于接收 AgentBus 邮件来源事实,并为后续 AI 识别、人工复核、Case / Task 流程提供可追溯输入。 +## AI-NSES 文档入口 + +新成员或新的 AI Agent 进入项目时,建议按以下顺序阅读: + +1. `AGENTS.md`:项目协作与开发规范。 +2. `CONTEXT.md`:项目目标、技术栈、系统组成和业务边界。 +3. `PROJECT_STATE.md`:当前 checkpoint、优先级、Known Issues 和 Next Steps。 +4. `docs/project/README.md`:当前项目专属文档总索引。 +5. `docs/project/ai-native-adoption.md`:本项目如何采用 AI-NSES。 + +可复用 AI-NSES 标准位于 `docs/import/reusable/ai-native-software-engineering-standard.md`,模板目录位于 `docs/import/reusable/ai-native-templates/`。 + ## 目录说明 ```text +CONTEXT.md +项目长期上下文入口,记录产品目标、技术栈、系统组成、业务领域和外部系统边界。 + +PROJECT_STATE.md +项目当前状态入口,记录当前 checkpoint、优先级、Known Issues 和 Next Steps。 + client/ 后续前端应用目录。前端只调用本项目后端,不直接访问 AgentBus、SuperAgent、OHIP、数据库或任何 Secret。 diff --git a/client/src/components/reservation/ReservationTaskDetailPanel.vue b/client/src/components/reservation/ReservationTaskDetailPanel.vue index 49d3b3b..2d6e542 100644 --- a/client/src/components/reservation/ReservationTaskDetailPanel.vue +++ b/client/src/components/reservation/ReservationTaskDetailPanel.vue @@ -49,21 +49,27 @@ - - {{ t('task.evidence') }} - + + - {{ t('task.sourceMessageId') }} - - {{ detail.source_message_id || '-' }} - - - - {{ t('task.sourceSubject') }} - - {{ detail.source_subject ?? t('task.sourceSummaryPending') }} - + {{ t('task.sourceEmailPreview') }} + {{ detail.source_subject ?? t('task.sourceSummaryPending') }} + + {{ t('task.sourceEmailFullConversation') }} · {{ t('common.endpointMissing') }} + + + {{ t('task.sourceEmailFullConversation') }} + + + {{ t('task.sourceSender') }} {{ detail.source_sender_summary ?? '-' }} @@ -72,53 +78,41 @@ {{ t('task.sourceReceivedAt') }} {{ formatReservationDateTime(detail.source_received_at) }} - - - - - {{ t('task.routeInfo') }} - - {{ t('task.systemTaskType') }} - - {{ formatReservationTaskCard(t, detail.system_task_type) }} - {{ detail.system_task_type }} - - - - {{ t('task.taskCardType') }} - - {{ formatReservationTaskCardLabel(t, detail) }} - {{ detail.task_card_type }} - - - - {{ t('task.resultType') }} - {{ detail.result_type ? formatReservationResultType(t, detail.result_type) : '-' }} - - - {{ t('task.aiTaskType') }} - {{ detail.ai_task_type || '-' }} - - - {{ t('task.taskSubtype') }} - {{ detail.task_subtype ? formatReservationRouteCode(t, detail.task_subtype) : '-' }} - - - {{ t('task.routeCode') }} + {{ t('task.sourceMessageId') }} - {{ detail.route_code || '-' }} + {{ detail.source_message_id || '-' }} - {{ t('task.systemProcessCategory') }} - {{ detail.system_process_category ? formatReservationSystemProcessCategory(t, detail.system_process_category) : '-' }} - - - {{ t('task.reviewStatus') }} - {{ detail.review_status || '-' }} + {{ t('conversation.messageCount') }} + {{ detail.conversation_message_count ?? '-' }} + + + {{ t('task.sourceEmailExcerpt') }} + {{ sourceEmailExcerpt ?? t('task.sourceEmailNoExcerpt') }} + + + {{ t('task.sourceEmailAttachments') }} + + + + + {{ item.name }} + {{ item.meta }} + + + + + + + + {{ t('task.routeInfo') }} + + + {{ t('task.systemTaskType') }} + + {{ formatReservationTaskCard(t, detail.system_task_type) }} + {{ detail.system_task_type }} + + + + {{ t('task.taskCardType') }} + + {{ formatReservationTaskCardLabel(t, detail) }} + {{ detail.task_card_type }} + + + + {{ t('task.resultType') }} + {{ detail.result_type ? formatReservationResultType(t, detail.result_type) : '-' }} + + + {{ t('task.aiTaskType') }} + {{ detail.ai_task_type || '-' }} + + + {{ t('task.taskSubtype') }} + {{ detail.task_subtype ? formatReservationRouteCode(t, detail.task_subtype) : '-' }} + + + {{ t('task.routeCode') }} + + {{ detail.route_code || '-' }} + + + + {{ t('task.systemProcessCategory') }} + {{ detail.system_process_category ? formatReservationSystemProcessCategory(t, detail.system_process_category) : '-' }} + + + {{ t('task.reviewStatus') }} + {{ detail.review_status || '-' }} + + + @@ -430,6 +470,7 @@ import type { import { buildEditableFieldValues, buildInitialFieldValues, + readReservationFieldValue, validateReservationFieldValues, type ReservationFieldValidationErrors, } from '@/utils/reservationFieldRules' @@ -481,6 +522,8 @@ const manualReviewRecord = computed(() => detail.value?.manual_review ?? sourceM const manualReviewEvidence = computed(() => buildManualReviewEvidence(detail.value, manualReviewRecord.value)) const adapterContractErrors = computed(() => detail.value?.adapter_contract_errors ?? []) const unhandledIntents = computed(() => detail.value?.unhandled_intents ?? []) +const sourceEmailExcerpt = computed(() => readSourceEmailExcerpt(detail.value, fieldValues.value)) +const sourceEmailAttachments = computed(() => readSourceEmailAttachments(detail.value, fieldValues.value)) const isFieldRendererReadOnly = computed( () => Boolean(detail.value?.availability.read_only) || @@ -731,6 +774,175 @@ function replaceOperation(operation: ReservationOperaOperationResult): void { } } +interface SourceEmailAttachmentItem { + key: string + name: string + meta: string +} + +const sourceEmailAttachmentPathTokens = ['attachment', 'attachments', 'file_reference', 'file_references'] + +function readSourceEmailExcerpt( + taskDetail: ReservationTaskDetailResult | null, + values: ReservationRecord, +): string | null { + if (!taskDetail) { + return null + } + const fieldExcerpt = stringifySourceEmailText(readFirstEvidenceFieldValue(taskDetail, values, ['relevant_message_excerpt'])) + if (fieldExcerpt) { + return fieldExcerpt + } + return null +} + +function readSourceEmailAttachments( + taskDetail: ReservationTaskDetailResult | null, + values: ReservationRecord, +): SourceEmailAttachmentItem[] { + if (!taskDetail) { + return [] + } + const attachmentValues = taskDetail.fields + .filter((field) => isSourceEmailAttachmentField(field)) + .map((field) => readReservationFieldValue(field, values)) + const seen = new Set() + return attachmentValues + .flatMap((value) => normalizeAttachmentValue(value)) + .map((value, index) => toSourceEmailAttachmentItem(value, index)) + .filter((item) => { + const key = `${item.name}-${item.meta}` + if (seen.has(key)) { + return false + } + seen.add(key) + return true + }) +} + +function readFirstEvidenceFieldValue( + taskDetail: ReservationTaskDetailResult, + values: ReservationRecord, + pathTokens: string[], +): unknown { + const field = taskDetail.fields.find((item) => { + const fieldPath = item.field_path.toLowerCase() + const pointer = item.field_pointer?.toLowerCase() ?? '' + return pathTokens.some((token) => fieldPath.includes(token) || pointer.includes(token.split('.').join('/'))) + }) + return field ? readReservationFieldValue(field, values) : null +} + +function isSourceEmailAttachmentField(field: ReservationTaskDetailResult['fields'][number]): boolean { + const fieldPath = field.field_path.toLowerCase() + const pointer = field.field_pointer?.toLowerCase() ?? '' + return sourceEmailAttachmentPathTokens.some((token) => fieldPath.includes(token) || pointer.includes(token)) +} + +function stringifySourceEmailText(value: unknown): string | null { + if (typeof value === 'string' && value.trim()) { + return value.trim() + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value) + } + return null +} + +function normalizeAttachmentValue(value: unknown): unknown[] { + if (value === null || value === undefined || value === '') { + return [] + } + if (Array.isArray(value)) { + return value.flatMap((item) => normalizeAttachmentValue(item)) + } + if (typeof value === 'string') { + const trimmedValue = value.trim() + if (!trimmedValue) { + return [] + } + const parsedValue = parseMaybeJson(trimmedValue) + return parsedValue === trimmedValue ? [trimmedValue] : normalizeAttachmentValue(parsedValue) + } + return [value] +} + +function toSourceEmailAttachmentItem(value: unknown, index: number): SourceEmailAttachmentItem { + const record = readRecord(value) + const name = record + ? readFirstString(record, ['name', 'file_name', 'filename', 'original_file_name', 'display_name', 'title']) + : stringifySourceEmailText(value) + const rawType = record ? readFirstString(record, ['content_type', 'mime_type', 'media_type', 'file_type', 'type']) : null + const size = record ? readFirstNumber(record, ['size_bytes', 'file_size', 'size']) : null + const typeLabel = formatAttachmentTypeLabel(name, rawType) + const sizeLabel = formatAttachmentSize(size) + const id = record ? readFirstString(record, ['id', 'media_id', 'file_id', 'attachment_id']) : null + const displayName = name || t('conversation.unnamedAttachment') + return { + key: id ? `${id}-${index}` : `${displayName}-${index}`, + name: displayName, + meta: [typeLabel, sizeLabel].filter(Boolean).join(' · '), + } +} + +function parseMaybeJson(value: string): unknown { + if (!value.startsWith('{') && !value.startsWith('[')) { + return value + } + try { + return JSON.parse(value) + } catch { + return value + } +} + +function readFirstNumber(record: ReservationRecord, keys: string[]): number | null { + for (const key of keys) { + const value = record[key] + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + if (typeof value === 'string' && Number.isFinite(Number(value))) { + return Number(value) + } + } + return null +} + +function formatAttachmentTypeLabel(fileName: string | null, rawType: string | null): string { + const source = `${rawType ?? ''} ${fileName ?? ''}`.toLowerCase() + if (source.includes('pdf') || source.endsWith('.pdf')) { + return 'PDF' + } + if (source.includes('spreadsheet') || source.includes('excel') || /\.(xlsx|xls|csv)$/.test(source)) { + return 'Excel' + } + if (source.includes('word') || /\.(docx|doc)$/.test(source)) { + return 'Word' + } + if (source.includes('image') || /\.(png|jpe?g|gif|webp)$/.test(source)) { + return 'Image' + } + if (source.includes('message/rfc822') || source.endsWith('.eml')) { + return 'EML' + } + const extension = fileName?.match(/\.([a-z0-9]{2,8})$/i)?.[1] + return extension ? extension.toUpperCase() : '' +} + +function formatAttachmentSize(size: number | null): string { + if (!size || size <= 0) { + return '' + } + if (size < 1024) { + return `${size} B` + } + if (size < 1024 * 1024) { + return `${(size / 1024).toFixed(1)} KB` + } + return `${(size / 1024 / 1024).toFixed(1)} MB` +} + function formatJson(value: unknown): string { return JSON.stringify(value ?? {}, null, 2) } @@ -921,7 +1133,7 @@ function formatStringList(items: string[]): string { color: var(--th-color-slate-500); } -.task-evidence, +.task-source-email, .task-route-info, .task-ai-diagnostics { display: grid; @@ -931,10 +1143,54 @@ function formatStringList(items: string[]): string { padding: 16px; } +.task-source-email { + gap: 16px; + background: var(--th-color-white); +} + +.task-source-email header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.task-source-email header p, +.task-source-email header h2, +.task-source-email__content h3, +.task-source-email__content p, +.task-source-email__content ul { + margin: 0; +} + +.task-source-email header p { + color: var(--th-color-blue-600); + font-size: 12px; + font-weight: 800; +} + +.task-source-email header h2 { + margin-top: 6px; + color: var(--th-color-slate-900); + font-size: 18px; + line-height: 1.35; +} + .task-route-info { background: var(--th-color-slate-50); } +.task-route-info summary { + color: var(--th-color-slate-700); + cursor: pointer; + font-size: 13px; + font-weight: 800; +} + +.task-route-info[open] summary { + margin-bottom: 10px; +} + .task-ai-diagnostics { background: var(--th-color-white); } @@ -961,9 +1217,6 @@ function formatStringList(items: string[]): string { padding: 16px; } -.task-evidence h2, -.task-evidence dl, -.task-route-info h2, .task-route-info dl, .task-ai-diagnostics h2, .task-ai-diagnostics pre, @@ -977,8 +1230,6 @@ function formatStringList(items: string[]): string { margin: 0; } -.task-evidence h2, -.task-route-info h2, .task-ai-diagnostics h2, .manual-review-evidence h2, .source-only-result h2 { @@ -1010,7 +1261,7 @@ function formatStringList(items: string[]): string { line-height: 1.6; } -.task-evidence dl, +.task-source-email__meta, .task-route-info dl, .manual-review-evidence > dl { display: grid; @@ -1024,7 +1275,7 @@ function formatStringList(items: string[]): string { gap: 12px; } -.task-evidence dt, +.task-source-email__meta dt, .task-route-info dt, .manual-review-evidence > dl dt { color: var(--th-color-slate-500); @@ -1032,7 +1283,7 @@ function formatStringList(items: string[]): string { font-weight: 700; } -.task-evidence dd, +.task-source-email__meta dd, .task-route-info dd, .manual-review-evidence > dl dd, .source-only-result dd { @@ -1042,6 +1293,71 @@ function formatStringList(items: string[]): string { overflow-wrap: anywhere; } +.task-source-email__content { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(260px, 0.65fr); + gap: 12px; +} + +.task-source-email__content section { + display: grid; + gap: 8px; + border: 1px solid var(--th-color-slate-200); + border-radius: var(--th-radius-sm); + background: var(--th-color-slate-50); + padding: 12px; +} + +.task-source-email__content section:only-child { + grid-column: 1 / -1; +} + +.task-source-email__content h3 { + color: var(--th-color-slate-500); + font-size: 12px; + font-weight: 800; +} + +.task-source-email__content p { + color: var(--th-color-slate-800); + font-size: 13px; + line-height: 1.65; + white-space: pre-wrap; +} + +.task-source-email__content ul { + display: grid; + gap: 8px; + padding: 0; + list-style: none; +} + +.task-source-email__content li { + display: flex; + gap: 8px; + color: var(--th-color-slate-900); + font-size: 13px; + line-height: 1.5; +} + +.task-source-email__content li i { + flex: 0 0 auto; + margin-top: 2px; + color: var(--th-color-blue-600); +} + +.task-source-email__content li span { + min-width: 0; + overflow-wrap: anywhere; +} + +.task-source-email__content li small { + display: block; + margin-top: 2px; + color: var(--th-color-slate-500); + font-size: 12px; +} + .manual-review-evidence__candidates { display: grid; gap: 10px; @@ -1213,7 +1529,7 @@ function formatStringList(items: string[]): string { grid-template-columns: 1fr; } - .task-evidence dl, + .task-source-email__meta, .task-route-info dl, .manual-review-evidence > dl, .candidate-grid { @@ -1222,7 +1538,16 @@ function formatStringList(items: string[]): string { } @media (max-width: 640px) { - .task-evidence dl, + .task-source-email header, + .task-source-email__content { + grid-template-columns: 1fr; + } + + .task-source-email header { + display: grid; + } + + .task-source-email__meta, .task-route-info dl, .manual-review-evidence > dl, .candidate-grid, diff --git a/client/src/i18n/locales/en-US.ts b/client/src/i18n/locales/en-US.ts index e8b99ec..4ef4775 100644 --- a/client/src/i18n/locales/en-US.ts +++ b/client/src/i18n/locales/en-US.ts @@ -418,6 +418,12 @@ export default { sourceSender: 'Sender', sourceReceivedAt: 'Received at', sourceSummaryPending: 'Source email summary fields are pending backend support', + sourceEmailPreview: 'Source email preview', + sourceEmailExcerpt: 'Email excerpt', + sourceEmailAttachments: 'Attachments', + sourceEmailNoExcerpt: 'No email excerpt', + sourceEmailNoAttachments: 'No attachments', + sourceEmailFullConversation: 'View full email conversation', sourceOnlyResult: 'Source message entry result', sourceOnlyReadonly: 'This task only shows the source message entry result. It does not enter order handling, field editing, or OPERA operations.', sourceOnlyNoOpera: 'This task type does not involve OPERA operations.', diff --git a/client/src/i18n/locales/th-TH.ts b/client/src/i18n/locales/th-TH.ts index 0dd4761..67f0e87 100644 --- a/client/src/i18n/locales/th-TH.ts +++ b/client/src/i18n/locales/th-TH.ts @@ -418,6 +418,12 @@ export default { sourceSender: 'ผู้ส่ง', sourceReceivedAt: 'เวลารับ', sourceSummaryPending: 'ข้อมูลสรุปอีเมลต้นทางยังรอหลังบ้านเพิ่มเติม', + sourceEmailPreview: 'ตัวอย่างอีเมลต้นทาง', + sourceEmailExcerpt: 'ข้อความบางส่วนของอีเมล', + sourceEmailAttachments: 'ไฟล์แนบ', + sourceEmailNoExcerpt: 'ไม่มีข้อความบางส่วน', + sourceEmailNoAttachments: 'ไม่มีไฟล์แนบ', + sourceEmailFullConversation: 'ดูเธรดอีเมลทั้งหมด', sourceOnlyResult: 'ผลลัพธ์ทางเข้าของข้อความต้นทาง', sourceOnlyReadonly: 'งานนี้ใช้แสดงผลลัพธ์ทางเข้าของข้อความต้นทางเท่านั้น ไม่เข้าสู่การจัดการออเดอร์ การแก้ไขข้อมูล หรือ OPERA', sourceOnlyNoOpera: 'งานประเภทนี้ไม่เกี่ยวข้องกับ OPERA', diff --git a/client/src/i18n/locales/zh-CN.ts b/client/src/i18n/locales/zh-CN.ts index 5368f00..d672759 100644 --- a/client/src/i18n/locales/zh-CN.ts +++ b/client/src/i18n/locales/zh-CN.ts @@ -418,6 +418,12 @@ export default { sourceSender: '发件人', sourceReceivedAt: '接收时间', sourceSummaryPending: '来源邮件摘要字段待后端补充', + sourceEmailPreview: '来源邮件预览', + sourceEmailExcerpt: '邮件片段', + sourceEmailAttachments: '相关附件', + sourceEmailNoExcerpt: '暂无邮件片段', + sourceEmailNoAttachments: '无附件', + sourceEmailFullConversation: '查看完整邮件会话', sourceOnlyResult: '来源消息入口结果', sourceOnlyReadonly: '该任务仅用于展示来源消息入口结果,不进入订单处理、字段编辑或 OPERA 操作。', sourceOnlyNoOpera: '该类任务不涉及 OPERA 操作。', diff --git a/client/src/tests/reservationTaskDetailPanel.spec.ts b/client/src/tests/reservationTaskDetailPanel.spec.ts index 873bbf4..646fc79 100644 --- a/client/src/tests/reservationTaskDetailPanel.spec.ts +++ b/client/src/tests/reservationTaskDetailPanel.spec.ts @@ -651,6 +651,90 @@ describe('ReservationTaskDetailPanel', () => { expect(wrapper.text()).not.toContain('接口待补') }) + it('renders source email preview with excerpt, attachments and full conversation entry', async () => { + vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue( + createTaskDetail({ + fields: [ + createRequiredField({ + field_path: 'relevant_message_excerpt', + display_name: '邮件片段', + value: 'LLT260509FA213 11-13/05/2026 2N 14 TWN +1 DBL', + editable: 'N', + input_editable: 'N', + required_rule: null, + control_type: 'evidence_text', + edit_scope: 'never', + write_target: 'none', + raw_readonly: true, + } as Partial), + createRequiredField({ + field_path: 'source_message.attachments', + display_name: '来源附件', + value: [ + { + id: 'att-1', + name: 'WYNDHAM LIANTAI 2026 UPDATE BOOKING 12-05-2026 NO.1.xlsx', + content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + size_bytes: 2048, + }, + ], + editable: 'N', + input_editable: 'N', + file_display: 'Y', + required_rule: null, + control_type: 'file', + edit_scope: 'never', + write_target: 'none', + raw_readonly: true, + } as Partial), + ], + }), + ) + + const wrapper = await mountPanel() + + expect(wrapper.text()).toContain('来源邮件预览') + expect(wrapper.text()).toContain('Booking Request') + expect(wrapper.text()).toContain('guest@example.test') + expect(wrapper.text()).toContain('2026-07-08 10:00:00') + expect(wrapper.text()).toContain('LLT260509FA213 11-13/05/2026 2N 14 TWN +1 DBL') + expect(wrapper.text()).toContain('WYNDHAM LIANTAI 2026 UPDATE BOOKING 12-05-2026 NO.1.xlsx') + expect(wrapper.text()).toContain('查看完整邮件会话') + expect(wrapper.text()).not.toContain('{"id":"att-1"') + }) + + it('does not surface source email excerpt or attachments from unexposed backend context', async () => { + vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue( + createTaskDetail({ + fields: [], + context_used: { + relevant_message_excerpt: 'Hidden context excerpt', + attachments: [ + { + name: 'hidden-context-attachment.pdf', + }, + ], + }, + manual_review: { + attachments: [ + { + name: 'hidden-review-attachment.pdf', + }, + ], + }, + }), + ) + + const wrapper = await mountPanel() + const sourceEmailPreview = wrapper.find('.task-source-email') + + expect(sourceEmailPreview.text()).toContain('暂无邮件片段') + expect(sourceEmailPreview.text()).not.toContain('Hidden context excerpt') + expect(sourceEmailPreview.text()).not.toContain('hidden-context-attachment.pdf') + expect(sourceEmailPreview.text()).not.toContain('hidden-review-attachment.pdf') + expect(sourceEmailPreview.text()).not.toContain('相关附件') + }) + it('renders source-message-only entry result without editable or OPERA actions', async () => { vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue(createSourceMessageOnlyTaskDetail()) diff --git a/client/src/tests/reservationViews.spec.ts b/client/src/tests/reservationViews.spec.ts index 10c1bbb..a591b7a 100644 --- a/client/src/tests/reservationViews.spec.ts +++ b/client/src/tests/reservationViews.spec.ts @@ -397,8 +397,11 @@ describe('reservation P0 views', () => { const wrapper = await mountWithPlugins(ReservationOrderListView) await vi.dynamicImportSettled() + const rowText = wrapper.find('tbody').text() expect(wrapper.text()).toContain('GRP-001') - expect(wrapper.text()).toContain('有效') + expect(rowText).not.toContain('20001') + expect(rowText).not.toContain('有效') + expect(wrapper.text()).not.toContain('未关闭任务') expect(wrapper.text()).toContain('继续处理') expect(wrapper.text()).not.toContain('接口待接入') }) diff --git a/client/src/views/reservation/ReservationOrderListView.vue b/client/src/views/reservation/ReservationOrderListView.vue index c11162f..dbacdc7 100644 --- a/client/src/views/reservation/ReservationOrderListView.vue +++ b/client/src/views/reservation/ReservationOrderListView.vue @@ -108,8 +108,6 @@ {{ t('orderList.columns.order') }} - {{ t('orderList.columns.status') }} - {{ t('orderList.columns.openTasks') }} {{ t('orderList.columns.createdAt') }} {{ t('workbench.columns.updatedAt') }} {{ t('workbench.columns.operation') }} @@ -122,10 +120,7 @@ > {{ formatOrderKey(order) }} - {{ order.order_id }} - - {{ order.open_task_count ?? 0 }} {{ formatReservationDateTime(order.created_at) }} {{ formatReservationDateTime(order.updated_at) }} @@ -197,7 +192,6 @@ import { computed, onMounted, reactive, ref, watch } from 'vue' import { RouterLink } from 'vue-router' import { useI18n } from 'vue-i18n' -import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue' import { fetchReservationOrders } from '@/services/reservationService' import { useAuthStore } from '@/stores/authStore' import type { ReservationOrderListFilters, ReservationOrderListItem, ReservationPageResult } from '@/types/reservation' diff --git a/docs/import/reusable/README.md b/docs/import/reusable/README.md index aec351f..a7ca815 100644 --- a/docs/import/reusable/README.md +++ b/docs/import/reusable/README.md @@ -9,6 +9,8 @@ ## 2. 文档清单 - `general-development-guidelines.md`:通用开发协作规范。 +- `ai-native-software-engineering-standard.md`:AI-NSES 通用标准,定义 AI 协作项目的文档结构、职责和开发闭环。 +- `ai-native-templates/`:AI-NSES 模板目录,供新项目初始化时复制使用。 - `frontend-development-guidelines.md`:通用前端开发规范,不包含具体业务项目规则。 - `backend-development-guidelines.md`:通用后端开发规范,不包含具体业务项目规则。 - `backend-base-structure-pagination-guidelines.md`:后端基础结构、ID、审计字段、分页和 Mapper 规范。 @@ -24,4 +26,5 @@ - 前端是否仍使用 Vue、TypeScript、Vite 和 PrimeVue。 - 是否需要 SuperAgent、AgentBus 或其他外部系统接入。 - 根目录 `AGENTS.md` 是否已经引用本目录下的规范。 +- 是否需要按 AI-NSES 建立 `CONTEXT.md`、`PROJECT_STATE.md` 和项目文档索引。 - 当前项目专属业务规则是否已经放在项目自己的文档目录,而不是混入本目录。 diff --git a/docs/import/reusable/ai-native-software-engineering-standard.md b/docs/import/reusable/ai-native-software-engineering-standard.md new file mode 100644 index 0000000..8baafdc --- /dev/null +++ b/docs/import/reusable/ai-native-software-engineering-standard.md @@ -0,0 +1,276 @@ +# AI-Native Software Engineering Standard (AI-NSES) + +| 项 | 内容 | +| --- | --- | +| Version | 0.1 | +| Scope | 可复用软件工程标准 | +| Audience | 人类开发者、产品人员、架构师、AI Agent | + +## 1. Purpose + +AI-NSES 是一套面向 AI 协作开发的软件工程标准。 + +它不属于某一个具体项目,而是描述: + +- 项目应该如何组织。 +- 文档应该如何维护。 +- AI Agent 应该如何工作。 +- 开发流程应该如何闭环。 + +目标是让任何新的 AI Agent 在几分钟内理解项目,而不依赖历史聊天记录。 + +## 2. Design Philosophy + +### Documentation First + +复杂软件首先是知识,其次才是代码。代码是项目知识的一种实现形式。 + +### Context Driven + +Prompt 是临时的,Context 是长期资产。重要知识应该沉淀在仓库,而不是沉淀在聊天记录里。 + +### Living Documentation + +文档不是一次性产物。文档随着项目成长,开发结束时文档也应该同步结束。 + +### AI as Team Member + +AI 不是单纯的代码生成器,而是项目成员。它可以参与需求分析、产品设计、架构设计、开发、Review 和维护。 + +## 3. Core Principle + +Build a project that teaches AI. + +Do not teach AI every day. + +中文说明:让项目成为 AI 的长期记忆,而不是每次都重新解释项目。 + +## 4. Recommended Project Structure + +```text +project/ +├── AGENTS.md +├── CONTEXT.md +├── PROJECT_STATE.md +├── docs/ +│ ├── domain/ +│ ├── architecture/ +│ ├── workflows/ +│ ├── adr/ +│ ├── specs/ +│ └── guidelines/ +├── src/ +└── tests/ +``` + +中文说明:实际项目可以使用 `client/`、`server/`、`backend/`、`frontend/` 等目录,只要在 `CONTEXT.md` 或项目架构文档中说明映射关系即可。 + +## 5. Document Responsibilities + +### AGENTS.md + +读者:所有 AI Agent。 + +职责:规定 Agent 如何工作,包括默认工作流程、Debug 原则、Spec 原则、文档更新原则和 Review 原则。 + +更新频率:极低。 + +### CONTEXT.md + +读者:人类成员和 AI Agent。 + +职责:介绍项目整体背景,包括产品目标、技术栈、系统组成、当前模块和当前开发方向。 + +更新频率:低。 + +### PROJECT_STATE.md + +读者:人类成员和 AI Agent。 + +职责:记录当前项目状态,包括当前 Sprint、当前 Feature、当前 Priority、Known Issues 和 Next Steps。 + +更新频率:高。建议每完成一个 Feature 或 Checkpoint 更新一次。 + +### docs/domain/ + +读者:产品、业务、开发和 AI Agent。 + +职责:记录业务知识。建议一个业务对象一个文档,例如 `Guest.md`、`Hotel.md`、`Order.md`、`Reservation.md`、`Email.md`、`Task.md`、`PMS.md`。 + +内容包括定义、生命周期、业务规则和关系。禁止记录实现细节。 + +更新频率:低。 + +### docs/architecture/ + +读者:架构、开发和 AI Agent。 + +职责:记录系统设计,例如 `ARCHITECTURE.md`、`DATABASE.md`、`EVENTS.md`、`MODULES.md`。 + +重点描述模块边界、系统通信、数据库、事件和部署边界。 + +更新频率:低。 + +### docs/workflows/ + +读者:产品、业务、开发和 AI Agent。 + +职责:记录业务流程,例如 Email Processing、Reservation Sync、Task Generation、Webhook Processing。 + +重点描述输入、输出、状态流转、异常和补偿。 + +更新频率:中。 + +### docs/adr/ + +读者:架构、开发和 AI Agent。 + +职责:记录重要架构决策。每个重要设计决策一份文档。 + +建议格式:背景、为什么、备选方案、最终选择、影响。 + +规则:ADR 永远追加,不覆盖历史。 + +### docs/specs/ + +读者:产品、开发、测试和 AI Agent。 + +职责:记录具体功能规格。每一个 Feature 对应一个 Spec。 + +生命周期:Draft -> Approved -> Implemented -> Archived。 + +规则:Spec 完成以后保留,不删除。 + +### docs/guidelines/ + +读者:开发、测试和 AI Agent。 + +职责:记录长期规范,例如 API、UI、CODING、TESTING、SECURITY。 + +更新频率:极低。 + +## 6. Knowledge Layers + +### Long-term Knowledge + +生命周期:整个项目。 + +包括 Vision、Domain、Architecture、Guidelines。 + +### Medium-term Knowledge + +生命周期:一个版本或一个阶段。 + +包括 Workflow、ADR、Spec。 + +### Short-term Knowledge + +生命周期:一个 Sprint 或一个开发周期。 + +包括 Current Sprint、Known Issues、Next Steps、Backlog。 + +## 7. Stable vs Dynamic Documents + +长期稳定: + +- `AGENTS.md` +- `CONTEXT.md` +- `docs/domain/` +- `docs/architecture/` +- `docs/guidelines/` + +中期演进: + +- `docs/workflows/` +- `docs/adr/` +- `docs/specs/` + +高频更新: + +- `PROJECT_STATE.md` + +中文说明:这样可以避免整个仓库每天发生无意义变化,也能让新 Agent 快速判断哪些文档代表长期事实,哪些文档代表当前状态。 + +## 8. Feature Lifecycle + +任何 Feature 默认按以下顺序推进: + +```text +Idea +-> Requirement +-> Discussion +-> Specification +-> Implementation +-> Verification +-> Documentation Update +-> Done +``` + +Documentation Update 属于 Definition of Done,不能跳过。 + +## 9. AI Working Principles + +- AI 应先理解,再开发。 +- 复杂需求默认先讨论,不立即编码。 +- 复杂功能默认先 Spec,不直接实现。 +- Bug 默认先定位,不猜测修复。 +- UI 默认保持一致性,不过度设计。 +- 代码修改前先确认目标、边界和验收标准。 +- 涉及接口、安全、权限、数据模型或外部系统时,先读相关契约文档。 + +## 10. Documentation Rules + +所有文档必须回答一个问题: + +未来的新 Agent 为什么需要阅读它? + +如果回答不了,就不要创建,也不要维护。 + +文档应该小、独立、易维护。 + +不要维护一个 8000 行的 `DOMAIN.md`。应该按对象拆分成 `Order.md`、`Guest.md`、`Hotel.md`、`Email.md`、`Task.md`。 + +## 11. Documentation Update Rules + +完成 Feature 后必须检查: + +- Domain 是否需要更新。 +- Architecture 是否需要更新。 +- Workflow 是否需要更新。 +- ADR 是否需要新增。 +- Spec 是否需要改为 Implemented 或补充结果。 +- Project State 是否需要更新。 +- 安全、权限、接口契约是否需要同步。 + +如果没有文档变化,应明确说明: + +```text +No documentation changes required. +``` + +不要为了修改而修改文档。 + +## 12. Success Criteria + +一个新的 AI Agent 进入项目后,阅读以下有限文档即可开始工作: + +```text +AGENTS.md +-> CONTEXT.md +-> PROJECT_STATE.md +-> 相关 Domain +-> 相关 Workflow +-> 相关 Spec 或 ADR +``` + +无需阅读整个代码库。 + +无需依赖历史聊天记录。 + +## 13. Scope + +AI-NSES 不限制编程语言、框架、数据库或 AI 模型。 + +它适用于 Codex、Claude Code、Gemini CLI、Cursor,以及未来任何 AI Agent。 + +它描述的是软件工程,不是某个具体工具。 diff --git a/docs/import/reusable/ai-native-templates/ADR.template.md b/docs/import/reusable/ai-native-templates/ADR.template.md new file mode 100644 index 0000000..3fa85ee --- /dev/null +++ b/docs/import/reusable/ai-native-templates/ADR.template.md @@ -0,0 +1,40 @@ +# ADR-编号 标题 + +| 项 | 内容 | +| --- | --- | +| 状态 | Proposed / Accepted / Superseded | +| 日期 | YYYY-MM-DD | +| 决策人 | 按实际填写 | + +## 1. 背景 + +说明为什么需要做这个决策。 + +## 2. 约束 + +- 约束 1: +- 约束 2: + +## 3. 备选方案 + +### 方案 A + +说明优点和缺点。 + +### 方案 B + +说明优点和缺点。 + +## 4. 最终选择 + +说明最终选择哪个方案。 + +## 5. 影响 + +- 正面影响: +- 负面影响: +- 后续动作: + +## 6. 历史说明 + +ADR 只追加,不覆盖历史。如果未来决策变化,新建 ADR 或标记被替代。 diff --git a/docs/import/reusable/ai-native-templates/AGENTS.template.md b/docs/import/reusable/ai-native-templates/AGENTS.template.md new file mode 100644 index 0000000..2018063 --- /dev/null +++ b/docs/import/reusable/ai-native-templates/AGENTS.template.md @@ -0,0 +1,39 @@ +# 项目协作与开发规范 + +## 1. 文档入口 + +- 项目背景:`CONTEXT.md` +- 当前状态:`PROJECT_STATE.md` +- 项目文档索引:`docs/README.md` 或项目自定义索引 +- 通用开发规范:按项目实际路径填写 + +## 2. 工作方式 + +- 先确认目标、边界和验收标准,再改代码或文档。 +- 每次只做一个明确 checkpoint。 +- 不修改与当前任务无关的用户变更。 +- 不回滚用户自己的改动,除非用户明确要求。 +- 遇到不确定的技术栈、接口契约、权限边界或数据模型,先确认再继续。 + +## 3. 文档更新原则 + +完成 Feature 后必须检查: + +- Domain 是否需要更新。 +- Architecture 是否需要更新。 +- Workflow 是否需要更新。 +- ADR 是否需要新增。 +- Spec 是否需要更新状态。 +- Project State 是否需要更新。 + +如果没有变化,明确说明: + +```text +No documentation changes required. +``` + +## 4. 测试与验证 + +- 修改后运行对应模块已有检查命令。 +- 如果检查命令尚未配置或因环境问题无法运行,必须明确说明原因。 +- 不假装测试通过。 diff --git a/docs/import/reusable/ai-native-templates/ARCHITECTURE.template.md b/docs/import/reusable/ai-native-templates/ARCHITECTURE.template.md new file mode 100644 index 0000000..b8d40f5 --- /dev/null +++ b/docs/import/reusable/ai-native-templates/ARCHITECTURE.template.md @@ -0,0 +1,31 @@ +# 架构说明 + +## 1. 系统目标 + +说明系统架构服务的产品目标和主要约束。 + +## 2. 模块边界 + +| 模块 | 职责 | 不负责 | +| --- | --- | --- | +| | | | + +## 3. 依赖方向 + +说明模块之间的依赖方向,避免双向依赖和跨层直连。 + +## 4. 数据边界 + +说明数据库、缓存、文件存储、消息队列和外部系统的数据边界。 + +## 5. 安全边界 + +说明鉴权、授权、租户隔离、审计和敏感数据处理方式。 + +## 6. 关键决策 + +列出相关 ADR 链接。 + +## 7. 演进计划 + +说明后续可能调整的方向和触发条件。 diff --git a/docs/import/reusable/ai-native-templates/CONTEXT.template.md b/docs/import/reusable/ai-native-templates/CONTEXT.template.md new file mode 100644 index 0000000..ea4fed6 --- /dev/null +++ b/docs/import/reusable/ai-native-templates/CONTEXT.template.md @@ -0,0 +1,50 @@ +# 项目上下文 + +## 1. 项目目标 + +说明项目要解决什么问题、主要服务谁、成功后用户会得到什么价值。 + +## 2. 当前系统组成 + +| 模块 | 中文说明 | +| --- | --- | +| `frontend/` 或 `client/` | 前端应用,负责展示、交互和调用本项目后端。 | +| `backend/` 或 `server/` | 后端服务,负责业务规则、数据、权限、安全和外部系统适配。 | +| `docs/` | 项目文档、规范、需求和架构说明。 | + +## 3. 技术栈 + +### 后端 + +- 语言: +- 框架: +- 数据库: +- 构建工具: +- 测试工具: + +### 前端 + +- 框架: +- 构建工具: +- UI 组件: +- 测试工具: + +## 4. 业务领域 + +列出核心业务对象,例如用户、订单、任务、酒店、邮件、支付、库存等。 + +## 5. 外部系统 + +列出外部系统、调用方向、鉴权方式和接口契约位置。 + +## 6. 当前开发方向 + +说明当前阶段最重要的开发目标和不做的事情。 + +## 7. 新 Agent 阅读顺序 + +1. `AGENTS.md` +2. `CONTEXT.md` +3. `PROJECT_STATE.md` +4. 项目文档索引 +5. 与当前任务相关的 Domain、Workflow、Spec、ADR diff --git a/docs/import/reusable/ai-native-templates/DOMAIN_OBJECT.template.md b/docs/import/reusable/ai-native-templates/DOMAIN_OBJECT.template.md new file mode 100644 index 0000000..616c1ea --- /dev/null +++ b/docs/import/reusable/ai-native-templates/DOMAIN_OBJECT.template.md @@ -0,0 +1,27 @@ +# 业务对象名称 + +## 1. 定义 + +说明这个业务对象是什么,不是什么。 + +## 2. 生命周期 + +说明对象从创建到结束的主要状态。 + +## 3. 业务规则 + +- 规则 1: +- 规则 2: +- 规则 3: + +## 4. 关系 + +说明它和其他业务对象的关系。 + +## 5. 禁止混淆 + +列出容易和它混淆的概念。 + +## 6. 非目标 + +说明本文不记录实现细节、表结构或接口字段。实现细节应放在 Architecture、Spec 或代码中。 diff --git a/docs/import/reusable/ai-native-templates/PROJECT_STATE.template.md b/docs/import/reusable/ai-native-templates/PROJECT_STATE.template.md new file mode 100644 index 0000000..689167b --- /dev/null +++ b/docs/import/reusable/ai-native-templates/PROJECT_STATE.template.md @@ -0,0 +1,39 @@ +# 项目当前状态 + +| 项 | 内容 | +| --- | --- | +| 最近更新 | YYYY-MM-DD | +| 当前分支 | 按实际填写 | +| 当前阶段 | 按实际填写 | +| 当前重点 | 按实际填写 | + +## 1. 当前 Feature 或 Checkpoint + +- 名称: +- 状态:Draft / In Progress / Blocked / Ready for Review / Done +- 目标: +- 验收标准: + +## 2. 当前优先级 + +1. 第一优先级: +2. 第二优先级: +3. 第三优先级: + +## 3. 已确认事实 + +- 记录对后续开发有影响的当前事实。 +- 只写仍然有效的事实,不写长篇历史。 + +## 4. Known Issues + +- 记录当前已知问题、风险和待验证点。 + +## 5. Next Steps + +- 下一步最小动作。 +- 下一个建议 checkpoint。 + +## 6. 文档同步提醒 + +完成 Feature 后检查 Domain、Architecture、Workflow、ADR、Spec、Project State 是否需要更新。 diff --git a/docs/import/reusable/ai-native-templates/README.md b/docs/import/reusable/ai-native-templates/README.md new file mode 100644 index 0000000..2ddf52a --- /dev/null +++ b/docs/import/reusable/ai-native-templates/README.md @@ -0,0 +1,21 @@ +# AI-NSES 模板目录 + +本目录保存可复制到新项目的 AI-NSES 文档模板。 + +使用方式: + +1. 复制需要的模板到新项目对应位置。 +2. 删除模板中的示例说明。 +3. 补充项目真实信息。 +4. 在项目 `AGENTS.md` 和项目文档索引中加入入口。 + +模板清单: + +- `AGENTS.template.md`:AI Agent 协作入口模板。 +- `CONTEXT.template.md`:项目背景入口模板。 +- `PROJECT_STATE.template.md`:项目当前状态模板。 +- `DOMAIN_OBJECT.template.md`:业务对象文档模板。 +- `WORKFLOW.template.md`:业务流程文档模板。 +- `ADR.template.md`:架构决策记录模板。 +- `SPEC.template.md`:功能规格模板。 +- `ARCHITECTURE.template.md`:架构说明模板。 diff --git a/docs/import/reusable/ai-native-templates/SPEC.template.md b/docs/import/reusable/ai-native-templates/SPEC.template.md new file mode 100644 index 0000000..386da21 --- /dev/null +++ b/docs/import/reusable/ai-native-templates/SPEC.template.md @@ -0,0 +1,49 @@ +# Feature Spec 标题 + +| 项 | 内容 | +| --- | --- | +| 状态 | Draft / Approved / Implemented / Archived | +| 日期 | YYYY-MM-DD | +| 负责人 | 按实际填写 | + +## 1. 背景 + +说明为什么要做这个功能。 + +## 2. 目标 + +- 目标 1: +- 目标 2: + +## 3. 非目标 + +- 不做事项 1: +- 不做事项 2: + +## 4. 用户与场景 + +说明谁会使用这个能力,在哪些场景使用。 + +## 5. 业务规则 + +- 规则 1: +- 规则 2: + +## 6. 接口或交互契约 + +说明请求、响应、权限、安全、审计和兼容性要求。 + +## 7. 验收标准 + +- Given / When / Then: +- Given / When / Then: + +## 8. 测试范围 + +- 单元测试: +- 集成测试: +- 手工验证: + +## 9. 文档更新 + +完成后检查 Domain、Architecture、Workflow、ADR、Project State 是否需要更新。 diff --git a/docs/import/reusable/ai-native-templates/WORKFLOW.template.md b/docs/import/reusable/ai-native-templates/WORKFLOW.template.md new file mode 100644 index 0000000..99f26f3 --- /dev/null +++ b/docs/import/reusable/ai-native-templates/WORKFLOW.template.md @@ -0,0 +1,37 @@ +# 业务流程名称 + +## 1. 目标 + +说明这个流程要完成什么业务目标。 + +## 2. 触发条件 + +说明流程从哪里开始。 + +## 3. 输入 + +| 输入 | 中文说明 | 来源 | +| --- | --- | --- | +| | | | + +## 4. 输出 + +| 输出 | 中文说明 | 去向 | +| --- | --- | --- | +| | | | + +## 5. 正常流程 + +1. 步骤一。 +2. 步骤二。 +3. 步骤三。 + +## 6. 异常与补偿 + +- 异常: +- 补偿: +- 审计: + +## 7. 边界 + +说明哪些事情属于本流程,哪些事情不属于本流程。 diff --git a/docs/project/README.md b/docs/project/README.md index 8031c94..48b432b 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -17,7 +17,10 @@ | 文档 | 状态 | 中文说明 | | --- | --- | --- | | `../../AGENTS.md` | 当前有效 | 项目协作入口,记录 agent 工作方式、分支、目录、前后端边界、安全和测试要求。 | +| `../../CONTEXT.md` | 当前有效 | 项目长期上下文入口,记录产品目标、技术栈、系统组成、业务领域和外部系统边界。 | +| `../../PROJECT_STATE.md` | 当前有效 | 项目当前状态入口,记录当前 checkpoint、优先级、Known Issues 和 Next Steps;允许高频更新。 | | `../../README.md` | 当前有效 | 项目根说明,记录目录、启动命令、健康检查、Debug EML 和 MCP 基础说明。 | +| `ai-native-adoption.md` | 当前有效 | 本项目采用 AI-NSES 的路径说明,记录标准目录与当前目录的映射关系。 | | `backend-development-guidelines.md` | 当前有效 | 当前项目后端专属规范。 | | `backend-time-design.md` | 当前有效 | 当前项目时间设计说明,记录数据库 UTC、API `Z` 时间、酒店时区展示和本地日期边界。 | | `security-access-control-boundary.md` | 当前有效 | 当前项目接口暴露、权限码、酒店隔离和审计边界总表;新增或修改接口时必须同步。 | @@ -25,6 +28,14 @@ | `frontend-backend/README.md` | 当前有效 | 前后端协作入口,记录接口契约来源、字段白名单和当前后置事项。 | | `go-live-notes.md` | 当前有效 | 当前项目上线注意事项,记录上线前检查、环境变量、安全、AgentBus、验证和回滚。 | +## AI-NSES 与可复用规范 + +| 文档 | 状态 | 中文说明 | +| --- | --- | --- | +| `../import/reusable/ai-native-software-engineering-standard.md` | 当前有效 | 可复制到其他项目的 AI-NSES 通用标准,定义项目文档结构、文档职责、Feature 生命周期和 AI 工作原则。 | +| `../import/reusable/ai-native-templates/README.md` | 当前有效 | AI-NSES 模板目录索引,包含 AGENTS、CONTEXT、PROJECT_STATE、Domain、Workflow、ADR、Spec 和 Architecture 模板。 | +| `../import/reusable/README.md` | 当前有效 | 可复用迁移规范总索引。 | + ## 需求与方案 | 文档 | 状态 | 中文说明 | @@ -75,6 +86,7 @@ - SuperAgent 对外 HTTP 接口以 `integrations/superagent-api-contract.md` 为权威来源。 - SuperAgent MCP 文档以 `integrations/superagent-mcp/` 为对外交付资料包,但字段语义应跟随 HTTP 总契约。 - 接口暴露、权限、酒店隔离和审计边界以 `security-access-control-boundary.md` 为总检查清单;具体 SuperAgent / MCP / AgentBus 请求响应契约仍以 `integrations/` 下对应文档为准。 +- AI-NSES 的通用标准以 `../import/reusable/ai-native-software-engineering-standard.md` 为复用来源;本项目采用方式以 `ai-native-adoption.md` 为准。 - 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/ai-native-adoption.md b/docs/project/ai-native-adoption.md new file mode 100644 index 0000000..fbb1de0 --- /dev/null +++ b/docs/project/ai-native-adoption.md @@ -0,0 +1,80 @@ +# 本项目 AI-NSES 落地说明 + +## 1. 目标 + +本文说明 TH Hotel Simple 如何采用 AI-Native Software Engineering Standard (AI-NSES)。 + +本次落地只建立文档标准化入口,不搬迁现有历史文档,不删除已有文档,不改变代码结构。 + +## 2. 当前采用方式 + +AI-NSES 推荐目录和本项目当前目录的映射如下: + +| AI-NSES 角色 | 本项目当前位置 | 中文说明 | +| --- | --- | --- | +| Agent 工作规则 | `AGENTS.md` | 当前项目协作、开发、安全、分支和测试规则。 | +| 项目长期上下文 | `CONTEXT.md` | 产品目标、系统组成、技术栈、业务领域和外部系统边界。 | +| 项目当前状态 | `PROJECT_STATE.md` | 当前 checkpoint、优先级、已确认事实、Known Issues 和 Next Steps。 | +| 项目文档索引 | `docs/project/README.md` | 当前项目专属文档总索引和权威来源说明。 | +| 通用规范 | `docs/import/reusable/` | 可复制到后续项目的通用标准、开发规范和模板。 | +| 当前项目需求 / Spec | `docs/project/requirements/` | 当前阶段仍沿用既有目录保存功能需求和方案。 | +| 当前项目集成契约 | `docs/project/integrations/` | SuperAgent、AgentBus 和 MCP 对接资料。 | +| 当前项目安全边界 | `docs/project/security-access-control-boundary.md` | 接口暴露、权限、酒店隔离和审计边界总表。 | +| 当前项目前后端协作 | `docs/project/frontend-backend/` | 前后端字段、接口和调试页面协作入口。 | + +## 3. 暂不搬迁的原因 + +当前项目已经有大量历史需求、集成契约、前后端协作文档和导入资料。 + +如果一次性把这些文档搬到 `docs/domain/`、`docs/architecture/`、`docs/workflows/`、`docs/specs/`,会带来以下风险: + +- 历史链接失效。 +- 当前开发基线不容易判断。 +- 大量文件移动会干扰代码 Review。 +- 新 Agent 反而需要同时理解旧路径和新路径。 + +因此当前阶段采用“入口标准化 + 索引映射”的方式。 + +## 4. 后续演进原则 + +- 新增重要 Feature 时,优先形成独立 Spec。 +- 新增重要架构决策时,新增 ADR,不覆盖历史。 +- 新增稳定业务概念时,再补 Domain 文档。 +- 新增跨模块流程时,再补 Workflow 文档。 +- 只有当迁移能降低理解成本时,才考虑移动旧文档。 +- `PROJECT_STATE.md` 是唯一允许高频更新的顶层项目状态文档。 + +## 5. Agent 默认阅读顺序 + +新的 Agent 进入项目后,默认先读: + +1. `AGENTS.md` +2. `CONTEXT.md` +3. `PROJECT_STATE.md` +4. `README.md` +5. `docs/project/README.md` +6. 当前任务相关的需求、集成、安全或前后端协作文档 + +涉及接口、权限、审计、酒店隔离或敏感数据返回时,必须阅读 `docs/project/security-access-control-boundary.md`。 + +## 6. Feature Definition of Done + +功能完成前需要确认: + +- 代码或文档是否满足本次验收标准。 +- 对应测试或基础检查是否运行。 +- `PROJECT_STATE.md` 是否需要更新。 +- 需求、Spec、Workflow、ADR 或集成契约是否需要更新。 +- 安全、权限、酒店隔离和审计边界是否受影响。 + +如果没有文档变化,应在交付说明中明确: + +```text +No documentation changes required. +``` + +## 7. 给后续 Agent 的一句话 + +本项目的长期记忆应优先来自仓库文档,而不是历史聊天记录。 + +遇到聊天记录和仓库文档不一致时,先以 `AGENTS.md`、`CONTEXT.md`、`PROJECT_STATE.md` 和 `docs/project/README.md` 中的当前有效文档为准;如果仍有冲突,再向用户确认。 diff --git a/docs/project/backend-development-guidelines.md b/docs/project/backend-development-guidelines.md index 221e7a4..51e9ff8 100644 --- a/docs/project/backend-development-guidelines.md +++ b/docs/project/backend-development-guidelines.md @@ -12,6 +12,8 @@ Runtime、Planner、Memory 或通用工具调用框架。 当前项目时间存储、接口返回、酒店时区展示和本地业务日期边界统一参考 `docs/project/backend-time-design.md`。 +当前项目接口暴露、权限、酒店隔离和审计边界统一参考 `docs/project/security-access-control-boundary.md`。新增或修改 Controller、第三方入口、调试入口或 worker 触发入口时,必须同步检查该文档。 + ## 2. 技术栈 以 `server/pom.xml` 为准,当前后端技术栈如下: @@ -278,7 +280,45 @@ Secret 只能通过 `.env`、环境变量或部署平台 Secret 注入。仓库 - 后端 Secret 不得放入前端 `VITE_*`。 - 生产环境应拆分 ConfigMap / 非敏感环境变量与 Secret / 密钥管理系统。 -## 14. API 设计规范 +## 14. 接口暴露、权限和审计边界 + +新增或修改后端入口前,必须先判定接口分类: + +```text +PUBLIC +FRONTEND_USER +FRONTEND_ADMIN +FRONTEND_DEBUG +THIRD_PARTY_SUPERAGENT +THIRD_PARTY_AGENTBUS +THIRD_PARTY_MCP +INTERNAL_ONLY +``` + +中文说明: + +| 分类 | 中文含义 | 后端落地要求 | +| --- | --- | --- | +| `PUBLIC` | 公开基础接口 | 只能返回健康、登录等非敏感信息,不返回业务数据和配置细节 | +| `FRONTEND_USER` | 普通业务前端接口 | 目标状态必须登录、权限码和酒店访问权控制 | +| `FRONTEND_ADMIN` | 系统管理后台接口 | 必须登录、管理权限码和管理审计 | +| `FRONTEND_DEBUG` | Debug、Demo、Replay、Probe 等调试接口 | 必须有环境开关、受控 access key 或管理权限,生产默认关闭或严格限制 | +| `THIRD_PARTY_SUPERAGENT` | SuperAgent 服务到服务接口 | 使用 HMAC、timestamp、nonce、body hash 等机器鉴权,不使用用户 Bearer token | +| `THIRD_PARTY_AGENTBUS` | AgentBus 实时入口 | 使用 AgentBus Token、入库幂等和受控 dispatch,不直接改业务状态 | +| `THIRD_PARTY_MCP` | MCP 工具入口 | 使用 Bearer Token 和工具级能力限制,不暴露无关业务接口 | +| `INTERNAL_ONLY` | 后端内部能力 | 不提供外部 HTTP 入口,只能通过 Service、Port、Worker 或 Adapter 内部调用 | + +落地规则: + +- Controller 方法新增前必须明确调用方、鉴权方式、权限码、酒店隔离方式、敏感字段返回边界和审计要求。 +- 前端业务接口最终应使用登录态、权限码和酒店访问权;第三方机器接口不得误用用户登录态。 +- 后端内部 Adapter、Repository、Mapper、Secret、外部系统 client、raw payload 和原始附件读取能力不得直接暴露给前端或第三方。 +- 邮件正文、HTML、附件 URL、AI 原始 payload、trace、调试错误摘要等敏感数据返回前必须确认调用方和审计策略。 +- 写接口必须明确 actor 来源;前端用户写操作使用当前登录用户,SuperAgent、MCP、AgentBus 使用机器身份。 +- 新增或变更接口必须同步更新 `docs/project/security-access-control-boundary.md`。影响前端时同步更新 `docs/project/frontend-backend/backend-to-frontend-notes.md`,影响 SuperAgent / MCP / AgentBus 时同步更新对应集成契约文档。 +- 新增接口权限时必须执行 `docs/project/security-access-control-boundary.md` 中“新增接口权限固定流程”:定义权限码、补启动同步、补内置角色矩阵、后端强制校验、前端权限类型和交互同步、系统设置可分配、补测试和文档。 + +## 15. API 设计规范 - API 返回稳定代码,不返回中文或英文文本作为前端业务判断依据。 - 动态表单字段使用稳定 `fieldKey` 和可国际化 `labelKey`。 @@ -287,7 +327,7 @@ Secret 只能通过 `.env`、环境变量或部署平台 Secret 注入。仓库 - 外部写操作结果不明确时,禁止盲目重试。 - 调试接口必须默认关闭,并使用独立访问密钥。 -## 15. 测试与检查命令 +## 16. 测试与检查命令 后端最低检查: @@ -308,7 +348,7 @@ cd server 如果命令尚未配置或因环境问题无法运行,必须明确说明,不能假装通过。 -## 16. Git 与协作流程 +## 17. Git 与协作流程 - 改动前说明目标、范围和将修改的文件。 - 每次只处理一个模块或一个纵向切片。 @@ -318,11 +358,13 @@ cd server - 修改后运行项目已配置的检查命令。 - Git commit message 使用中文,清楚说明本次提交的业务或技术变更。 -## 17. 后端提交前检查清单 +## 18. 后端提交前检查清单 - [ ] 是否遵守 platform / workflows / integrations 分层? +- [ ] 是否已在 `docs/project/security-access-control-boundary.md` 中登记或更新接口分类、鉴权方式、权限码、酒店隔离和审计要求? - [ ] 模块内部是否遵守 `control` / `service` / `service.impl` / `domain` / `mapper` / `repository` / `common.dto` / `common.request` / `common.result` / `common.enums` 目录规则? - [ ] Controller 是否没有直接访问 Mapper 或外部 Adapter? +- [ ] 第三方接口是否没有误用用户登录权限,前端接口是否没有直接暴露 Secret、raw payload 或外部系统调用能力? - [ ] Request、Result、DTO 是否放在 `common.request`、`common.result`、`common.dto`,而不是混在 `service` 或临时 `application` 包? - [ ] `control`、`service`、`service.impl`、`repository` 方法是否有中文注释? - [ ] Mapper 自定义语义化方法是否按复杂度合理补充中文注释,且未为了 MyBatis-Plus 自带继承方法强行加无意义注释? diff --git a/docs/project/frontend-backend/frontend-to-backend-api-requests.md b/docs/project/frontend-backend/frontend-to-backend-api-requests.md index 3e23633..fc99374 100644 --- a/docs/project/frontend-backend/frontend-to-backend-api-requests.md +++ b/docs/project/frontend-backend/frontend-to-backend-api-requests.md @@ -40,9 +40,9 @@ | `POST /api/reservation/tasks/{taskId}/manual-review-resolutions` | 已完成第一版 | 可以 | 只用于 type-known manual review;第一版 `confirmed_order_id` 必须等于当前任务订单,不开放普通任务任意切换订单。 | | `GET /api/source-messages` | 已完成安全摘要列表 | 可以 | 不能替代邮件会话全文接口。 | | `GET /api/source-messages/{id}` | 已完成单条安全摘要 | 可以 | 不能替代邮件会话全文接口。 | -| `GET /api/source-messages/{id}/original` | 已完成单封原文受控读取 | 谨慎接入 | 只能读单封邮件,不能返回同一 conversation 全量邮件。 | +| `GET /api/source-messages/{id}/original` | 已完成单封原文权限读取 | 谨慎接入 | 必须带 Bearer token,需要同时拥有 `SOURCE_MESSAGE_READ` 和 `SOURCE_MESSAGE_ORIGINAL_READ`;只能读单封邮件,不能返回同一 conversation 全量邮件。 | | `GET /api/reservation/orders` | 已完成第一版 | 可以 | 默认查询全部订单状态;`open_task_count` 排除 `COMPLETED` 和 `FAILED`。 | -| `GET /api/source-messages/{sourceMessageId}/conversation` | 已完成第一版,已补 `html_body_sanitized` 和 `html_render_mode` | 可以 | 返回完整 text/html、后端清洗后的 HTML、媒体外链和关联订单 / 任务摘要;前端不传原文读取 key,页面展示优先使用 `html_body_sanitized`。 | +| `GET /api/source-messages/{sourceMessageId}/conversation` | 已完成第一版,已补 `html_body_sanitized` 和 `html_render_mode` | 可以 | 必须带 Bearer token,需要同时拥有 `SOURCE_MESSAGE_READ` 和 `SOURCE_MESSAGE_ORIGINAL_READ`;返回完整 text/html、后端清洗后的 HTML、媒体外链和关联订单 / 任务摘要;前端不传原文读取 key,页面展示优先使用 `html_body_sanitized`。 | | `POST /api/system/reservation/demo-data` | 已完成 | 仅本地 / test 联调可用 | 默认关闭,必须后端配置访问口令;不能作为生产页面接口。 | | `POST /api/system/debug/eml-superagent-runs` | 已完成第一版 | 仅 dev/test Debug 页面可用 | 默认关闭,必须后端配置访问口令、阿里云 OSS 和 SuperAgent Open API;第一版只展示 SuperAgent 结果,不创建订单和任务;已能识别旧 S000/S999 和新结构化 S10/S99。 | | `GET /api/source-message-conversations/{externalConversationId}` | 未发现后端实现 | 不可以 | 历史讨论过的候选路径,当前不提供;前端统一使用 `GET /api/source-messages/{sourceMessageId}/conversation`。 | @@ -378,10 +378,11 @@ GET /api/source-message-conversations/{externalConversationId} 中文说明: +- 该接口已经完成权限收口:请求必须带 `Authorization: Bearer `,当前用户必须同时拥有 `SOURCE_MESSAGE_READ` 和 `SOURCE_MESSAGE_ORIGINAL_READ`,后端会按 SourceMessage 实际所属酒店校验访问权。 - “全部邮件”指同一个 `externalConversationId` 下的历史邮件、当前邮件和后续回复,不是只展示任务对应的单封来源邮件。 - 邮件会话详情页需要展示完整正文或清洗后的 HTML、附件、内联图片、发件人展示值、发送 / 接收时间、主题和关联订单 / 任务。 - 前端不在页面上做业务截断或隐藏;但仍只调用本项目后端接口,不直接访问邮箱、AgentBus、数据库或外部附件 URL Secret。 -- 如果后端仍需要审计原文读取,应由后端在该业务接口内部处理;前端不保存 `X-TH-Hotel-Source-Original-Read-Key` 一类受控访问 key。 +- 原文读取审计由后端在该业务接口内部处理,actor 使用当前登录用户稳定 ID;前端不保存或传递 `X-TH-Hotel-Source-Original-Read-Key` 一类受控访问 key。 - 2026-07-08 后端已新增 `html_body_sanitized` 和 `html_render_mode`;前端页面展示邮件 HTML 时应优先使用 `html_body_sanitized`,`html_body` 只作为原始内容兼容字段,不建议生产直渲。 - 第一版仅处理 HTML 内容清洗;附件和内联图片 URL 来自本系统 OSS 服务,暂不做额外拦截或代理转换。 diff --git a/docs/project/integrations/superagent-agentbus-project-integration-guide.md b/docs/project/integrations/superagent-agentbus-project-integration-guide.md index d7387e6..df5c56f 100644 --- a/docs/project/integrations/superagent-agentbus-project-integration-guide.md +++ b/docs/project/integrations/superagent-agentbus-project-integration-guide.md @@ -294,8 +294,6 @@ AGENTBUS_SUPERAGENT_DISPATCH_ENABLED=false AGENTBUS_SUPERAGENT_DISPATCH_WORKER_ENABLED=false AGENTBUS_REPLY_MODE=NONE AUTH_DEV_BOOTSTRAP_DEFAULT_HOTEL_ID=HOTEL-DEV -SOURCE_MESSAGE_DEV_ORIGINAL_READ_ACCESS_KEY= -SOURCE_MESSAGE_ORIGINAL_READ_ACCESS_KEY= ``` 变量说明: @@ -322,10 +320,7 @@ SOURCE_MESSAGE_ORIGINAL_READ_ACCESS_KEY= | `SUPERAGENT_OPEN_API_SSE_RECOVERY_MAX_ATTEMPTS` | 否 | SSE 断流恢复最大次数,默认 `5`。 | | `AUTH_DEV_BOOTSTRAP_DEFAULT_HOTEL_ID` | 否 | dev 初始化平台酒店;M005 后 AgentBus 捕获运行时从 `platform_hotel` 唯一 `ACTIVE` 酒店解析系统酒店,不再依赖 `AGENTBUS_DEFAULT_HOTEL_ID`。 | | `AGENTBUS_REPLY_MODE` | 否 | 调试回复模式。真实客户渠道应保持 `NONE`。 | -| `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,只能通过生产 Secret 注入。 | -| `SOURCE_MESSAGE_ORIGINAL_READ_ACCESS_KEY` | 是 | 旧通用原文读取 key,仅作为兼容兜底。 | +| `SOURCE_MESSAGE_*_ORIGINAL_READ_ACCESS_KEY` | 否 | 已废弃。SourceMessage 原文 / 会话正文读取已迁移到前端 Bearer 登录 + `SOURCE_MESSAGE_READ` + `SOURCE_MESSAGE_ORIGINAL_READ` + 酒店访问权,不再配置原文读取 key。 | ### 5.2 WebSocket 连接 @@ -599,10 +594,6 @@ MessageEvent - `DEERFLOW_OPEN_API_KEY` - `SUPERAGENT_PROBE_ACCESS_KEY` - `AGENTBUS_WS_TOKEN` -- `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` - `SOURCE_MESSAGE_REPLAY_ACCESS_KEY` - 数据库密码 - 任何真实客户渠道 Token diff --git a/docs/project/requirements/M001-source-message-inbox-prd.md b/docs/project/requirements/M001-source-message-inbox-prd.md index 9c88029..e260c66 100644 --- a/docs/project/requirements/M001-source-message-inbox-prd.md +++ b/docs/project/requirements/M001-source-message-inbox-prd.md @@ -219,7 +219,7 @@ SOURCE_MESSAGE_READ // 邮件来源记录读取权限:允许查看列表和摘要详情 SOURCE_MESSAGE_ORIGINAL_READ -// 邮件原文读取权限:允许读取正文、HTML、正文图片 URL 和附件 URL +// 邮件原文读取附加权限:需叠加 SOURCE_MESSAGE_READ,允许读取正文、HTML、正文图片 URL 和附件 URL ``` ## 6. Success Metrics @@ -233,7 +233,7 @@ SOURCE_MESSAGE_ORIGINAL_READ - 重复投递去重成功率:重复外部邮件不会重复创建 SourceMessage。 - 查询可用性:可以按时间、状态、外部邮件 ID、外部邮件链 ID 查询本项目已接收邮件。 -- 原文读取可控性:只有具备 `SOURCE_MESSAGE_ORIGINAL_READ` 权限的调用方可以读取原文和媒体 URL。 +- 原文读取可控性:只有同时具备 `SOURCE_MESSAGE_READ` 和 `SOURCE_MESSAGE_ORIGINAL_READ` 权限的调用方可以读取原文和媒体 URL。 ### Guardrail Metrics @@ -310,10 +310,10 @@ so that 后续业务功能可以在需要时展示完整邮件上下文 Acceptance Criteria: -- Given 调用方具备 `SOURCE_MESSAGE_ORIGINAL_READ` 权限 +- Given 调用方同时具备 `SOURCE_MESSAGE_READ` 和 `SOURCE_MESSAGE_ORIGINAL_READ` 权限 When 调用邮件原文读取接口 Then 返回 `textBody`、`htmlBody`、`inlineImages[]`、`attachments[]` -- Given 调用方不具备 `SOURCE_MESSAGE_ORIGINAL_READ` 权限 +- Given 调用方不具备 `SOURCE_MESSAGE_READ` 或 `SOURCE_MESSAGE_ORIGINAL_READ` 任一权限 When 调用邮件原文读取接口 Then 系统拒绝访问 - Given 原文接口返回 `htmlBody` @@ -347,9 +347,9 @@ Acceptance Criteria: ### Dependencies - AgentBus payload 字段稳定性:依赖 `source.external_message_id`、`source.external_conversation_id`、`body.text`、`body.html`、`inline_images[]`、`attachments[]`。 -- 权限体系:需要后续确认 `SOURCE_MESSAGE_READ` 和 `SOURCE_MESSAGE_ORIGINAL_READ` 如何落到角色。 -- 审计体系:需要记录原文读取行为,具体表结构可与平台审计能力统一设计。 -- HTML 安全展示:前端展示 HTML 前必须 sanitize,后端接口文档也要明确该约束。 +- 权限体系:已落地 `SOURCE_MESSAGE_READ` 和 `SOURCE_MESSAGE_ORIGINAL_READ`,内置角色映射以 M003 和当前代码为准。 +- 审计体系:原文读取已使用 `platform_source_message_original_access_audit` 独立记录。 +- HTML 安全展示:后端已返回 `html_body_sanitized` / `html_render_mode`,前端生产展示优先使用清洗字段。 ### Risks & Mitigations @@ -358,7 +358,7 @@ Acceptance Criteria: - Risk:重复投递 payload 内容不同,覆盖原始事实会破坏追溯。 Mitigation:幂等命中后不覆盖原 payload,差异通过安全摘要或后续 attempt 记录表达。 - Risk:邮件原文或媒体 URL 在列表、日志、错误响应中过度暴露。 - Mitigation:列表只返回安全摘要;原文接口独立权限;日志和错误响应脱敏。 + Mitigation:列表只返回安全摘要;原文接口必须同时校验摘要读取和原文读取权限;日志和错误响应脱敏。 - Risk:业务模块直接依赖 AgentBus DTO。 Mitigation:业务模块只依赖 SourceMessage ID 和平台接口;AgentBus DTO 只留在 `integrations.messaging.agentbus`。 - Risk:HTML 原文直接渲染带来安全问题。 @@ -368,10 +368,10 @@ Acceptance Criteria: | 问题 | 负责人 | 状态 | | --- | --- | --- | -| 第一个 checkpoint 是否直接实现 `/api/source-messages/{id}/original` 原文读取接口,还是只写接口契约并后置实现? | 产品 / 后端 | Open | -| `SOURCE_MESSAGE_READ` 和 `SOURCE_MESSAGE_ORIGINAL_READ` 第一阶段如何映射到角色? | 产品 / 后端 | Open | -| 原文读取审计是否使用独立表,还是复用后续统一审计能力? | 后端 | Open | -| HTML sanitize 主要在前端完成,还是后端也提供清洗后的安全 HTML? | 前端 / 后端 | Open | +| 第一个 checkpoint 是否直接实现 `/api/source-messages/{id}/original` 原文读取接口,还是只写接口契约并后置实现? | 产品 / 后端 | Closed:已实现单封原文读取,并补充会话完整正文接口。 | +| `SOURCE_MESSAGE_READ` 和 `SOURCE_MESSAGE_ORIGINAL_READ` 第一阶段如何映射到角色? | 产品 / 后端 | Closed:内置 `RESERVATION_OPERATOR` 含原文读取权限,`RESERVATION_VIEWER` 仅含安全摘要读取权限;系统管理员拥有全部权限。 | +| 原文读取审计是否使用独立表,还是复用后续统一审计能力? | 后端 | Closed:使用 `platform_source_message_original_access_audit` 独立记录原文读取审计。 | +| HTML sanitize 主要在前端完成,还是后端也提供清洗后的安全 HTML? | 前端 / 后端 | Closed:后端返回 `html_body_sanitized` / `html_render_mode`,前端生产展示优先使用清洗字段。 | | 是否需要第一阶段支持按正文摘要关键字搜索,还是只按 ID、时间、状态、邮件链查询? | 产品 | Open | ## PRD Self-Assessment diff --git a/docs/project/requirements/M003-identity-access-hotel-menu-v1.md b/docs/project/requirements/M003-identity-access-hotel-menu-v1.md index 600fd78..9d73e64 100644 --- a/docs/project/requirements/M003-identity-access-hotel-menu-v1.md +++ b/docs/project/requirements/M003-identity-access-hotel-menu-v1.md @@ -263,7 +263,7 @@ platform.security | 权限码 | 中文说明 | | --- | --- | | `SOURCE_MESSAGE_READ` | 查看来源消息安全摘要 | -| `SOURCE_MESSAGE_ORIGINAL_READ` | 查看来源消息原文和媒体 URL | +| `SOURCE_MESSAGE_ORIGINAL_READ` | 查看来源消息原文和媒体 URL;必须叠加 `SOURCE_MESSAGE_READ` 使用 | | `RESERVATION_ORDER_READ` | 查看订单 | | `RESERVATION_TASK_READ` | 查看任务 | | `RESERVATION_TASK_EDIT` | 保存任务草稿 | @@ -289,7 +289,7 @@ platform.security 中文说明: - `SYSTEM_ADMIN` 用于系统初始化和调试能力,第一版可访问所有启用酒店。 -- `RESERVATION_OPERATOR` 需要查看邮件原文和附件外链来处理任务,因此第一版包含 `SOURCE_MESSAGE_ORIGINAL_READ`。 +- `RESERVATION_OPERATOR` 需要查看邮件原文和附件外链来处理任务,因此第一版同时包含 `SOURCE_MESSAGE_READ` 和 `SOURCE_MESSAGE_ORIGINAL_READ`。 - `RESERVATION_VIEWER` 只读查看订单、任务、审计和来源消息安全摘要;不允许保存、确认、执行 OPERA 模拟,也不允许访问 Debug EML。 - `SYSTEM_DEBUG_EML_RUN` 第一版只授予 `SYSTEM_ADMIN`,避免普通业务用户触发 SuperAgent 调试链路。 - 启动初始化会按上表同步内置角色权限矩阵:矩阵中新增的权限会补齐,矩阵中移除的旧关系会清理。后续如果管理后台允许人工改内置角色,需要先重新确认“代码矩阵”和“后台配置”的优先级。 @@ -477,9 +477,9 @@ Authorization: Bearer 后续强制鉴权时应按接口分批启用: -1. 查询类接口先要求登录和酒店权限。 +1. 查询类接口先要求登录和酒店权限。已完成:Reservation / SourceMessage 第一批只读查询。 2. 写操作再要求具体操作权限。 -3. 邮件原文读取迁移到 `SOURCE_MESSAGE_ORIGINAL_READ`。 +3. 邮件原文读取迁移到 `SOURCE_MESSAGE_READ` + `SOURCE_MESSAGE_ORIGINAL_READ`。已完成:`/api/source-messages/{id}/original` 和 `/api/source-messages/{id}/conversation`。 4. OPERA 模拟迁移到 `RESERVATION_OPERA_SIM_EXECUTE`。 5. 审计查询迁移到 `RESERVATION_AUDIT_READ`。 @@ -519,7 +519,7 @@ Authorization: Bearer | 用户最终确认任务 | 有 token 时记录当前用户;无 token 时继续兼容本地占位 | | Fallback 转换 | 有 token 时记录当前用户 | | OPERA 模拟执行 / 重试 | 有 token 时记录当前用户 | -| 邮件原文读取 | 后续从 access-key 迁移到 `SOURCE_MESSAGE_ORIGINAL_READ` 权限 | +| 邮件原文读取 | 已从 access-key 迁移到 `SOURCE_MESSAGE_READ` + `SOURCE_MESSAGE_ORIGINAL_READ` 权限,并记录当前登录用户稳定 ID | 第一期不强制改完所有业务审计 actor,但需要提供可复用的当前用户上下文接口。 @@ -602,7 +602,7 @@ AUTH_TEST_SESSION_TTL_MINUTES=720 - Reservation 查询接口校验登录和酒店权限。 - Reservation 写接口校验具体权限。 -- SourceMessage 原文读取迁移到 `SOURCE_MESSAGE_ORIGINAL_READ`。 +- SourceMessage 原文读取迁移到 `SOURCE_MESSAGE_READ` + `SOURCE_MESSAGE_ORIGINAL_READ`。已完成 CP2,后续保持回归。 - 审计 actor 全面迁移到当前用户。 ### CP4:管理后台接口 diff --git a/docs/project/requirements/M006-system-admin-management-console-v1.md b/docs/project/requirements/M006-system-admin-management-console-v1.md index e64b59a..47328d4 100644 --- a/docs/project/requirements/M006-system-admin-management-console-v1.md +++ b/docs/project/requirements/M006-system-admin-management-console-v1.md @@ -840,7 +840,7 @@ GET /api/admin/audits - Reservation 查询接口强制登录和酒店权限。 - Reservation 写接口强制具体操作权限。 -- SourceMessage 原文读取迁移到 `SOURCE_MESSAGE_ORIGINAL_READ`。 +- SourceMessage 原文读取迁移到 `SOURCE_MESSAGE_READ` + `SOURCE_MESSAGE_ORIGINAL_READ`。已通过接口权限与酒店隔离收口 CP2 完成。 - 业务审计 actor 全面迁移到当前用户。 该阶段和 M003 CP3、M005 酒店上下文统一收口有关,建议单独拆文档和任务。 diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageController.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageController.java index 8462ad1..4726060 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageController.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageController.java @@ -12,9 +12,9 @@ import cn.nianxx.thhotel.platform.message.service.SourceMessageConversationServi import cn.nianxx.thhotel.platform.message.service.SourceMessageOriginalService; import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse; +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -22,12 +22,16 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; /** - * SourceMessage Inbox 安全查询接口。该 Controller 不返回正文、HTML、附件 URL 或原始 payload。 + * SourceMessage Inbox 查询接口。摘要接口只返回安全信息,原文和会话正文接口必须额外校验原文读取权限。 */ @RestController @RequestMapping("/api/source-messages") public class SourceMessageController { + private static final String FRONTEND_ACTOR_PREFIX = "frontend_user:"; + private static final String ORIGINAL_ACCESS_SCENE = "source-message-original"; + private static final String CONVERSATION_ACCESS_SCENE = "source-message-conversation"; + private final SourceMessageQueryService queryService; private final SourceMessageOriginalService originalService; private final SourceMessageConversationService conversationService; @@ -90,35 +94,35 @@ public class SourceMessageController { } /** - * 读取 SourceMessage 原文内容。该接口必须携带受控访问口令、调用方和访问场景,并会写入审计。 + * 读取 SourceMessage 原文内容。必须登录且拥有原文读取权限,并按消息所属酒店校验访问权。 */ @GetMapping("/{id}/original") - public SourceMessageOriginalResponse original( - @PathVariable Long id, - @RequestHeader(name = "X-TH-Hotel-Source-Original-Read-Key", required = false) String accessKey, - @RequestHeader(name = "X-TH-Hotel-Actor", required = false) String actorId, - @RequestHeader(name = "X-TH-Hotel-Access-Scene", required = false) String accessScene) { - if (!originalService.canReadOriginal(accessKey)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "source message original read denied"); - } - if (!hasText(actorId) || !hasText(accessScene)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "actor and access scene are required"); - } - return originalService.readOriginal(id, new SourceMessageOriginalAccessRequest(actorId, accessScene)) + public SourceMessageOriginalResponse original(@PathVariable Long id) { + AuthenticatedUserContext actor = requireSourceMessageOriginalReadPermission(); + SourceMessageSummaryResponse summary = requireExistingSourceMessage(id); + requireSourceMessageHotelAccess(summary); + return originalService.readOriginal( + id, + new SourceMessageOriginalAccessRequest(actorId(actor), ORIGINAL_ACCESS_SCENE)) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "source message not found")); } /** - * 读取指定 SourceMessage 所在邮件会话完整详情。该接口由后端内部记录原文读取审计,前端不传原文 key。 + * 读取指定 SourceMessage 所在邮件会话完整详情。必须拥有原文读取权限,前端不传原文 key。 */ @GetMapping("/{id}/conversation") public SourceMessageConversationResult conversation(@PathVariable Long id) { - return conversationService.getConversation(id) + AuthenticatedUserContext actor = requireSourceMessageOriginalReadPermission(); + SourceMessageSummaryResponse summary = requireExistingSourceMessage(id); + requireSourceMessageHotelAccess(summary); + return conversationService.getConversation( + id, + new SourceMessageOriginalAccessRequest(actorId(actor), CONVERSATION_ACCESS_SCENE)) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "source message not found")); } /** - * 校验请求头文本是否有效,避免空白调用方或场景进入审计记录。 + * 校验查询参数文本是否有效,避免空白参数影响兼容参数选择。 */ private boolean hasText(String value) { return value != null && !value.trim().isEmpty(); @@ -144,4 +148,27 @@ public class SourceMessageController { private void requireSourceMessageHotelAccess(SourceMessageSummaryResponse summary) { hotelContextService.requireAccessibleHotel(summary.hotelId()); } + + /** + * 查询 SourceMessage 安全摘要用于存在性和酒店访问校验,避免原文读取先于权限边界发生。 + */ + private SourceMessageSummaryResponse requireExistingSourceMessage(Long id) { + return queryService.getSummary(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "source message not found")); + } + + /** + * 校验邮件原文读取权限。原文权限必须叠加摘要读取权限,避免自定义角色只拥有原文权限时绕过摘要边界。 + */ + private AuthenticatedUserContext requireSourceMessageOriginalReadPermission() { + authorizationService.requirePermission(PlatformPermissionCode.SOURCE_MESSAGE_READ.name()); + return authorizationService.requirePermission(PlatformPermissionCode.SOURCE_MESSAGE_ORIGINAL_READ.name()); + } + + /** + * 生成原文读取审计 actor,使用稳定用户 ID 并加前端用户前缀,便于区分机器和系统内部访问。 + */ + private String actorId(AuthenticatedUserContext actor) { + return FRONTEND_ACTOR_PREFIX + actor.userId(); + } } diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerAdvice.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerAdvice.java index 99db6a1..57c19a1 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerAdvice.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerAdvice.java @@ -8,7 +8,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; /** - * SourceMessage 查询接口异常处理。只返回安全错误码和摘要信息,不暴露内部堆栈。 + * SourceMessage 前端接口异常处理。只返回安全错误码和摘要信息,不暴露内部堆栈。 */ @RestControllerAdvice(assignableTypes = SourceMessageController.class) public class SourceMessageControllerAdvice { @@ -25,7 +25,7 @@ public class SourceMessageControllerAdvice { } /** - * 处理 SourceMessage 摘要接口登录或权限不足异常,避免泄漏内部鉴权细节。 + * 处理 SourceMessage 接口登录或权限不足异常,避免泄漏内部鉴权细节。 */ @ExceptionHandler(FrontendAuthorizationException.class) public ResponseEntity> handleFrontendAuthorizationException( diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageConversationService.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageConversationService.java index dcf1feb..0d47148 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageConversationService.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageConversationService.java @@ -1,10 +1,11 @@ package cn.nianxx.thhotel.platform.message.service; +import cn.nianxx.thhotel.platform.message.common.request.SourceMessageOriginalAccessRequest; import cn.nianxx.thhotel.platform.message.common.result.SourceMessageConversationResult; import java.util.Optional; /** - * SourceMessage 邮件会话详情服务。该服务会返回完整正文、HTML 和媒体外链,并写入内部读取审计。 + * SourceMessage 邮件会话详情服务。该服务会返回完整正文、HTML 和媒体外链,并写入读取审计。 */ public interface SourceMessageConversationService { @@ -12,7 +13,10 @@ public interface SourceMessageConversationService { * 读取指定 SourceMessage 所在邮件会话的完整详情。 * * @param sourceMessageId 当前定位的 SourceMessage Inbox ID + * @param accessRequest 当前登录用户和访问场景,用于原文读取审计 * @return 找到时返回同一会话全部邮件详情;不存在时为空 */ - Optional getConversation(Long sourceMessageId); + Optional getConversation( + Long sourceMessageId, + SourceMessageOriginalAccessRequest accessRequest); } diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageOriginalService.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageOriginalService.java index e101e9b..fe3de8e 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageOriginalService.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageOriginalService.java @@ -5,18 +5,10 @@ import cn.nianxx.thhotel.platform.message.common.result.SourceMessageOriginalRes import java.util.Optional; /** - * SourceMessage 原文读取服务。该服务负责权限口令校验、原文读取和访问审计。 + * SourceMessage 原文读取服务。Controller 负责登录、权限和酒店隔离,本服务负责原文读取和访问审计。 */ public interface SourceMessageOriginalService { - /** - * 校验原文读取访问口令,当前用于替代尚未落地的角色权限体系。 - * - * @param submittedAccessKey 调用方提交的原文读取口令 - * @return 口令可用时返回 true - */ - boolean canReadOriginal(String submittedAccessKey); - /** * 按内部 SourceMessage ID 读取原文内容,并记录成功访问审计。 * diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageConversationServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageConversationServiceImpl.java index b1cf90b..a60c0c3 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageConversationServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageConversationServiceImpl.java @@ -6,6 +6,7 @@ import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalAccess import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalContent; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalMediaItem; import cn.nianxx.thhotel.platform.message.common.enums.SourceMessageOriginalAccessResult; +import cn.nianxx.thhotel.platform.message.common.request.SourceMessageOriginalAccessRequest; import cn.nianxx.thhotel.platform.message.common.result.SourceMessageConversationMessageResult; import cn.nianxx.thhotel.platform.message.common.result.SourceMessageConversationResult; import cn.nianxx.thhotel.platform.message.common.result.SourceMessageConversationSummaryResult; @@ -23,13 +24,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; /** - * SourceMessage 邮件会话详情服务实现。该服务负责受控读取原文并写入后端内部审计。 + * SourceMessage 邮件会话详情服务实现。该服务负责受控读取原文并写入当前用户读取审计。 */ @Service public class SourceMessageConversationServiceImpl implements SourceMessageConversationService { - private static final String INTERNAL_ACTOR_ID = "system:source-message-conversation"; - private static final String ACCESS_SCENE = "source-message-conversation"; private static final String MEDIA_TYPE_INLINE_IMAGE = "INLINE_IMAGE"; private static final String MEDIA_TYPE_ATTACHMENT = "ATTACHMENT"; @@ -50,11 +49,15 @@ public class SourceMessageConversationServiceImpl implements SourceMessageConver } /** - * 读取指定 SourceMessage 所在会话的完整原文链路,并为每封邮件写入内部读取审计。 + * 读取指定 SourceMessage 所在会话的完整原文链路,并为每封邮件写入当前用户读取审计。 */ @Override @Transactional - public Optional getConversation(Long sourceMessageId) { + public Optional getConversation( + Long sourceMessageId, + SourceMessageOriginalAccessRequest accessRequest) { + String actorId = requireText(accessRequest.actorId(), "actorId"); + String accessScene = requireText(accessRequest.accessScene(), "accessScene"); Optional sourceOptional = inboxRepository.findById(sourceMessageId); if (sourceOptional.isEmpty()) { return Optional.empty(); @@ -63,7 +66,7 @@ public class SourceMessageConversationServiceImpl implements SourceMessageConver List conversationMessages = findConversationMessages(source); SourceMessageConversationSummaryResult conversation = toConversationSummary(source, conversationMessages); List messages = conversationMessages.stream() - .map(this::toConversationMessage) + .map(message -> toConversationMessage(message, actorId, accessScene)) .toList(); return Optional.of(new SourceMessageConversationResult(conversation, messages)); } @@ -102,8 +105,11 @@ public class SourceMessageConversationServiceImpl implements SourceMessageConver /** * 将 Inbox 快照转换为会话邮件详情,并附带原文、媒体外链和业务关联摘要。 */ - private SourceMessageConversationMessageResult toConversationMessage(SourceMessageInboxSnapshot message) { - SourceMessageOriginalContent originalContent = readOriginalAndAudit(message); + private SourceMessageConversationMessageResult toConversationMessage( + SourceMessageInboxSnapshot message, + String actorId, + String accessScene) { + SourceMessageOriginalContent originalContent = readOriginalAndAudit(message, actorId, accessScene); SourceMessageRelatedContextResult relatedContext = findRelatedContext(message); String htmlBody = originalContent.htmlBody(); return new SourceMessageConversationMessageResult( @@ -126,16 +132,19 @@ public class SourceMessageConversationServiceImpl implements SourceMessageConver } /** - * 读取原文并写入内部审计。会话接口不依赖前端传原文读取 key。 + * 读取原文并写入当前用户审计。会话接口不依赖前端传原文读取 key。 */ - private SourceMessageOriginalContent readOriginalAndAudit(SourceMessageInboxSnapshot message) { + private SourceMessageOriginalContent readOriginalAndAudit( + SourceMessageInboxSnapshot message, + String actorId, + String accessScene) { SourceMessageOriginalContent content = inboxRepository.findOriginalContent(message.id()) .orElse(new SourceMessageOriginalContent(message.id(), null, null, List.of())); LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); inboxRepository.insertOriginalAccessAudit(new SourceMessageOriginalAccessAuditDraft( message.id(), - INTERNAL_ACTOR_ID, - ACCESS_SCENE, + actorId, + accessScene, SourceMessageOriginalAccessResult.GRANTED.code(), now)); return content; @@ -181,4 +190,14 @@ public class SourceMessageConversationServiceImpl implements SourceMessageConver item.externalMediaId()); } + /** + * 校验审计字段,避免空 actor 或访问场景写入原文读取审计。 + */ + private String requireText(String value, String fieldName) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + return value.trim(); + } + } diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageOriginalServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageOriginalServiceImpl.java index 59b803d..734f64a 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageOriginalServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageOriginalServiceImpl.java @@ -14,36 +14,22 @@ import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.List; import java.util.Optional; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; /** - * SourceMessage 原文读取服务实现。当前用受控访问口令替代尚未落地的角色权限体系。 + * SourceMessage 原文读取服务实现。权限边界由 Controller 收口,服务层只负责原文读取和审计写入。 */ @Service public class SourceMessageOriginalServiceImpl implements SourceMessageOriginalService { private final SourceMessageInboxRepository inboxRepository; - private final String configuredAccessKey; /** - * 注入 SourceMessage 持久化边界和原文读取访问口令,服务层负责审计写入。 + * 注入 SourceMessage 持久化边界,服务层负责原文读取审计写入。 */ - public SourceMessageOriginalServiceImpl( - SourceMessageInboxRepository inboxRepository, - @Value("${source-message.original-read.access-key:}") String configuredAccessKey) { + public SourceMessageOriginalServiceImpl(SourceMessageInboxRepository inboxRepository) { this.inboxRepository = inboxRepository; - this.configuredAccessKey = configuredAccessKey; - } - - /** - * 校验原文读取口令。配置为空时默认关闭原文读取能力,避免误开放敏感正文和媒体 URL。 - */ - @Override - public boolean canReadOriginal(String submittedAccessKey) { - String configured = trimToNull(configuredAccessKey); - return configured != null && configured.equals(submittedAccessKey); } /** @@ -122,7 +108,7 @@ public class SourceMessageOriginalServiceImpl implements SourceMessageOriginalSe } /** - * 将空白字符串统一转换为空,避免空白访问口令或审计字段通过校验。 + * 将空白字符串统一转换为空,避免空白审计字段通过校验。 */ private String trimToNull(String value) { if (value == null) { diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java index 648a6cf..34c5a8b 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java @@ -2,6 +2,7 @@ package cn.nianxx.thhotel.workflows.reservation.control; import cn.nianxx.thhotel.platform.security.service.impl.FrontendAuthorizationException; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationWorkflowErrorResponse; +import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationInvoiceGenerationException; import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationTaskWorkflowException; import java.util.List; import org.springframework.http.ResponseEntity; @@ -14,7 +15,8 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; @RestControllerAdvice(assignableTypes = { ReservationTaskController.class, ReservationFrontendQueryController.class, - ReservationDemoDataController.class + ReservationDemoDataController.class, + ReservationInvoiceGenerationController.class }) public class ReservationTaskControllerAdvice { @@ -31,6 +33,19 @@ public class ReservationTaskControllerAdvice { exception.getDetails())); } + /** + * 处理 Reservation Invoice 生成阶段的受控业务异常。 + */ + @ExceptionHandler(ReservationInvoiceGenerationException.class) + public ResponseEntity handleInvoiceGenerationException( + ReservationInvoiceGenerationException exception) { + return ResponseEntity.status(exception.getStatus()) + .body(new ReservationWorkflowErrorResponse( + exception.getErrorCode(), + exception.getMessage(), + exception.getDetails())); + } + /** * 处理前端业务接口登录或权限不足异常,保持 Reservation 错误响应结构稳定。 */ diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerTest.java index c086255..70e7a8c 100644 --- a/server/src/test/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerTest.java @@ -75,6 +75,7 @@ class AuthControllerTest { "RESERVATION_TASK_CONFIRM", "RESERVATION_OPERA_SIM_EXECUTE", "RESERVATION_AUDIT_READ", + "RESERVATION_INVOICE_GENERATE", "HOTEL_SWITCH", "SYSTEM_AUTH_READ", "SYSTEM_USER_MANAGE", diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerTest.java index aab3697..823a9f1 100644 --- a/server/src/test/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerTest.java @@ -28,7 +28,6 @@ import org.springframework.test.web.servlet.MockMvc; @SpringBootTest( classes = ThHotelApplication.class, properties = { - "source-message.original-read.access-key=test-original-read-key", "spring.datasource.url=jdbc:h2:mem:source_message_controller_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE", "auth.bootstrap.admin.username=source-message-admin", "auth.bootstrap.admin.password=Admin@123456", @@ -63,6 +62,18 @@ class SourceMessageControllerTest { return adminToken; } + /** + * 按用户名查询稳定用户 ID,用于校验原文读取审计 actor 不受用户名变更影响。 + */ + private String frontendActorId(String username) { + Long userId = jdbcTemplate.queryForObject(""" + SELECT id + FROM platform_user + WHERE username = ? + """, Long.class, username); + return "frontend_user:" + userId; + } + @Test void shouldListAndReadSummaryWithoutOriginalContentOrMediaUrls() throws Exception { SourceMessageCaptureResult result = captureService.capture(command( @@ -101,7 +112,7 @@ class SourceMessageControllerTest { } @Test - void shouldRejectOriginalReadWithoutAccessKey() throws Exception { + void shouldRejectOriginalReadWithoutLoginToken() throws Exception { SourceMessageCaptureResult result = captureService.capture(command( "mail-original-denied-001", "conversation-original-denied-001", @@ -111,13 +122,13 @@ class SourceMessageControllerTest { )); mockMvc.perform(get("/api/source-messages/{id}/original", result.inboxId()) - .header("X-TH-Hotel-Actor", "operator-001") .header("X-TH-Hotel-Access-Scene", "reservation-detail")) - .andExpect(status().isForbidden()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error_code").value("AUTH_TOKEN_REQUIRED")); } @Test - void shouldReadOriginalContentWithAccessKeyAndRecordAudit() throws Exception { + void shouldReadOriginalContentWithLoginPermissionAndRecordCurrentUserAudit() throws Exception { SourceMessageCaptureResult result = captureService.capture(command( "mail-original-001", "conversation-original-001", @@ -126,8 +137,7 @@ class SourceMessageControllerTest { "https://media.example.test/original.pdf" )); - mockMvc.perform(get("/api/source-messages/{id}/original", result.inboxId()) - .header("X-TH-Hotel-Source-Original-Read-Key", "test-original-read-key") + performAuthorized(mockMvc, adminToken(), get("/api/source-messages/{id}/original", result.inboxId()) .header("X-TH-Hotel-Actor", "operator-001") .header("X-TH-Hotel-Access-Scene", "reservation-detail")) .andExpect(status().isOk()) @@ -143,9 +153,9 @@ class SourceMessageControllerTest { SELECT COUNT(*) FROM platform_source_message_original_access_audit WHERE inbox_id = ? - AND actor_id = 'operator-001' - AND access_scene = 'reservation-detail' - """, Long.class, result.inboxId()); + AND actor_id = ? + AND access_scene = 'source-message-original' + """, Long.class, result.inboxId(), frontendActorId("source-message-admin")); assert auditCount != null; org.assertj.core.api.Assertions.assertThat(auditCount).isEqualTo(1L); } @@ -177,7 +187,8 @@ class SourceMessageControllerTest { insertRelatedTransition(transitionId, first.inboxId(), "GRP-CONVERSATION-P0-001"); insertRelatedTask(taskId, orderId, first.inboxId(), transitionId); - mockMvc.perform(get("/api/source-messages/{sourceMessageId}/conversation", first.inboxId())) + performAuthorized(mockMvc, adminToken(), get("/api/source-messages/{sourceMessageId}/conversation", + first.inboxId())) .andExpect(status().isOk()) .andExpect(jsonPath("$.conversation.external_conversation_id").value("conversation-p0-001")) .andExpect(jsonPath("$.conversation.message_count").value(2)) @@ -215,9 +226,9 @@ class SourceMessageControllerTest { SELECT COUNT(*) FROM platform_source_message_original_access_audit WHERE inbox_id IN (?, ?) - AND actor_id = 'system:source-message-conversation' + AND actor_id = ? AND access_scene = 'source-message-conversation' - """, Long.class, first.inboxId(), second.inboxId()); + """, Long.class, first.inboxId(), second.inboxId(), frontendActorId("source-message-admin")); org.assertj.core.api.Assertions.assertThat(auditCount).isEqualTo(2L); } @@ -236,7 +247,8 @@ class SourceMessageControllerTest { insertRelatedTransition(transitionId, sourceMessage.inboxId(), "TMP-HIDDEN-CONVERSATION-001"); insertRelatedTask(taskId, orderId, sourceMessage.inboxId(), transitionId); - mockMvc.perform(get("/api/source-messages/{sourceMessageId}/conversation", sourceMessage.inboxId())) + performAuthorized(mockMvc, adminToken(), get("/api/source-messages/{sourceMessageId}/conversation", + sourceMessage.inboxId())) .andExpect(status().isOk()) .andExpect(jsonPath("$.messages[0].related_orders.length()").value(0)) .andExpect(jsonPath("$.messages[0].related_tasks[0].task_id").value(taskId.toString())) diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/FrontendReadAuthorizationControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/FrontendReadAuthorizationControllerTest.java index 3d30d5c..9770350 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/FrontendReadAuthorizationControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/FrontendReadAuthorizationControllerTest.java @@ -8,6 +8,11 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import cn.nianxx.thhotel.ThHotelApplication; +import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode; +import cn.nianxx.thhotel.platform.access.common.enums.PlatformRoleStatus; +import cn.nianxx.thhotel.platform.access.domain.PlatformPermissionEntity; +import cn.nianxx.thhotel.platform.access.domain.PlatformRoleEntity; +import cn.nianxx.thhotel.platform.access.repository.PlatformAccessRepository; import cn.nianxx.thhotel.platform.hotel.repository.PlatformHotelRepository; import cn.nianxx.thhotel.platform.identity.common.enums.PlatformUserStatus; import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity; @@ -49,6 +54,9 @@ class FrontendReadAuthorizationControllerTest { private static final String HOTEL_ID = "HOTEL-TEST"; private static final String OTHER_HOTEL_ID = "HOTEL-OTHER"; + private static final String ORIGINAL_ONLY_USERNAME = "cp2-original-only"; + private static final String ORIGINAL_ONLY_PASSWORD = "OriginalOnly@123456"; + private static final String ORIGINAL_ONLY_ROLE_CODE = "CP2_ORIGINAL_ONLY"; @Autowired private MockMvc mockMvc; @@ -59,6 +67,8 @@ class FrontendReadAuthorizationControllerTest { @Autowired private PlatformIdentityRepository identityRepository; @Autowired + private PlatformAccessRepository accessRepository; + @Autowired private PlatformHotelRepository hotelRepository; @Autowired private AuthPasswordService passwordService; @@ -81,6 +91,7 @@ class FrontendReadAuthorizationControllerTest { return created; }); hotelRepository.ensureUserHotel(user.getId(), HOTEL_ID, true); + ensureOriginalOnlyUser(); } @Test @@ -119,6 +130,44 @@ class FrontendReadAuthorizationControllerTest { .andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED")); } + @Test + void shouldRejectSourceMessageOriginalAndConversationWhenTokenMissing() throws Exception { + mockMvc.perform(get("/api/source-messages/{id}/original", 970000000000000901L)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error_code").value("AUTH_TOKEN_REQUIRED")); + + mockMvc.perform(get("/api/source-messages/{id}/conversation", 970000000000000901L)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error_code").value("AUTH_TOKEN_REQUIRED")); + } + + @Test + void shouldRejectSourceMessageOriginalAndConversationWhenOriginalPermissionMissing() throws Exception { + String token = loginToken(mockMvc, "cp1-no-permission", "NoPerm@123456"); + + performAuthorized(mockMvc, token, get("/api/source-messages/{id}/original", 970000000000000902L)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED")); + + performAuthorized(mockMvc, token, get("/api/source-messages/{id}/conversation", 970000000000000902L)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED")); + } + + @Test + void shouldRejectSourceMessageOriginalAndConversationWhenSummaryPermissionMissing() throws Exception { + String token = loginToken(mockMvc, ORIGINAL_ONLY_USERNAME, ORIGINAL_ONLY_PASSWORD); + SourceMessageCaptureResult source = captureHotelSourceMessage(); + + performAuthorized(mockMvc, token, get("/api/source-messages/{id}/original", source.inboxId())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED")); + + performAuthorized(mockMvc, token, get("/api/source-messages/{id}/conversation", source.inboxId())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED")); + } + @Test void shouldRejectSourceMessageListAcrossHotelsWhenSnakeHotelIdProvided() throws Exception { String token = loginToken(mockMvc, "cp1-admin", "Admin@123456"); @@ -154,6 +203,20 @@ class FrontendReadAuthorizationControllerTest { .andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED")); } + @Test + void shouldRejectSourceMessageOriginalAndConversationAcrossHotels() throws Exception { + String token = loginToken(mockMvc, "cp1-admin", "Admin@123456"); + SourceMessageCaptureResult source = captureOtherHotelSourceMessage(); + + performAuthorized(mockMvc, token, get("/api/source-messages/{id}/original", source.inboxId())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED")); + + performAuthorized(mockMvc, token, get("/api/source-messages/{id}/conversation", source.inboxId())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED")); + } + @Test void shouldKeepSuperAgentTaskResultEndpointOutsideFrontendLoginInterceptor() throws Exception { mockMvc.perform(post("/api/integrations/superagent/task-results") @@ -207,6 +270,62 @@ class FrontendReadAuthorizationControllerTest { List.of())); } + private SourceMessageCaptureResult captureHotelSourceMessage() { + return captureService.capture(new CaptureSourceMessageCommand( + HOTEL_ID, + "AGENTBUS", + "EMAIL", + "cp2-hotel-mail-001", + "cp2-hotel-thread-001", + "frame-cp2-hotel", + "session-cp2-hotel", + Instant.parse("2026-07-13T02:00:00Z"), + "guest@example.test", + "Current hotel message", + "Current hotel body", + "Current hotel body", + "{\"source\":{\"external_message_id\":\"cp2-hotel-mail-001\"}}", + "agentbus-outlook-v1", + List.of())); + } + + private void ensureOriginalOnlyUser() { + PlatformUserEntity user = identityRepository.findUserByUsername(ORIGINAL_ONLY_USERNAME) + .orElseGet(() -> { + LocalDateTime now = LocalDateTime.now(); + PlatformUserEntity created = new PlatformUserEntity(); + created.setUsername(ORIGINAL_ONLY_USERNAME); + created.setPasswordHash(passwordService.hash(ORIGINAL_ONLY_PASSWORD)); + created.setDisplayName("仅原文权限用户"); + created.setUserStatus(PlatformUserStatus.ACTIVE.name()); + created.setSuperAdmin(false); + created.setPasswordChangedAt(now); + created.setCreatedAt(now); + created.setUpdatedAt(now); + identityRepository.insertUser(created); + return created; + }); + PlatformRoleEntity role = accessRepository.findRoleByCode(ORIGINAL_ONLY_ROLE_CODE) + .orElseGet(() -> { + LocalDateTime now = LocalDateTime.now(); + PlatformRoleEntity created = new PlatformRoleEntity(); + created.setRoleCode(ORIGINAL_ONLY_ROLE_CODE); + created.setRoleName("CP2 仅原文权限测试角色"); + created.setRoleStatus(PlatformRoleStatus.ACTIVE.name()); + created.setSystemBuiltin(false); + created.setCreatedAt(now); + created.setUpdatedAt(now); + accessRepository.insertRole(created); + return created; + }); + PlatformPermissionEntity originalPermission = accessRepository + .findPermissionByCode(PlatformPermissionCode.SOURCE_MESSAGE_ORIGINAL_READ.name()) + .orElseThrow(); + accessRepository.replaceRolePermissions(role.getId(), List.of(originalPermission.getId())); + accessRepository.ensureUserRole(user.getId(), role.getId()); + hotelRepository.ensureUserHotel(user.getId(), HOTEL_ID, true); + } + private void insertOtherHotelOrderTask(Long orderId, Long taskId, Long sourceMessageId) { Long transitionId = taskId - 1; jdbcTemplate.update(""" diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationDemoDataControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationDemoDataControllerTest.java index 755f412..08cb6f9 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationDemoDataControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationDemoDataControllerTest.java @@ -133,7 +133,8 @@ class ReservationDemoDataControllerTest { .andExpect(jsonPath("$.opera_operations[0].operation_status").value("FAILED")) .andExpect(jsonPath("$.opera_operations[0].attempt_count").value(1)); - mockMvc.perform(get("/api/source-messages/{sourceMessageId}/conversation", queueSourceMessageId)) + performAuthorized(mockMvc, adminToken(), get("/api/source-messages/{sourceMessageId}/conversation", + queueSourceMessageId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.messages", hasSize(greaterThanOrEqualTo(2)))) .andExpect(jsonPath("$.messages[0].html_sanitize_required").value(true))
{{ t('task.sourceEmailPreview') }}
{{ sourceEmailExcerpt ?? t('task.sourceEmailNoExcerpt') }}