diff --git a/.gitignore b/.gitignore index 94a099c..8dfa3ab 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ client/node_modules/ client/dist/ client/.vite/ +.pnpm-store/ server/target/ diff --git a/README.md b/README.md index 8a42049..edafdf2 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,14 @@ VITE_API_PROXY_TARGET=http://127.0.0.1:8080 VITE_RESERVATION_HOTEL_ID=HOTEL-TEST 中文说明:`VITE_RESERVATION_HOTEL_ID` 是公开前端配置,不是 Secret,用于 Reservation 订单列表、任务列表和订单详情接口的默认 `hotel_id`。 +Debug EML 上传到 SuperAgent 调试页面为隐藏入口,不放在普通业务菜单中: + +```text +http://127.0.0.1:5174/debug/eml-superagent +``` + +中文说明:该页面只用于 dev/test 受控调试。Debug 上传口令必须由调试人员在页面手动输入,不能写入 `VITE_*`、源码、localStorage、sessionStorage、URL、错误上报或普通日志。 + ### 测试数据库配置 默认 `test` profile 使用 H2 MySQL Mode,便于本地和 CI 在没有 MySQL 的情况下运行: diff --git a/client/pnpm-workspace.yaml b/client/pnpm-workspace.yaml new file mode 100644 index 0000000..9c604b1 --- /dev/null +++ b/client/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +packages: + - . + +allowBuilds: + vue-demi: true diff --git a/client/src/i18n/locales/en-US.ts b/client/src/i18n/locales/en-US.ts index 2429e7b..60a6104 100644 --- a/client/src/i18n/locales/en-US.ts +++ b/client/src/i18n/locales/en-US.ts @@ -173,6 +173,58 @@ export default { relatedOrders: 'Related orders', relatedTasks: 'Related tasks', }, + debugEml: { + title: 'Debug EML upload', + subtitle: 'Upload one .eml email and review SourceMessage and SuperAgent debug results', + uploadConfig: 'Upload configuration', + hotelId: 'Hotel ID', + debugUploadKey: 'Debug upload key', + runLabel: 'Run label', + emlFile: '.eml file', + submit: 'Submit debug run', + submitting: 'Waiting for SuperAgent', + executionStatus: 'Execution status', + status: 'Status', + debugRunId: 'Debug run ID', + backendStatus: 'Backend status', + backendMessage: 'Backend message', + waitingSuperAgent: 'Parsing the email and waiting for SuperAgent', + sourceTrace: 'SourceMessage trace', + sourceMessageId: 'SourceMessage ID', + sourceProvider: 'Source provider', + externalMessageId: 'External message ID', + externalConversationId: 'External conversation ID', + mailPreview: 'Email preview', + noSafeHtml: 'No safe HTML body is available', + uploadedMedia: 'Uploaded media', + unnamedMedia: 'Unnamed media', + rawHtml: 'Raw processed HTML', + superAgentResult: 'SuperAgent result', + superAgentSessionId: 'SuperAgent session ID', + superAgentRunId: 'SuperAgent run ID', + parsedJson: 'Parsed JSON', + rawAnswer: 'Raw answer', + warnings: 'Warnings', + debugPayload: 'Debug payload', + statuses: { + idle: 'Idle', + uploading: 'Running', + succeeded: 'Succeeded', + failed: 'Failed', + }, + errors: { + REQUEST_FIELD_REQUIRED: 'Required fields are missing. Check hotel ID, file, or request parameters', + EML_FILE_REQUIRED: 'Choose an .eml email file', + EML_FILE_TOO_LARGE: 'The email file exceeds the backend limit', + INVALID_FILE_TYPE: 'Only .eml email files are supported', + DEBUG_UPLOAD_KEY_INVALID: 'The debug upload key is missing or invalid', + EML_PARSE_FAILED: 'The email file could not be parsed', + OSS_UPLOAD_FAILED: 'OSS upload failed. Contact backend support', + SUPERAGENT_OPEN_API_FAILED: 'SuperAgent call failed. SourceMessage may already be captured', + DEBUG_EML_RUN_FAILED: 'Debug EML run failed internally', + UNKNOWN: 'Debug EML upload failed', + }, + }, status: { TEMPORARY: 'Temporary', ACTIVE: 'Active', diff --git a/client/src/i18n/locales/th-TH.ts b/client/src/i18n/locales/th-TH.ts index d452504..c887052 100644 --- a/client/src/i18n/locales/th-TH.ts +++ b/client/src/i18n/locales/th-TH.ts @@ -173,6 +173,58 @@ export default { relatedOrders: 'ออเดอร์ที่เกี่ยวข้อง', relatedTasks: 'งานที่เกี่ยวข้อง', }, + debugEml: { + title: 'อัปโหลด Debug EML', + subtitle: 'อัปโหลดอีเมล .eml หนึ่งฉบับและดูผล Debug ของ SourceMessage กับ SuperAgent', + uploadConfig: 'ค่าการอัปโหลด', + hotelId: 'Hotel ID', + debugUploadKey: 'คีย์อัปโหลด Debug', + runLabel: 'ป้ายกำกับรัน', + emlFile: 'ไฟล์ .eml', + submit: 'ส่ง Debug', + submitting: 'รอ SuperAgent', + executionStatus: 'สถานะการทำงาน', + status: 'สถานะ', + debugRunId: 'Debug run ID', + backendStatus: 'สถานะจากระบบหลังบ้าน', + backendMessage: 'ข้อความจากระบบหลังบ้าน', + waitingSuperAgent: 'กำลังแยกอีเมลและรอ SuperAgent ตอบกลับ', + sourceTrace: 'การติดตาม SourceMessage', + sourceMessageId: 'SourceMessage ID', + sourceProvider: 'Source provider', + externalMessageId: 'External message ID', + externalConversationId: 'External conversation ID', + mailPreview: 'ตัวอย่างอีเมล', + noSafeHtml: 'ยังไม่มี HTML ที่ปลอดภัยสำหรับแสดงผล', + uploadedMedia: 'สื่อที่อัปโหลด', + unnamedMedia: 'สื่อไม่มีชื่อ', + rawHtml: 'HTML หลังประมวลผลดิบ', + superAgentResult: 'ผลจาก SuperAgent', + superAgentSessionId: 'SuperAgent session ID', + superAgentRunId: 'SuperAgent run ID', + parsedJson: 'JSON ที่แยกแล้ว', + rawAnswer: 'คำตอบดิบ', + warnings: 'คำเตือน', + debugPayload: 'Debug payload', + statuses: { + idle: 'รออัปโหลด', + uploading: 'กำลังทำงาน', + succeeded: 'สำเร็จ', + failed: 'ล้มเหลว', + }, + errors: { + REQUEST_FIELD_REQUIRED: 'ขาดข้อมูลจำเป็น โปรดตรวจ Hotel ID ไฟล์ หรือพารามิเตอร์คำขอ', + EML_FILE_REQUIRED: 'โปรดเลือกไฟล์อีเมล .eml', + EML_FILE_TOO_LARGE: 'ไฟล์อีเมลเกินขนาดที่หลังบ้านกำหนด', + INVALID_FILE_TYPE: 'รองรับเฉพาะไฟล์อีเมล .eml', + DEBUG_UPLOAD_KEY_INVALID: 'คีย์อัปโหลด Debug ขาดหายหรือไม่ถูกต้อง', + EML_PARSE_FAILED: 'ไม่สามารถแยกไฟล์อีเมลได้', + OSS_UPLOAD_FAILED: 'อัปโหลด OSS ล้มเหลว โปรดให้หลังบ้านตรวจสอบ', + SUPERAGENT_OPEN_API_FAILED: 'เรียก SuperAgent ล้มเหลว SourceMessage อาจถูกบันทึกแล้ว', + DEBUG_EML_RUN_FAILED: 'กระบวนการ Debug EML ล้มเหลวภายใน', + UNKNOWN: 'อัปโหลด Debug EML ล้มเหลว', + }, + }, status: { TEMPORARY: 'ชั่วคราว', ACTIVE: 'ใช้งานอยู่', diff --git a/client/src/i18n/locales/zh-CN.ts b/client/src/i18n/locales/zh-CN.ts index 26fc6b9..12e5963 100644 --- a/client/src/i18n/locales/zh-CN.ts +++ b/client/src/i18n/locales/zh-CN.ts @@ -173,6 +173,58 @@ export default { relatedOrders: '关联订单', relatedTasks: '关联任务', }, + debugEml: { + title: 'Debug EML 调试上传', + subtitle: '上传单封 .eml 邮件并查看 SourceMessage 与 SuperAgent 调试结果', + uploadConfig: '上传配置', + hotelId: '酒店 ID', + debugUploadKey: 'Debug 上传口令', + runLabel: '运行标签', + emlFile: '.eml 文件', + submit: '提交调试', + submitting: '等待 SuperAgent 返回', + executionStatus: '执行状态', + status: '状态', + debugRunId: 'Debug 运行 ID', + backendStatus: '后端状态', + backendMessage: '后端消息', + waitingSuperAgent: '正在解析邮件并等待 SuperAgent 返回', + sourceTrace: 'SourceMessage 追溯', + sourceMessageId: 'SourceMessage ID', + sourceProvider: '来源 Provider', + externalMessageId: '外部消息 ID', + externalConversationId: '外部会话 ID', + mailPreview: '邮件内容预览', + noSafeHtml: '当前没有可安全渲染的 HTML 正文', + uploadedMedia: '上传媒体', + unnamedMedia: '未命名媒体', + rawHtml: '原始处理 HTML', + superAgentResult: 'SuperAgent 结果', + superAgentSessionId: 'SuperAgent Session ID', + superAgentRunId: 'SuperAgent Run ID', + parsedJson: '解析 JSON', + rawAnswer: '原始回答', + warnings: '警告', + debugPayload: '调试 Payload', + statuses: { + idle: '待上传', + uploading: '执行中', + succeeded: '成功', + failed: '失败', + }, + errors: { + REQUEST_FIELD_REQUIRED: '缺少必填字段,请检查酒店 ID、文件或请求参数', + EML_FILE_REQUIRED: '请选择 .eml 邮件文件', + EML_FILE_TOO_LARGE: '邮件文件超过后端限制', + INVALID_FILE_TYPE: '只支持 .eml 邮件文件', + DEBUG_UPLOAD_KEY_INVALID: '调试上传口令缺失或错误', + EML_PARSE_FAILED: '邮件文件无法解析', + OSS_UPLOAD_FAILED: 'OSS 上传失败,请联系后端排查', + SUPERAGENT_OPEN_API_FAILED: 'SuperAgent 调用失败,SourceMessage 可能已经写入', + DEBUG_EML_RUN_FAILED: 'Debug EML 链路内部失败', + UNKNOWN: 'Debug EML 调试失败', + }, + }, status: { TEMPORARY: '临时', ACTIVE: '有效', diff --git a/client/src/router/index.ts b/client/src/router/index.ts index 98e5fa9..6e4a1c2 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -51,5 +51,13 @@ export const router = createRouter({ titleKey: 'nav.sourceMessageConversation', }, }, + { + path: '/debug/eml-superagent', + name: 'debug-eml-superagent', + component: () => import('@/views/debug/DebugEmlSuperAgentRunView.vue'), + meta: { + titleKey: 'debugEml.title', + }, + }, ], }) diff --git a/client/src/services/debugEmlService.ts b/client/src/services/debugEmlService.ts new file mode 100644 index 0000000..d8cddb8 --- /dev/null +++ b/client/src/services/debugEmlService.ts @@ -0,0 +1,126 @@ +import type { + DebugEmlErrorCode, + DebugEmlErrorResponse, + DebugEmlSuperAgentRunResult, + DebugEmlUploadInput, +} from '@/types/debugEml' + +const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '' +const debugEmlEndpoint = '/api/system/debug/eml-superagent-runs' + +export class DebugEmlUploadError extends Error { + readonly status: number + readonly errorCode: DebugEmlErrorCode + readonly details: unknown + + constructor(message: string, status: number, errorCode: DebugEmlErrorCode, details: unknown) { + super(message) + this.name = 'DebugEmlUploadError' + this.status = status + this.errorCode = errorCode + this.details = details + } +} + +export async function uploadDebugEmlSuperAgentRun( + input: DebugEmlUploadInput, +): Promise { + const form = new FormData() + form.append('file', input.file) + form.append('hotel_id', input.hotelId.trim()) + const runLabel = input.runLabel?.trim() + if (runLabel) { + form.append('run_label', runLabel) + } + + const response = await fetch(`${apiBaseUrl}${debugEmlEndpoint}`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'X-TH-Hotel-Debug-Upload-Key': input.debugUploadKey.trim(), + }, + body: form, + }) + + const payload = await readResponsePayload(response) + if (!response.ok) { + const errorPayload = isDebugEmlErrorResponse(payload) ? payload : {} + throw new DebugEmlUploadError( + errorPayload.message || `Request failed with status ${response.status}`, + response.status, + errorPayload.error_code || 'DEBUG_EML_RUN_FAILED', + payload, + ) + } + + return normalizeDebugEmlResult(payload) +} + +async function readResponsePayload(response: Response): Promise { + const contentType = response.headers.get('content-type') ?? '' + if (!contentType.includes('application/json')) { + return response.text() + } + return response.json() +} + +function normalizeDebugEmlResult(payload: unknown): DebugEmlSuperAgentRunResult { + const result = isRecord(payload) ? payload : {} + return { + debug_run_id: stringValue(result.debug_run_id), + source_message_id: nullableStringValue(result.source_message_id), + source_provider: nullableStringValue(result.source_provider), + external_message_id: nullableStringValue(result.external_message_id), + external_conversation_id: nullableStringValue(result.external_conversation_id), + original_eml_oss_url: nullableStringValue(result.original_eml_oss_url), + original_eml_sha256: nullableStringValue(result.original_eml_sha256), + uploaded_media: Array.isArray(result.uploaded_media) + ? result.uploaded_media.map(normalizeUploadedMedia) + : [], + html_body_with_oss_urls: nullableStringValue(result.html_body_with_oss_urls), + html_body_sanitized: nullableStringValue(result.html_body_sanitized), + html_sanitize_required: + typeof result.html_sanitize_required === 'boolean' ? result.html_sanitize_required : null, + html_render_mode: nullableStringValue(result.html_render_mode), + agentbus_like_payload: isRecord(result.agentbus_like_payload) ? result.agentbus_like_payload : null, + superagent_session_id: nullableStringValue(result.superagent_session_id), + superagent_run_id: nullableStringValue(result.superagent_run_id), + superagent_raw_answer: nullableStringValue(result.superagent_raw_answer), + superagent_parsed_json: result.superagent_parsed_json ?? null, + warnings: Array.isArray(result.warnings) ? result.warnings.filter(isString) : [], + status: nullableStringValue(result.status), + } +} + +function normalizeUploadedMedia(value: unknown): DebugEmlSuperAgentRunResult['uploaded_media'][number] { + const media = isRecord(value) ? value : {} + return { + media_type: nullableStringValue(media.media_type), + file_name: nullableStringValue(media.file_name), + content_type: nullableStringValue(media.content_type), + size_bytes: typeof media.size_bytes === 'number' ? media.size_bytes : null, + external_url: nullableStringValue(media.external_url), + external_media_id: nullableStringValue(media.external_media_id), + object_key: nullableStringValue(media.object_key), + } +} + +function isDebugEmlErrorResponse(value: unknown): value is DebugEmlErrorResponse { + return isRecord(value) && (typeof value.error_code === 'string' || typeof value.message === 'string') +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isString(value: unknown): value is string { + return typeof value === 'string' +} + +function nullableStringValue(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value : '' +} diff --git a/client/src/tests/debugEmlService.spec.ts b/client/src/tests/debugEmlService.spec.ts new file mode 100644 index 0000000..f95222c --- /dev/null +++ b/client/src/tests/debugEmlService.spec.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { DebugEmlUploadError, uploadDebugEmlSuperAgentRun } from '@/services/debugEmlService' + +const jsonHeaders = { + headers: { + get: (name: string) => (name.toLowerCase() === 'content-type' ? 'application/json' : null), + }, +} + +function mockJsonResponse(payload: unknown, status = 201): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => payload, + text: async () => JSON.stringify(payload), + ...jsonHeaders, + } as Response +} + +describe('debugEmlService', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('uploads an eml file with FormData and debug key header', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + mockJsonResponse({ + debug_run_id: '90001', + source_message_id: '30001', + source_provider: 'DEBUG_EML_UPLOAD', + external_message_id: 'debug-eml-run-90001-abcdef123456', + external_conversation_id: 'debug-thread-90001', + original_eml_oss_url: 'https://oss.example/raw.eml', + original_eml_sha256: 'sha256', + uploaded_media: [], + html_body_with_oss_urls: 'raw', + html_body_sanitized: 'safe', + html_sanitize_required: true, + html_render_mode: 'SANITIZED_HTML', + agentbus_like_payload: { schema_version: 'debug-eml-upload-v1' }, + superagent_session_id: 'session-1', + superagent_run_id: 'run-1', + superagent_raw_answer: '{"ok":true}', + superagent_parsed_json: { ok: true }, + warnings: [], + status: 'SUPERAGENT_SUCCEEDED', + }), + ) + const file = new File(['From: guest@example.test'], 'booking.eml', { type: 'message/rfc822' }) + + const result = await uploadDebugEmlSuperAgentRun({ + hotelId: 'HOTEL-TEST', + debugUploadKey: ' manual-debug-key ', + runLabel: 'frontend-smoke', + file, + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0]! + expect(url).toBe('/api/system/debug/eml-superagent-runs') + expect(init).toMatchObject({ + method: 'POST', + headers: { + Accept: 'application/json', + 'X-TH-Hotel-Debug-Upload-Key': 'manual-debug-key', + }, + }) + expect(init?.headers).not.toHaveProperty('Content-Type') + expect(init?.body).toBeInstanceOf(FormData) + const formData = init?.body as FormData + expect(formData.get('hotel_id')).toBe('HOTEL-TEST') + expect(formData.get('run_label')).toBe('frontend-smoke') + expect(formData.get('file')).toBe(file) + expect(result.debug_run_id).toBe('90001') + }) + + it('throws a typed safe error for backend error_code responses', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + mockJsonResponse( + { + error_code: 'DEBUG_UPLOAD_KEY_INVALID', + message: 'Debug 上传访问口令缺失或错误。', + }, + 401, + ), + ) + const file = new File(['From: guest@example.test'], 'booking.eml', { type: 'message/rfc822' }) + + await expect( + uploadDebugEmlSuperAgentRun({ + hotelId: 'HOTEL-TEST', + debugUploadKey: 'wrong-key', + file, + }), + ).rejects.toMatchObject({ + status: 401, + errorCode: 'DEBUG_UPLOAD_KEY_INVALID', + message: 'Debug 上传访问口令缺失或错误。', + } satisfies Partial) + }) +}) diff --git a/client/src/tests/debugEmlView.spec.ts b/client/src/tests/debugEmlView.spec.ts new file mode 100644 index 0000000..44a2a2d --- /dev/null +++ b/client/src/tests/debugEmlView.spec.ts @@ -0,0 +1,150 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createI18n } from 'vue-i18n' + +import zhCN from '@/i18n/locales/zh-CN' +import { DebugEmlUploadError } from '@/services/debugEmlService' +import DebugEmlSuperAgentRunView from '@/views/debug/DebugEmlSuperAgentRunView.vue' + +vi.mock('@/services/debugEmlService', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + uploadDebugEmlSuperAgentRun: vi.fn(), + } +}) + +const service = await import('@/services/debugEmlService') + +function mountView() { + const i18n = createI18n({ + legacy: false, + locale: 'zh-CN', + messages: { + 'zh-CN': zhCN, + }, + }) + + return mount(DebugEmlSuperAgentRunView, { + global: { + plugins: [i18n], + }, + }) +} + +function createResult() { + return { + debug_run_id: '90001', + source_message_id: '30001', + source_provider: 'DEBUG_EML_UPLOAD', + external_message_id: 'debug-eml-run-90001-abcdef123456', + external_conversation_id: 'debug-thread-90001', + original_eml_oss_url: 'https://oss.example/raw.eml', + original_eml_sha256: 'sha256', + uploaded_media: [ + { + media_type: 'ORIGINAL_EMAIL', + file_name: 'booking.eml', + content_type: 'message/rfc822', + size_bytes: 1200, + external_url: 'https://oss.example/raw.eml', + external_media_id: 'raw', + object_key: 'debug/eml/raw/booking.eml', + }, + ], + html_body_with_oss_urls: '原始 HTML', + html_body_sanitized: '安全正文', + html_sanitize_required: true, + html_render_mode: 'SANITIZED_HTML', + agentbus_like_payload: { + schema_version: 'debug-eml-upload-v1', + source: { + original_message_id: '', + }, + }, + superagent_session_id: 'session-1', + superagent_run_id: 'run-1', + superagent_raw_answer: '{"ok":true}', + superagent_parsed_json: { ok: true }, + warnings: ['SuperAgent 返回非 JSON 时会展示原始回答'], + status: 'SUPERAGENT_SUCCEEDED', + } +} + +async function chooseFile(wrapper: ReturnType) { + const file = new File(['From: guest@example.test'], 'booking.eml', { type: 'message/rfc822' }) + const input = wrapper.find('input[type="file"]') + Object.defineProperty(input.element, 'files', { + value: [file], + configurable: true, + }) + await input.trigger('change') + return file +} + +describe('DebugEmlSuperAgentRunView', () => { + beforeEach(() => { + vi.mocked(service.uploadDebugEmlSuperAgentRun).mockReset() + }) + + it('submits the selected eml file and renders sanitized debug result', async () => { + vi.mocked(service.uploadDebugEmlSuperAgentRun).mockResolvedValue(createResult()) + const wrapper = mountView() + + await wrapper.find('input[name="hotel_id"]').setValue('HOTEL-TEST') + await wrapper.find('input[name="debug_upload_key"]').setValue('manual-debug-key') + await wrapper.find('input[name="run_label"]').setValue('frontend-smoke') + const file = await chooseFile(wrapper) + await wrapper.find('form').trigger('submit') + await flushPromises() + + expect(service.uploadDebugEmlSuperAgentRun).toHaveBeenCalledWith({ + hotelId: 'HOTEL-TEST', + debugUploadKey: 'manual-debug-key', + runLabel: 'frontend-smoke', + file, + }) + expect(wrapper.text()).toContain('90001') + expect(wrapper.text()).toContain('30001') + expect(wrapper.text()).toContain('DEBUG_EML_UPLOAD') + expect(wrapper.find('.debug-eml-html-preview').html()).toContain('安全正文') + expect(wrapper.find('.debug-eml-html-preview').html()).not.toContain(' + +