From 70f71f4c5cea886d110159b0d959ec04fc2a0a61 Mon Sep 17 00:00:00 2001 From: Suphonchai Phoonsawat Date: Thu, 10 Sep 2026 11:49:16 +0700 Subject: [PATCH 01/10] Phase 12 v9: draft opens in Word + Thai word-breaking fix (FR-16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit แก้ตามผลใช้งานจริง 2 ประเด็น: 1. เปิดใน Word ทันที — generate-draft เปลี่ยนจากคืนไฟล์ตรงๆ เป็นเก็บ ชั่วคราวใต้ drafts/ (lifecycle ลบรายวัน, config Minio__DraftRetentionDays) + คืน presigned URL ให้เว็บเปิดผ่าน ms-word:ofe พร้อมลิงก์สำรอง (useVersionOpen.openDocxUrl แยกแกนเปิด docx URL ใช้ร่วมกับ flow v0) 2. ตัดคำไทย — T14 แตก placeholder เป็น [ / ข้อความ / ] โดย run ข้อความ ถือ w:hint="cs" + w:cs เท่านั้น; เดิมเขียนค่าลง run ปีกกาทำให้ Word จัดหน้าไทยผิด — แก้เป็นเขียนที่ run ข้อความยาวสุด + SDT เปล่าสืบทอด rPr จาก sdtPr (ใช้ร่วมทั้ง generate และ generate-draft) - ถอดของที่เลิกใช้: CORS expose Content-Disposition, downloadFile init, useApi.spec · Spec v9 + ADR 0002 Amendment + acceptance-review AC-19 - Tests: backend 84 / web 56 + build; QA reviewer PASS (2 rounds, 10 findings แก้ครบ — รวม bug จริงที่ test ใหม่จับได้: lifecycle Rules null ทำ startup ตั้ง rule ล้มแบบเงียบ) · ค้าง manual บน Word จริง Co-Authored-By: Claude Code --- CLAUDE.md | 6 +- CONTEXT.md | 2 +- .../DraftTests.cs | 103 +++++++++++++-- .../GenerateTests.cs | 4 +- .../IntegrationTestFixture.cs | 9 ++ .../TestDocx.cs | 79 +++++++++++- .../TestDtos.cs | 3 + .../VersionPdfTests.cs | 1 + .../Controllers/TemplatesController.cs | 38 ++++-- .../WordVersioning.Api/Models/TemplateDtos.cs | 4 + backend/WordVersioning.Api/Program.cs | 22 +++- .../Services/DocxGenerationService.cs | 25 ++-- .../Services/MinioOptions.cs | 4 + .../Services/ObjectStorageService.cs | 54 ++++++++ docs/acceptance-review.md | 15 ++- docs/adr/0002-draft-stateless.md | 16 ++- docs/plans/officejs-implementation-plan.md | 7 +- specs/spec-officejs.md | 18 ++- web/app/composables/useApi.ts | 38 +----- web/app/composables/useVersionOpen.ts | 40 +++--- .../pages/documents/create/[templateId].vue | 34 +++-- web/test/useApi.spec.ts | 117 ------------------ web/test/useVersionOpen.spec.ts | 10 ++ 23 files changed, 408 insertions(+), 241 deletions(-) delete mode 100644 web/test/useApi.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index 13a47c3..2f059b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`) -**สถานะปัจจุบัน: Phase 0–12 — FR-16 ไฟล์ร่าง: automated QA PASS (backend 82 / add-in 66 / web 59 + build ครบ), ค้าง manual บน Word จริง · Phase 0–11 เสร็จครบ (FR-15 ผ่าน QA + manual บน Word for Mac 2026-09-08)** — ค้างยืนยันบน Word for Windows / Word on the web ตามรายการใน `docs/acceptance-review.md` +**สถานะปัจจุบัน: Phase 0–12 — FR-16 ไฟล์ร่าง: automated QA PASS (backend 84 / add-in 66 / web 56 + build ครบ · v9: เปิดใน Word ทันที + แก้ตัดคำไทย), ค้าง manual บน Word จริง · Phase 0–11 เสร็จครบ (FR-15 ผ่าน QA + manual บน Word for Mac 2026-09-08)** — ค้างยืนยันบน Word for Windows / Word on the web ตามรายการใน `docs/acceptance-review.md` ## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ) @@ -97,6 +97,6 @@ npx vitest run test/useTheme.spec.ts # รัน test เดี `DocumentVersion.PdfObjectKey` nullable เก็บ sibling `.pdf` ใน bucket เดิม; history มี `hasPdf`. `/download` และ `/download-link` รับ `?format=pdf`, ไม่มี PDF → 404. Presigned URLs กำหนดชื่อไฟล์ไทย + `_vN`; PDF inline, docx attachment. เว็บเปิด Word ผ่าน `ms-word:ofe|u|` พร้อมดาวน์โหลดสำรอง; PDF เปิดแท็บใหม่. Add-in ใช้ `openBrowserWindow` เท่านั้น. ข้อมูลเก่าไม่ backfill หรือเปลี่ยนเลข. -## ไฟล์ร่าง (FR-16 / spec v8) +## ไฟล์ร่าง (FR-16 / spec v8 · ปรับ v9) -ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม → `POST /api/templates/{id}/generate-draft` **stateless** (คืน docx ทันที ไม่สร้าง Document/version ไม่แตะ MinIO; ไม่มีปุ่มเปิดใน Word เพราะไม่มี URL) — validate ชุดเดียวกับ generate, ไม่มี `message`. ไฟล์ถูก stamp custom property **`TemplateId` ตัวเดียว** (ไม่มี SavedByAddIn/DocumentTrackingId) จึงเป็น untracked ในสายตา add-in. เมื่อกด Start Versioning, add-in อ่าน TemplateId แล้วแนบ form field `templateId` (ใช้เฉพาะตอน auto-create Document; validate ทุกครั้งที่ส่ง: ไม่ใช่ GUID/ไม่พบ → 400, deactivated → ผูกได้) → **Document ผูก template + v1** (ไม่มี v0) + field sync เต็มระบบ. CORS ต้อง expose `Content-Disposition` เพื่อให้เว็บอ่านชื่อไฟล์ไทย. การตัดสินใจ: `docs/adr/0002-draft-stateless.md`. +ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม → `POST /api/templates/{id}/generate-draft` — ไม่สร้าง Document/version ใน DB; generate แล้ว**เก็บชั่วคราว**ใน bucket `docx-versions` ใต้ prefix `drafts/` (lifecycle ลบรายวัน — `Minio__DraftRetentionDays`, backend ตั้ง rule ตอน startup) แล้วคืน **presigned URL อายุสั้น** ให้เว็บเปิดใน Word ทันทีผ่าน `ms-word:ofe` + ลิงก์สำรอง (`useVersionOpen.openDocxUrl`) — validate ชุดเดียวกับ generate, ไม่มี `message`. ไฟล์ถูก stamp custom property **`TemplateId` ตัวเดียว** (ไม่มี SavedByAddIn/DocumentTrackingId) จึงเป็น untracked ในสายตา add-in. เมื่อกด Start Versioning, add-in อ่าน TemplateId แล้วแนบ form field `templateId` (ใช้เฉพาะตอน auto-create Document; validate ทุกครั้งที่ส่ง: ไม่ใช่ GUID/ไม่พบ → 400, deactivated → ผูกได้) → **Document ผูก template + v1** (ไม่มี v0) + field sync เต็มระบบ. การแทนที่ค่าเขียนที่ **run ข้อความ placeholder จริง** (run ที่ถือ `w:hint="cs"`+`w:cs` — template ไทยแตก placeholder เป็น `[`/ข้อความ/`]` หลาย run) และ SDT เปล่าสืบทอด rPr จาก sdtPr ไม่งั้น Word ตัดคำไทยผิด. การตัดสินใจ: `docs/adr/0002-draft-stateless.md`. diff --git a/CONTEXT.md b/CONTEXT.md index d60c353..018e7f3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -2,6 +2,6 @@ - **Version PDF**: สำเนา PDF ที่ Word ผลิตขณะเปิดเอกสาร; ขาดได้แบบ best-effort - **ฉบับตั้งต้น (v0)**: version ที่เว็บสร้างจากฟอร์ม ยังไม่เคยผ่าน Word -- **ไฟล์ร่าง (draft)**: ไฟล์ .docx ที่สร้างจาก template แบบ stateless — ยังไม่มีเอกสาร/version ใดๆ ในระบบ; ระบุ template ต้นทางผ่าน custom property และเริ่ม track จริงตอนกด Start Versioning ใน Word (ได้เป็น version 1) +- **ไฟล์ร่าง (draft)**: ไฟล์ .docx ที่สร้างจาก template — ยังไม่มีเอกสาร/version ใดๆ ในระบบ; ระบุ template ต้นทางผ่าน custom property และเริ่ม track จริงตอนกด Start Versioning ใน Word (ได้เป็น version 1) - **เลข version**: จำนวนรอบการบันทึก version ผ่าน Word หลังฉบับตั้งต้น - **ต้นฉบับ (docx)**: ตัว version จริง; PDF เป็น derived artifact diff --git a/backend/WordVersioning.Api.IntegrationTests/DraftTests.cs b/backend/WordVersioning.Api.IntegrationTests/DraftTests.cs index f0d6c10..7e11217 100644 --- a/backend/WordVersioning.Api.IntegrationTests/DraftTests.cs +++ b/backend/WordVersioning.Api.IntegrationTests/DraftTests.cs @@ -5,8 +5,8 @@ using WordVersioning.Api.Services; namespace WordVersioning.Api.IntegrationTests; -/// FR-16 — ไฟล์ร่าง (draft): generate docx จาก template แบบ stateless -/// (ค่าครบ + stamp เฉพาะ TemplateId + ไม่เกิด Document/version ใดๆ ในระบบ) +/// FR-16 — ไฟล์ร่าง (draft): generate docx จาก template โดยไม่เกิด Document/version ใน DB +/// (v9: เก็บไฟล์ชั่วคราวใต้ drafts/ + คืน presigned URL ให้เว็บเปิดใน Word + ค่าที่แทนที่ได้ formatting ไทยถูกต้อง) [Collection(IntegrationTestCollection.Name)] public sealed class DraftTests(IntegrationTestFixture fixture) { @@ -19,13 +19,16 @@ public sealed class DraftTests(IntegrationTestFixture fixture) using var response = await client.PostAsJsonAsync( $"api/templates/{templateId}/generate-draft", new { values = TestFlow.DefaultValues }, IntegrationTestFixture.Json); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); // ไม่ใช่ 201 — ไม่มีอะไรถูกสร้าง + Assert.Equal(HttpStatusCode.OK, response.StatusCode); // ไม่ใช่ 201 — ไม่มีอะไรถูกสร้างใน DB - // คืนไฟล์ docx พร้อมชื่อไฟล์จากชื่อ template (Content-Disposition attachment) - Assert.Equal(TestDocx.DocxContentType, response.Content.Headers.ContentType?.MediaType); - Assert.NotNull(response.Content.Headers.ContentDisposition?.FileNameStar); - - var fileBytes = await response.Content.ReadAsByteArrayAsync(); + // คืน presigned URL อายุสั้น (เปิดใน Word ผ่าน ms-word:ofe / ดาวน์โหลดสำรอง) — ดึงไฟล์จริงได้ทันที + var link = (await response.Content.ReadFromJsonAsync(IntegrationTestFixture.Json))!; + Assert.False(string.IsNullOrWhiteSpace(link.Url)); + Assert.True(link.ExpiresAt > DateTimeOffset.UtcNow.AddSeconds(30)); + using var browser = new HttpClient(); + using var file = await browser.GetAsync(link.Url); + Assert.Equal(HttpStatusCode.OK, file.StatusCode); + var fileBytes = await file.Content.ReadAsByteArrayAsync(); // (1) ค่าทุก field ถูกแทนที่ใน SDT — เหมือน generate ปกติ Assert.Equal("สมหญิง รักงาน", TestDocx.ReadSdtText(fileBytes, "employee_name")); @@ -33,7 +36,7 @@ public sealed class DraftTests(IntegrationTestFixture fixture) Assert.Equal("75000", TestDocx.ReadSdtText(fileBytes, "salary")); Assert.Equal("2026-08-01", TestDocx.ReadSdtText(fileBytes, "start_date")); - // (2) multi-run SDT ยังรักษา formatting ของ run แรก (bold) + // (2) multi-run SDT ยังรักษา formatting ของ run ผู้บริจาคค่า (bold — ชนะด้วย tie แบบเจตนา, ดู comment ใน TestDocx) var positionRuns = TestDocx.ReadSdtRuns(fileBytes, "position"); var runWithText = Assert.Single(positionRuns.Where(r => r.Text.Length > 0)); Assert.True(runWithText.Bold); @@ -43,7 +46,7 @@ public sealed class DraftTests(IntegrationTestFixture fixture) Assert.Null(TestDocx.ReadCustomProperty(fileBytes, "SavedByAddIn")); Assert.Null(TestDocx.ReadCustomProperty(fileBytes, "DocumentTrackingId")); - // (4) stateless จริง: ไม่เกิด Document/version ใดๆ จาก template นี้ + // (4) ไม่เกิด Document/version ใดๆ ใน DB จาก template นี้ (ไฟล์เก็บชั่วคราวใน MinIO เท่านั้น ไม่ใช่ record) await using var db = fixture.CreateDb(); Assert.False(await db.Documents.AsNoTracking().AnyAsync(d => d.TemplateId == templateId)); Assert.False(await db.DocumentVersions.AsNoTracking() @@ -52,6 +55,63 @@ public sealed class DraftTests(IntegrationTestFixture fixture) .AnyAsync(v => db.Documents.Any(d => d.Id == v.DocumentId && d.TemplateId == templateId))); } + [Fact] + public async Task Draft_ThaiPlaceholder_UsesComplexScriptRun_ForWordBreaking() + { + // template ไทยจริง (แบบ T14) แตก placeholder เป็น [ / ข้อความ / ] — ค่าต้องลง run ข้อความ + // ที่ถือ w:hint="cs" + w:cs ไม่ใช่ run ปีกกา ไม่งั้น Word ตัดคำ/กระจายไทยผิดทั้งบรรทัด (v9) + var templateId = await CreateThaiTemplateAsync(); + using var client = fixture.CreateClient(); + const string detail = "โจทก์ฟ้องร้องให้เพิกถอนนิติกรรมสัญญาซื้อขายที่ดินโฉนดเลขที่ 12345 เนื่องจากถูกปลอมลายมือชื่อ"; + + using var response = await client.PostAsJsonAsync( + $"api/templates/{templateId}/generate-draft", + new { values = new Dictionary { ["detail1"] = detail, ["remark1"] = "หมายเหตุผู้พิพากษา" } }, + IntegrationTestFixture.Json); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var link = (await response.Content.ReadFromJsonAsync(IntegrationTestFixture.Json))!; + using var browser = new HttpClient(); + using var file = await browser.GetAsync(link.Url); + Assert.Equal(HttpStatusCode.OK, file.StatusCode); + var fileBytes = await file.Content.ReadAsByteArrayAsync(); + + // ค่ายาวอยู่ใน run เดียวที่ถือ complex script flags ตาม run ข้อความไทยของ template + // (run ปีกกาถูกลบ text แต่คง rPr ไว้ — โล่ง มองไม่เห็นใน Word เหมือนพฤติกรรมเดิมของ generate) + var run = Assert.Single(TestDocx.ReadSdtRunInfo(fileBytes, "detail1").Where(r => r.Text.Length > 0)); + Assert.Equal(detail, run.Text); + Assert.True(run.CsHint); + Assert.True(run.ComplexScript); + + // SDT เปล่า: run ใหม่สืบทอด rPr จาก sdtPr (ฟอนต์ไทยของผู้แต่ง template) ไม่ใช่ default font + var emptyRun = Assert.Single(TestDocx.ReadSdtRunInfo(fileBytes, "remark1").Where(r => r.Text.Length > 0)); + Assert.Equal("หมายเหตุผู้พิพากษา", emptyRun.Text); + Assert.Equal("TH SarabunPSK", emptyRun.AsciiFont); + } + + /// สร้าง template จาก BuildThaiTemplateDocx พร้อมตั้ง field defs (detail1 required, remark1 ทางเลือก) + private async Task CreateThaiTemplateAsync() { + using var client = fixture.CreateClient(); + using var create = await client.PostAsync( + "api/templates", + ApiExtensions.TemplateForm(TestDocx.BuildThaiTemplateDocx(), $"thai {Guid.NewGuid():N}")); + create.EnsureSuccessStatusCode(); + var created = (await create.Content.ReadFromJsonAsync(IntegrationTestFixture.Json))!; + + using var put = await client.PutAsJsonAsync($"api/templates/{created.Id}", new + { + fields = created.Fields.Select(f => new + { + key = f.Key, + label = f.Key, + type = "text", + isRequired = f.Key == "detail1", + sortOrder = f.SortOrder, + }), + }, IntegrationTestFixture.Json); + put.EnsureSuccessStatusCode(); + return created.Id; + } + [Fact] public async Task Draft_MissingRequiredField_Returns400() { @@ -123,7 +183,28 @@ public sealed class DraftTests(IntegrationTestFixture fixture) using var response = await client.PostAsJsonAsync( $"api/templates/{templateId}/generate-draft", new { values }, IntegrationTestFixture.Json); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var fileBytes = await response.Content.ReadAsByteArrayAsync(); + var link = (await response.Content.ReadFromJsonAsync(IntegrationTestFixture.Json))!; + using var browser = new HttpClient(); + using var file = await browser.GetAsync(link.Url); + Assert.Equal(HttpStatusCode.OK, file.StatusCode); + var fileBytes = await file.Content.ReadAsByteArrayAsync(); Assert.Equal(templateId.ToString(), TestDocx.ReadCustomProperty(fileBytes, AddInMetadata.TemplateIdPropertyName)); } + + [Fact] + public async Task Draft_LifecycleRule_Idempotent_AndSyncsRetentionDays() + { + // Program.cs ตั้ง rule `expire-drafts` ให้ bucket ตั้งแต่ startup แล้ว — ตรวจ path สร้างใหม่/ซ้ำ/เปลี่ยนค่า + // ผ่าน rule id อื่นกับ prefix ทดสอบ (ทั้งหมดยิง MinIO จริง) — ค่าคืน true = เขียน config จริง, false = ถูกต้องอยู่แล้ว + var storage = fixture.CreateStorage(); + var bucket = fixture.MinioConfig.VersionsBucket; + + Assert.True(await storage.EnsurePrefixExpiryAsync(bucket, "test-drafts-probe/", 1, "test-expire-probe")); // สร้าง + Assert.False(await storage.EnsurePrefixExpiryAsync(bucket, "test-drafts-probe/", 1, "test-expire-probe")); // idempotent + Assert.True(await storage.EnsurePrefixExpiryAsync(bucket, "test-drafts-probe/", 2, "test-expire-probe")); // sync ค่าใหม่ + Assert.False(await storage.EnsurePrefixExpiryAsync(bucket, "test-drafts-probe/", 2, "test-expire-probe")); // ตั้งแล้วเช่นกัน + + await Assert.ThrowsAsync(() => + storage.EnsurePrefixExpiryAsync(bucket, "test-drafts-probe/", 0, "test-expire-probe")); + } } diff --git a/backend/WordVersioning.Api.IntegrationTests/GenerateTests.cs b/backend/WordVersioning.Api.IntegrationTests/GenerateTests.cs index d523249..4349237 100644 --- a/backend/WordVersioning.Api.IntegrationTests/GenerateTests.cs +++ b/backend/WordVersioning.Api.IntegrationTests/GenerateTests.cs @@ -31,8 +31,8 @@ public sealed class GenerateTests(IntegrationTestFixture fixture) Assert.Equal("75000", TestDocx.ReadSdtText(fileBytes, "salary")); Assert.Equal("2026-08-01", TestDocx.ReadSdtText(fileBytes, "start_date")); - // (2) multi-run SDT: ค่าใหม่เขียนที่ Text แรก (ใน Run แรก — สืบทอด rPr bold ของ template) - // และ Text ของ run หลังถูกลบ (เหลือ run โล่งที่มีแต่ rPr — มองไม่เห็น ไม่มีผลกับเนื้อหา) + // (2) multi-run SDT: ค่าใหม่เขียนที่ run ข้อความยาวสุด (v9 — ที่นี่คือ run bold ซึ่งชนะด้วย tie แบบเจตนา, + // ดู comment ใน TestDocx) และ Text ของ run หลังถูกลบ (เหลือ run โล่งที่มีแต่ rPr — มองไม่เห็น ไม่มีผลกับเนื้อหา) var positionRuns = TestDocx.ReadSdtRuns(fileBytes, "position"); var runWithText = Assert.Single(positionRuns.Where(r => r.Text.Length > 0)); Assert.Equal("วิศวกรซอฟต์แวร์", runWithText.Text); diff --git a/backend/WordVersioning.Api.IntegrationTests/IntegrationTestFixture.cs b/backend/WordVersioning.Api.IntegrationTests/IntegrationTestFixture.cs index bc9f607..f01215a 100644 --- a/backend/WordVersioning.Api.IntegrationTests/IntegrationTestFixture.cs +++ b/backend/WordVersioning.Api.IntegrationTests/IntegrationTestFixture.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Testcontainers.Minio; using Testcontainers.PostgreSql; using WordVersioning.Api.Data; @@ -152,6 +153,14 @@ public sealed class IntegrationTestFixture : IAsyncLifetime .UseNpgsql(_postgres.GetConnectionString()) .Options); + /// บริการ storage จริงจาก host ของ API — ใช้ทดสอบกับ MinIO container ตรงๆ (เช่น lifecycle rule) + public WordVersioning.Api.Services.IObjectStorageService CreateStorage() => + _factory.Services.GetRequiredService(); + + /// config MinIO ที่ host ใช้จริง (ชื่อ bucket ฯลฯ) + public WordVersioning.Api.Services.MinioOptions MinioConfig => + _factory.Services.GetRequiredService>().Value; + /// user token จริงผ่าน password grant (client word-versioning-web ของ realm test เปิดให้ตอน test เท่านั้น) /// — token มี aud = word-versioning-web จาก audience mapper ของ realm ตามที่ backend validate /// (เลี่ยง PKCE dance ของ production flow — ตัว validate ฝั่ง backend คือตัวเดิมทดสอบเต็มรูปแบบ) diff --git a/backend/WordVersioning.Api.IntegrationTests/TestDocx.cs b/backend/WordVersioning.Api.IntegrationTests/TestDocx.cs index 02c34a2..f61af5c 100644 --- a/backend/WordVersioning.Api.IntegrationTests/TestDocx.cs +++ b/backend/WordVersioning.Api.IntegrationTests/TestDocx.cs @@ -32,7 +32,9 @@ public static class TestDocx new RunProperties(new Bold()), new Text("[ชื่อพนักงาน]")))), - // SDT multi-run คนละ formatting — generate ต้องเขียนค่าที่ run แรก (รักษา bold) และลบ text ของ run หลัง + // SDT multi-run คนละ formatting — generate เขียนค่าที่ run ข้อความยาวสุด (v9) และลบ text ของอีก run + // ⚠️ สอง run ยาวเท่ากัน (8/8 อักขระ) โดยเจตนา: tie → run แรก (bold) ชนะเพราะ OrderByDescending + // เสถียร — GenerateTests/DraftTests ใช้จุดนี้เป็น regression guard ของพฤติกรรม tie ห้ามแก้ความยาว new Paragraph(SdtRun("position", new Run( new RunProperties(new Bold()), @@ -83,6 +85,49 @@ public static class TestDocx return buffer.ToArray(); } + /// template แบบที่พบใน template ไทยจริง (เช่น T14): placeholder แตกเป็น [ / ข้อความไทย / ] + /// หลาย run โดย run ข้อความไทยถือ complex script flags (w:hint="cs" + w:cs) ต่างจาก run ปีกกา + /// — และมี SDT เปล่าที่มี rPr อยู่ใน sdtPr เท่านั้น (formatting ที่ผู้แต่งกำหนดให้เนื้อหา) + public static byte[] BuildThaiTemplateDocx() + { + RunFonts ThaiFonts(bool hintCs) => new() + { + Ascii = "TH SarabunPSK", + HighAnsi = "TH SarabunPSK", + ComplexScript = "TH SarabunPSK", + Hint = hintCs ? FontTypeHintValues.ComplexScript : null, + }; + + // แบบ T14 หน้ารับส่ง: [ / รายละเอียด / ] — ค่าที่แทนที่ต้องได้ formatting ของ run ข้อความไทย + var detail = new Paragraph(new SdtRun( + new SdtProperties(new Tag { Val = "detail1" }), + new SdtContentRun( + new Run( + new RunProperties(ThaiFonts(false), new FontSize { Val = "36" }), + new Text("[")), + new Run( + new RunProperties(ThaiFonts(true), new FontSize { Val = "36" }, new ComplexScript()), + new Text("รายละเอียด")), + new Run( + new RunProperties(ThaiFonts(false), new FontSize { Val = "36" }), + new Text("]"))))); + + // SDT เปล่า: formatting อยู่ที่ sdtPr/rPr เท่านั้น — run ใหม่ต้องสืบทอดมา ไม่ใช่ default font + var remark = new Paragraph(new SdtRun( + new SdtProperties( + new RunProperties(ThaiFonts(true), new FontSize { Val = "36" }), + new Tag { Val = "remark1" }), + new SdtContentRun())); + + var buffer = new MemoryStream(); + using (var doc = WordprocessingDocument.Create(buffer, WordprocessingDocumentType.Document)) + { + var main = doc.AddMainDocumentPart(); + main.Document = new Document(new Body(detail, remark)); + } + return buffer.ToArray(); + } + /// อ่านข้อความรวมของ SDT ตาม tag (คืน "" เมื่อไม่มี tag นั้นในไฟล์) — ตรวจผลลัพธ์ generate public static string ReadSdtText(byte[] docx, string tag) { @@ -109,7 +154,7 @@ public static class TestDocx } /// อ่านข้อความ+formatting ของแต่ละ run ใน SDT (ตาม tag) — ใช้ตรวจว่า generate - /// รวม multi-run เป็น run เดียวแล้วยังรักษา run properties (bold/italic) ของ run แรกไว้ + /// รวม multi-run เหลือ run ที่ถือค่าแล้วยังรักษา run properties (bold/italic) ของ run นั้นไว้ (v9: run ข้อความยาวสุด) public static List<(string Text, bool Bold, bool Italic)> ReadSdtRuns(byte[] docx, string tag) { using var doc = WordprocessingDocument.Open(new MemoryStream(docx), false); @@ -140,6 +185,36 @@ public static class TestDocx ?.VTLPWSTR?.Text; } + /// ข้อมูล run หนึ่งตัวใน SDT (ตาม tag): ข้อความรวม + hint=cs ของ rFonts + flag complex script (w:cs) + /// + ฟอนต์ ascii — ใช้ตรวจว่าค่าที่แทนที่ได้ formatting ของ run ข้อความไทยจริง (FR-16 v9) + public record SdtRunInfo(string Text, bool CsHint, bool ComplexScript, string? AsciiFont); + + public static List ReadSdtRunInfo(byte[] docx, string tag) + { + using var doc = WordprocessingDocument.Open(new MemoryStream(docx), false); + var main = doc.MainDocumentPart!; + foreach (var root in new OpenXmlPartRootElement?[] { main.Document } + .Concat(main.HeaderParts.Select(h => (OpenXmlPartRootElement?)h.Header)) + .Concat(main.FooterParts.Select(f => (OpenXmlPartRootElement?)f.Footer))) + { + if (root is null) continue; + foreach (var sdtTag in root.Descendants()) + { + if (sdtTag.Val?.Value != tag) continue; + var sdt = sdtTag.Parent?.Parent; + var content = sdt?.ChildElements.FirstOrDefault(c => c.LocalName == "sdtContent"); + return content?.Descendants() + .Select(run => new SdtRunInfo( + string.Concat(run.Descendants().Select(t => t.Text)), + run.RunProperties?.RunFonts?.Hint?.Value == FontTypeHintValues.ComplexScript, + run.RunProperties?.ComplexScript is not null, + run.RunProperties?.RunFonts?.Ascii?.Value)) + .ToList() ?? []; + } + } + return []; + } + private static SdtRun SdtRun(string tag, params Run[] runs) => new( new SdtProperties(new Tag { Val = tag }), new SdtContentRun(runs)); diff --git a/backend/WordVersioning.Api.IntegrationTests/TestDtos.cs b/backend/WordVersioning.Api.IntegrationTests/TestDtos.cs index 3e4d619..15440cb 100644 --- a/backend/WordVersioning.Api.IntegrationTests/TestDtos.cs +++ b/backend/WordVersioning.Api.IntegrationTests/TestDtos.cs @@ -9,6 +9,9 @@ public record UploadVersionResult(Guid VersionId, int VersionNumber, bool HasPdf public record DownloadLinkResult(string Url, DateTimeOffset ExpiresAt); +/// response ของ POST generate-draft (FR-16) — presigned URL อายุสั้นของไฟล์ร่าง +public record DraftLinkResult(string Url, DateTimeOffset ExpiresAt); + public record GenerateResult(Guid DocumentId, Guid VersionId, int VersionNumber, string DownloadUrl); public record VersionItem(Guid VersionId, int VersionNumber, DateTimeOffset CreatedAt, string CreatedByUserId, string ContentHash, JsonElement? Fields, string? Message, bool HasPdf); diff --git a/backend/WordVersioning.Api.IntegrationTests/VersionPdfTests.cs b/backend/WordVersioning.Api.IntegrationTests/VersionPdfTests.cs index 9962152..2a3c0b3 100644 --- a/backend/WordVersioning.Api.IntegrationTests/VersionPdfTests.cs +++ b/backend/WordVersioning.Api.IntegrationTests/VersionPdfTests.cs @@ -166,6 +166,7 @@ public sealed class VersionPdfTests(IntegrationTestFixture fixture) } public Task GetStreamAsync(string bucket, string key, CancellationToken ct = default) => throw new NotSupportedException(); public string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry, string? contentDisposition = null) => throw new NotSupportedException(); + public Task EnsurePrefixExpiryAsync(string bucket, string prefix, int days, string ruleId, CancellationToken ct = default) => Task.FromResult(false); } } diff --git a/backend/WordVersioning.Api/Controllers/TemplatesController.cs b/backend/WordVersioning.Api/Controllers/TemplatesController.cs index 70e94a8..4204d98 100644 --- a/backend/WordVersioning.Api/Controllers/TemplatesController.cs +++ b/backend/WordVersioning.Api/Controllers/TemplatesController.cs @@ -305,11 +305,12 @@ public class TemplatesController( return Ok(new ReplaceTemplateFileResponse(template.Id, FieldDtos(template), addedTags, removedTags)); } - /// FR-16 — ไฟล์ร่าง (draft): generate docx จาก template แบบ stateless — ไม่สร้าง - /// Document/DocumentVersion/DocumentFieldValue และไม่เขียน MinIO; คืนไฟล์ทันที - /// (ผู้ใช้เปิดใน Word แล้วกด Start Versioning เพื่อเริ่ม track เป็น v1 พร้อมผูกกลับมาที่ template นี้) + /// FR-16 — ไฟล์ร่าง (draft): generate docx จาก template โดยไม่สร้าง + /// Document/DocumentVersion/DocumentFieldValue ใน DB — เก็บไฟล์ชั่วคราวใน bucket versions ใต้ prefix + /// `drafts/` (หมดอายุรายวันตาม lifecycle) แล้วคืน presigned URL อายุสั้นให้เว็บเปิดใน Word + /// ผ่าน ms-word:ofe พร้อมลิงก์ดาวน์โหลดสำรอง (v9 — ตอนแรกคืนไฟล์ตรงๆ แต่ผู้ใช้ต้องเปิด Word เอง) [HttpPost("{id:guid}/generate-draft")] - [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(GenerateDraftResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status502BadGateway)] @@ -366,11 +367,32 @@ public class TemplatesController( return StatusCode(StatusCodes.Status502BadGateway, "ไฟล์ template เสียหาย (เปิดด้วย OpenXML ไม่ได้)"); } - logger.LogInformation("Draft generated templateId={TemplateId} userId={UserId} bytes={Size}", - template.Id, currentUser.UserId, fileBytes.Length); + // เก็บชั่วคราวใต้ drafts/ — lifecycle ลบให้เอง (Program.cs ตั้ง rule ตอน startup) + var now = DateTimeOffset.UtcNow; + var objectKey = $"drafts/{template.Id}/{now:yyyyMMddHHmmss}_{Guid.NewGuid():N}.docx"; + try + { + await storage.PutAsync(minioOptions.Value.VersionsBucket, objectKey, + new MemoryStream(fileBytes), DocxUploadValidation.DocxContentType, ct); + } + catch (AmazonServiceException ex) + { + logger.LogError(ex, "Draft upload failed templateId={TemplateId} userId={UserId}", + template.Id, currentUser.UserId); + return StatusCode(StatusCodes.Status502BadGateway, "อัปโหลดไฟล์ขึ้น storage ไม่สำเร็จ"); + } - return File(fileBytes, DocxUploadValidation.DocxContentType, - DocxGenerationService.BuildGeneratedFileName(template.Name, DateTimeOffset.UtcNow)); + // presigned อายุสั้น + ชื่อไฟล์ไทยตาม RFC 5987 (นโยบายเดียวกับ download-link ของ version) + var expirySeconds = Math.Clamp(minioOptions.Value.PresignedExpirySeconds, 30, 600); + var fileName = DocxGenerationService.BuildGeneratedFileName(template.Name, now); + var url = storage.GetPresignedGetUrl( + minioOptions.Value.VersionsBucket, objectKey, TimeSpan.FromSeconds(expirySeconds), + $"attachment; filename*=UTF-8''{Uri.EscapeDataString(fileName)}"); + + logger.LogInformation("Draft generated templateId={TemplateId} userId={UserId} bytes={Size} key={ObjectKey}", + template.Id, currentUser.UserId, fileBytes.Length, objectKey); + + return Ok(new GenerateDraftResponse(url, now.AddSeconds(expirySeconds))); } /// deactivate (soft-delete) — document เก่าที่อ้างอยู่ยังใช้ได้ต่อ แต่สร้างเอกสารใหม่ไม่ได้ diff --git a/backend/WordVersioning.Api/Models/TemplateDtos.cs b/backend/WordVersioning.Api/Models/TemplateDtos.cs index a65f0a4..661cd91 100644 --- a/backend/WordVersioning.Api/Models/TemplateDtos.cs +++ b/backend/WordVersioning.Api/Models/TemplateDtos.cs @@ -61,6 +61,10 @@ public class GenerateDraftRequest public JsonElement Values { get; set; } } +/// response ของ POST /api/templates/{id}/generate-draft (FR-16) — presigned URL อายุสั้น +/// ให้เว็บเปิดใน Word ผ่าน ms-word:ofe พร้อมลิงก์ดาวน์โหลดสำรอง (object ใต้ drafts/ หมดอายุรายวันตาม lifecycle) +public record GenerateDraftResponse(string Url, DateTimeOffset ExpiresAt); + public record TemplateFieldDto(Guid Id, string Key, string Label, string Type, string Editor, bool IsRequired, int SortOrder); public record TemplateListItem( diff --git a/backend/WordVersioning.Api/Program.cs b/backend/WordVersioning.Api/Program.cs index a123d40..ebb2a34 100644 --- a/backend/WordVersioning.Api/Program.cs +++ b/backend/WordVersioning.Api/Program.cs @@ -2,6 +2,8 @@ using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Microsoft.OpenApi.Models; using WordVersioning.Api.Data; using WordVersioning.Api.Services; @@ -65,14 +67,11 @@ builder.Services builder.Services.AddAuthorization(); // CORS — add-in task pane (https://localhost:3000) + web portal (http://localhost:5173) เท่านั้น -// expose Content-Disposition ให้ fetch ฝั่งเว็บอ่านชื่อไฟล์ (ไทย) ของไฟล์ร่างที่ดาวน์โหลดได้ (FR-16) -// — ไม่ expose แล้ว header จะเป็น null บน cross-origin และชื่อไฟล์หายเงียบๆ const string corsPolicy = "frontends"; builder.Services.AddCors(options => options.AddPolicy(corsPolicy, policy => policy .WithOrigins(builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? []) .AllowAnyHeader() - .AllowAnyMethod() - .WithExposedHeaders("Content-Disposition"))); + .AllowAnyMethod())); // --- dev HTTPS ------------------------------------------------------------- // Word webview (WKWebView บน Mac) บล็อก fetch จากหน้า https ไป http แบบเงียบๆ ("Load failed") @@ -102,6 +101,21 @@ if (devHttpsEnabled) var app = builder.Build(); +// FR-16 — ไฟล์ร่างอยู่ใต้ prefix `drafts/` ของ bucket versions: ตั้ง lifecycle ให้หมดอายุเอง (idempotent) +// best-effort — MinIO ยังไม่พร้อมตอนสตาร์ทก็แค่ log เตือน ไม่บล็อก (ไฟล์ร่างจะค้างนานขึ้นเท่านั้น) +try +{ + await using var scope = app.Services.CreateAsyncScope(); + var storageInit = scope.ServiceProvider.GetRequiredService(); + var minioConfig = scope.ServiceProvider.GetRequiredService>().Value; + await storageInit.EnsurePrefixExpiryAsync( + minioConfig.VersionsBucket, "drafts/", minioConfig.DraftRetentionDays, "expire-drafts"); +} +catch (Exception ex) +{ + app.Logger.LogWarning(ex, "ตั้ง lifecycle ของไฟล์ร่าง (drafts/) ไม่สำเร็จ — object จะไม่หมดอายุอัตโนมัติ"); +} + if (app.Environment.IsDevelopment()) { app.UseSwagger(); diff --git a/backend/WordVersioning.Api/Services/DocxGenerationService.cs b/backend/WordVersioning.Api/Services/DocxGenerationService.cs index 2dd724a..4646f77 100644 --- a/backend/WordVersioning.Api/Services/DocxGenerationService.cs +++ b/backend/WordVersioning.Api/Services/DocxGenerationService.cs @@ -108,8 +108,11 @@ public class DocxGenerationService : IDocxGenerationService } } - /// แทนที่ข้อความใน SDT ที่ run level — เก็บ Run เดิมไว้ตัวแรก (formatting ตาม template) - /// แล้วลบ Text ที่เหลือ ไม่ทับทั้ง paragraph (spec ส่วน 9 เพิ่มเติม v2) + /// แทนที่ข้อความใน SDT ที่ run level — เขียนค่าที่ Run ของ "ข้อความ placeholder จริง" + /// (run ที่มีข้อความยาวสุด) เพื่อสืบทอด rPr ของมัน แล้วลบ Text ที่เหลือ ไม่ทับทั้ง paragraph + /// (spec ส่วน 9 เพิ่มเติม v2 · v9: template ไทยมักแตก placeholder เป็น [ / ข้อความ / ] หลาย run — + /// ตัวข้อความคือ run ที่ถือ complex script flags (w:hint="cs" + w:cs) ที่ทำให้ Word ตัดคำ/กระจายไทยถูก + /// ถ้าเขียนลง run วงเล็บที่ไม่มี flags นี้ ค่ายาวๆ จะจัดหน้าผิดทั้งบรรทัด) private static void ReplaceSdtContent(Tag tag, string value) { var sdtPr = tag.Parent; // w:sdtPr @@ -122,7 +125,11 @@ public class DocxGenerationService : IDocxGenerationService if (texts.Count == 0) { // SDT ไม่มีข้อความเลย (เช่น ที่ว่างมา) — สร้าง Run ใหม่ใน paragraph แรก (block) หรือใน content ตรงๆ (run) + // โดยสืบทอด rPr จาก sdtPr ถ้ามี (คือ formatting ที่ผู้แต่ง template กำหนดให้เนื้อหาของ control นี้ — + // เช่นเดียวกับที่ Word ใช้ตอนผู้ใช้พิมพ์ลง control ไม่ใช่ปล่อยเป็น default font) var run = new Run(new Text(value) { Space = SpaceProcessingModeValues.Preserve }); + if (sdtPr?.Elements().FirstOrDefault() is { } sdtRunProperties) + run.RunProperties = (RunProperties)sdtRunProperties.CloneNode(true); if (content is SdtContentBlock) { var paragraph = content.ChildElements.OfType().FirstOrDefault(); @@ -140,12 +147,16 @@ public class DocxGenerationService : IDocxGenerationService return; } - // เขียนที่ Text แรก (อยู่ใน Run แรก — สืบทอด rPr ของ run นั้น) แล้วเก็บ space ไว้กรณีค่ามีช่องว่างหัว/ท้าย - texts[0].Text = value; - texts[0].Space = SpaceProcessingModeValues.Preserve; + // เลือก Run ผู้บริจาค formatting = run ที่มีข้อความยาวสุด (tie → run แรก) — หลีกเลี่ยง run ปีกกา/สัญลักษณ์ + // ที่ formatting มักไม่ครบ (เช่น ไม่มี complex script flags) + var donorText = texts.OrderByDescending(t => t.Text?.Length ?? 0).First(); - // Text ที่เหลือ (จากการ split run เดิม เช่น rsid/formatting ปนกัน) ลบทิ้ง — field = ค่าเดียว - foreach (var text in texts.Skip(1)) + // เขียนที่ Text ของ donor แล้วเก็บ space ไว้กรณีค่ามีช่องว่างหัว/ท้าย + donorText.Text = value; + donorText.Space = SpaceProcessingModeValues.Preserve; + + // Text อื่นทั้งหมด (รวม donor ถ้ามีหลาย Text ใน run เดียวกัน ไม่เกิด) ลบทิ้ง — field = ค่าเดียว + foreach (var text in texts.Where(t => !ReferenceEquals(t, donorText))) { var run = text.Parent; text.Remove(); diff --git a/backend/WordVersioning.Api/Services/MinioOptions.cs b/backend/WordVersioning.Api/Services/MinioOptions.cs index 8fbd4a2..b27034c 100644 --- a/backend/WordVersioning.Api/Services/MinioOptions.cs +++ b/backend/WordVersioning.Api/Services/MinioOptions.cs @@ -21,4 +21,8 @@ public class MinioOptions /// อายุของ presigned download URL ที่ออกให้เบราว์เซอร์ (วินาที) — สั้นๆ ตาม spec public int PresignedExpirySeconds { get; set; } = 120; + + /// FR-16 — จำนวนวันที่ไฟล์ร่าง (object ใต้ prefix `drafts/` ของ VersionsBucket) ถูกลบโดย + /// lifecycle rule (ผู้ใช้เปิดผ่าน presigned URL ในไม่กี่นาทีแรก หลังจากนั้นเป็นขยะ) + public int DraftRetentionDays { get; set; } = 1; } diff --git a/backend/WordVersioning.Api/Services/ObjectStorageService.cs b/backend/WordVersioning.Api/Services/ObjectStorageService.cs index d848158..69d2c55 100644 --- a/backend/WordVersioning.Api/Services/ObjectStorageService.cs +++ b/backend/WordVersioning.Api/Services/ObjectStorageService.cs @@ -16,6 +16,12 @@ public interface IObjectStorageService /// สร้าง presigned GET URL อายุสั้น — ให้เปิดในเบราว์เซอร์ของผู้ใช้โดยตรง /// (webview ของ Word ทิ้ง blob download แบบเงียบๆ — spec อนุญาต presigned URL อายุสั้นสำหรับดาวน์โหลด) string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry, string? contentDisposition = null); + + /// FR-16 — ตั้ง lifecycle rule ให้ object ใต้ prefix หนึ่งหมดอายุเองตามจำนวนวัน + /// (idempotent ตาม rule id และ sync ค่า days ใหม่ถ้า config เปลี่ยนหลัง boot แรก — days < 1 คือ throw) + /// ใช้กับไฟล์ร่างที่เก็บชั่วคราวใน bucket versions ใต้ `drafts/` + /// คืน true เมื่อเขียน config ใหม่, false เมื่อ rule ถูกต้องอยู่แล้ว (ใช้ assert ใน test) + Task EnsurePrefixExpiryAsync(string bucket, string prefix, int days, string ruleId, CancellationToken ct = default); } /// Wrapper เรียก MinIO ผ่าน AWSSDK.S3 (S3-compatible API) @@ -99,6 +105,54 @@ public class ObjectStorageService : IObjectStorageService return url; } + public async Task EnsurePrefixExpiryAsync(string bucket, string prefix, int days, string ruleId, CancellationToken ct = default) + { + ArgumentOutOfRangeException.ThrowIfLessThan(days, 1); + + var rules = new List(); + try + { + var existing = await _s3.GetLifecycleConfigurationAsync( + new GetLifecycleConfigurationRequest { BucketName = bucket }, ct); + // Rules เป็น null ได้เมื่อ bucket มี lifecycle config แต่ยังไม่มี rule เลย + rules = existing.Configuration.Rules ?? []; + } + catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + { + // ยังไม่มี lifecycle เลย — เริ่มชุดใหม่ + } + + var rule = rules.FirstOrDefault(r => r.Id == ruleId); + if (rule is null) + { + rules.Add(new LifecycleRule + { + Id = ruleId, + Filter = new LifecycleFilter { Prefix = prefix }, + Status = LifecycleRuleStatus.Enabled, + Expiration = new LifecycleRuleExpiration { Days = days }, + }); + } + else if (rule.Expiration?.Days == days) + { + _logger.LogDebug("Lifecycle rule {RuleId} มีอยู่แล้วที่ {Bucket} (expire={Days}d)", ruleId, bucket, days); + return false; + } + else + { + // config เปลี่ยนหลัง boot แรก — sync ค่าใหม่ ไม่ใช่ปล่อยให้เป็น no-op เงียบๆ + rule.Expiration = new LifecycleRuleExpiration { Days = days }; + } + + await _s3.PutLifecycleConfigurationAsync(new PutLifecycleConfigurationRequest + { + BucketName = bucket, + Configuration = new LifecycleConfiguration { Rules = rules }, + }, ct); + _logger.LogInformation("Lifecycle rule {RuleId} ตั้งแล้ว: {Bucket}/{Prefix} expire={Days}d", ruleId, bucket, prefix, days); + return true; + } + /// Stream ที่ครอบ GetObjectResponse — dispose stream = dispose response ทั้งอัน /// (ไม่งั้น controller ต้องรับ response มาด้วยแล้วจัดการ lifetime เอง ยุ่ง) private sealed class S3ResponseStream(GetObjectResponse response) : Stream diff --git a/docs/acceptance-review.md b/docs/acceptance-review.md index 8ede5ba..418b074 100644 --- a/docs/acceptance-review.md +++ b/docs/acceptance-review.md @@ -136,14 +136,15 @@ QA reviewer: **PASS**, 2 rounds, 3 findings fixed (build compatibility, pending PDF tab cleanup, PDF success copy); 2026-09-08. Manual acceptance ผ่านบน Word for Mac 2026-09-08 — **Phase 11 ปิดแล้ว**; ค้างเฉพาะ Word for Windows / Word on the web ตามรายการด้านบน. -## AC-19 — ไฟล์ร่าง (v8) +## AC-19 — ไฟล์ร่าง (v8 · ปรับ v9) -**ผล: 🔶 ผ่านด้วย automated tests — ค้างยืนยันด้วยมือบน Word จริง** (ตาม pattern เดิม: unit/integration tests ยืนยัน contract และข้อมูลใน DB ได้ แต่ไม่ยืนยันพฤติกรรม add-in ใน Word จริงได้) +**ผล: 🔶 ผ่านด้วย automated tests — ค้างยืนยันด้วยมือบน Word จริง** (ตาม pattern เดิม: unit/integration tests ยืนยัน contract และข้อมูลใน DB ได้ แต่ไม่ยืนยันพฤติกรรม add-in/การเรนเดอร์ Word จริงได้) | เกณฑ์ | หลักฐาน | |---|---| -| สร้างไฟล์ร่างแล้ว stateless จริง (ไม่เกิด Document/version/field values) + ค่าครบ + formatting รักษาไว้ | `DraftTests.Draft_FillsAllFields_PreservesFormatting_StampsOnlyTemplateId_CreatesNothing` | +| สร้างไฟล์ร่างแล้วไม่เกิด Document/version/field values ใน DB + ค่าครบ + formatting รักษาไว้ + **presigned URL ดึงไฟล์จริงได้ทันที** (v9) | `DraftTests.Draft_FillsAllFields_PreservesFormatting_StampsOnlyTemplateId_CreatesNothing` | | stamp เฉพาะ `TemplateId` — ไม่มี SavedByAddIn/DocumentTrackingId | test เดียวกัน (อ่าน custom properties จากไฟล์จริง) | +| **ค่าที่แทนที่ได้ complex script ของ run ข้อความไทย (w:hint="cs"+w:cs) · SDT เปล่าสืบทอด sdtPr rPr** (v9 — แก้การตัดคำไทย) | `DraftTests.Draft_ThaiPlaceholder_UsesComplexScriptRun_ForWordBreaking` (template จำลองโครงสร้าง T14) | | validate เหมือน FR-10: required ขาด/type ผิด → 400, unknown → 404, deactivate → 400, ค่าเกินนิยาม ignore | `DraftTests` ที่เหลือ (5 tests) | | Start Versioning ของไฟล์ร่าง: upload + templateId → **v1** + Document ผูก template + field sync ครบ + snapshot แบบมีนิยาม | `TemplateLinkingTests.Upload_WithTemplateId_OnNewDocument_CreatesLinkedDocV1WithFieldSync` | | upload ถัดไปไม่ส่ง templateId → v2, TemplateId คงเดิม | test เดียวกัน (upload รอบสอง) | @@ -152,15 +153,17 @@ QA reviewer: **PASS**, 2 rounds, 3 findings fixed (build compatibility, pending | document เดิม (standalone) ส่ง templateId มา → เฉย | `Upload_ExistingDocument_IgnoresTemplateId` | | endpoint ใหม่ต้อง [Authorize] | `AuthTests` เพิ่ม `POST api/templates/{0}/generate-draft` | | add-in: อ่าน TemplateId / ส่งเฉพาะ startVersioning / append form field | `metadataService.test.ts` (ไฟล์ร่างอ่าน TemplateId), `versionFlow.test.ts` (แนบ/ไม่แนบ templateId), `versioningService.test.ts` (form field) | -| เว็บ: ปุ่มไฟล์ร่าง POST → blob, ชื่อไทยจาก Content-Disposition, 401 retry, !ok → error | `useApi.spec.ts` (4 tests) + CORS expose `Content-Disposition` ที่ `Program.cs` | +| เว็บ: เปิด docx URL ที่ถืออยู่เอง (ไฟล์ร่าง) ผ่าน ms-word + fallback + notice | `useVersionOpen.spec.ts` `openDocxUrl` test | -ชุดเต็ม: backend **82 ผ่าน**, add-in **66 ผ่าน**, web **59 ผ่าน** + production build ผ่าน. +ชุดเต็ม (v9): backend **84 ผ่าน**, add-in **66 ผ่าน**, web **56 ผ่าน** + production build ผ่าน (backend 82 / web 59 ของ v8 + แก้ v9) ### ค้างยืนยันด้วยมือ (ต้องมี Word จริง) -- [ ] Mac: เว็บสร้างไฟล์ร่าง → ได้ .docx ชื่อไทยถูกต้อง → เปิดใน Word → task pane แสดง untracked + hint ไฟล์ร่าง → Start Versioning → v1 + หน้า document detail ของเว็บแสดง template และค่า fields +- [ ] Mac: เว็บสร้างไฟล์ร่าง → **Word เปิดไฟล์ทันที** พร้อมลิงก์สำรอง → ตรวจการตัดคำ/จัดหน้าภาษาไทยของ field ที่ค่ายาว (เช่น Detail ของ T14) ตรงตาม template → task pane แสดง untracked + hint ไฟล์ร่าง → Start Versioning → v1 + หน้า document detail ของเว็บแสดง template และค่า fields - [ ] Word for Windows / Word on the web — ตามข้อค้างเดิมตั้งแต่ AC-1 รายการเต็ม: [แผน FR-16](plans/fr16-draft-generation-plan.md). QA reviewer: **PASS**, 2 rounds, 8 findings แก้ครบไม่มีที่ถูกปฏิเสธ (2026-09-10) — round 1: blocker `.at(-1)` ใน test ใหม่ตัดกับ tsconfig `lib: es2020` ทำ `addin npm run build` พัง (jest ไม่ type-check จึงรอดมา) + minors 4 (นับจำนวน test ผิดในตารางนี้, stale notices ระหว่างสองปุ่ม submit, AlertBox สำเร็จไม่ใช่ `type="success"`, `isTrackedDocument` ไม่มี production caller → เก็บไว้พร้อมหมายเหตุเหตุผล) · round 2: minors 2 + pre-existing 1 (หัวเอกสาร/สถานะเก่าใน acceptance-review, CLAUDE.md, implementation-plan header). ค้าง manual บน Word จริงตามรายการด้านบน. + +QA reviewer รอบ v9 (เปิดใน Word + ตัดคำไทย): **PASS**, 2 rounds, 10 findings แก้ครบไม่มีที่ถูกปฏิเสธ (2026-09-10) — round 1: minors 6 (dead `downloadFile`/`filenameFrom`, glossary "stateless" เก่า, lifecycle config เปลี่ยนแล้วเป็น no-op เงียบๆ + days ไม่ validate, ไม่มี test คุม lifecycle, comment เก่าใน TestDocx, typo ใน spec) · round 2: minors 2 + nits 2 (comment เก่าอีก 2 จุด, "(stateless)" ตกค้าง 2 บรรทัด, lifecycle test เพิ่ม assertion ผ่านค่าคืน `true/false`, ตัว verdict นี้เอง) — และ test ใหม่จับ bug จริงได้: `Configuration.Rules` เป็น null ได้ทำให้ startup ตั้ง rule ล้มแบบเงียบมาตั้งแต่แรก (โดน catch กลืน) แก้ด้วย `?? []`. diff --git a/docs/adr/0002-draft-stateless.md b/docs/adr/0002-draft-stateless.md index 86fae5c..fcc3ad1 100644 --- a/docs/adr/0002-draft-stateless.md +++ b/docs/adr/0002-draft-stateless.md @@ -1,14 +1,18 @@ -# ADR 0002: ไฟล์ร่าง stateless — stamp TemplateId ตัวเดียว ผูก template ทีหลังตอน Start Versioning +# ADR 0002: ไฟล์ร่าง — stamp TemplateId ตัวเดียว ผูก template ทีหลังตอน Start Versioning -วันที่: 2026-09-10 · สถานะ: Accepted +วันที่: 2026-09-10 · สถานะ: Accepted (แก้ไข v9 วันเดียวกัน — ด้วย Amendment ด้านล่าง) ## Decision -ไฟล์ร่าง (FR-16) สร้างแบบ stateless: `POST /api/templates/{id}/generate-draft` คืนไฟล์ทันที ไม่สร้าง Document/version ไม่เขียน MinIO. ไฟล์ถูก stamp custom property `TemplateId` ตัวเดียว (ไม่มี SavedByAddIn/DocumentTrackingId) จึงเป็น untracked ในสายตา add-in. การผูกกลับเข้า template เกิดทีหลังผ่าน form field `templateId` ทางเลือกของ upload ซึ่งใช้เฉพาะตอน auto-create Document — เอกสารเกิดเป็น version 1 โดยไม่มี v0. +ไฟล์ร่าง (FR-16) สร้างโดยไม่มี record ใน DB: `POST /api/templates/{id}/generate-draft` ไม่สร้าง Document/version/field values. ไฟล์ถูก stamp custom property `TemplateId` ตัวเดียว (ไม่มี SavedByAddIn/DocumentTrackingId) จึงเป็น untracked ในสายตา add-in. การผูกกลับเข้า template เกิดทีหลังผ่าน form field `templateId` ทางเลือกของ upload ซึ่งใช้เฉพาะตอน auto-create Document — เอกสารเกิดเป็น version 1 โดยไม่มี v0. + +## Amendment (v9, 2026-09-10 — หลังใช้งานจริง) +เดิม endpoint คืนไฟล์เป็น stream ตรงๆ (stateless เต็มที่) ทำให้ผู้ใช้ต้องเปิด Word เองจาก Downloads. ผู้ใช้ต้องการเปิดใน Word ทันทีแบบ flow v0 จึงแก้เป็น: generate แล้วเก็บ object ชั่วคราวใต้ prefix `drafts/` ของ bucket docx-versions พร้อม **lifecycle rule ลบรายวัน** (config ได้) แล้วคืน presigned URL อายุสั้นให้เว็บเปิดผ่าน `ms-word:ofe`. สาระสำคัญของ decision เดิมยังอยู่: ไม่มี Document สถานะร่างใน DB ไม่ต้องจัดการ row ที่ไม่ถูกเปิด — ขยะจำกัดอยู่แค่ object ใต้ prefix เดียวที่ลบตัวเอง + +นอกจากนี้พบจาก template จริง (T14): ค่าที่แทนที่ต้องเขียนที่ run ของข้อความ placeholder จริง (run ที่ถือ `w:hint="cs"` + `w:cs`) ไม่ใช่ run แรก (ปีกกา) ไม่งั้น Word ตัดคำ/กระจายภาษาไทยผิดทั้งบรรทัด และ SDT เปล่าต้องสืบทอด rPr จาก sdtPr ## Alternatives rejected -- สร้าง Document สถานะ "ร่าง" ค้างไว้ใน DB + ไฟล์ใน MinIO รอผู้ใช้เปิด: ต้องมี lifecycle/cleanup ของเอกสารที่ไม่ถูกเปิด และเอกสารขยะในหน้ารายการ +- สร้าง Document สถานะ "ร่าง" ค้างไว้ใน DB รอผู้ใช้เปิด: ต้องมี lifecycle/cleanup ของ row ที่ไม่ถูกเปิด และเอกสารขยะในหน้ารายการ - stamp SavedByAddIn + DocumentTrackingId ตั้งแต่ตอนสร้างไฟล์ร่าง: add-in จะมองเป็น tracked document แต่ backend ไม่มี record → history ติด 404 ถาวร (ไม่มี 404-handling ในปัจจุบัน) -- ปุ่ม "เปิดใน Word" สำหรับไฟล์ร่างแบบ flow v0 (`ms-word:ofe|u|`): ไม่มี URL เพราะไม่มี object ใน storage — ต้องเสีย statelessness ไปเก็บไฟล์ชั่วคราวพร้อม TTL ## Consequences -ไฟล์ร่างหาย/ไม่ถูกเปิด = ไม่มีอะไรค้างในระบบ (ต้องกรอกฟอร์มใหม่). เอกสารจากไฟล์ร่างเริ่มที่ v1 ไม่มี v0 — เลข version ยังกำหนดเป็น "รอบบันทึกผ่าน Word หลังฉบับตั้งต้น" โดย v0 เป็นทางเลือกของ flow เดิม. Field sync เริ่มทำงานที่ v1 จากค่าที่ extract ใน Word. CORS ต้อง expose `Content-Disposition` เพื่อให้เว็บอ่านชื่อไฟล์ไทยได้. +เอกสารจากไฟล์ร่างเริ่มที่ v1 ไม่มี v0 — เลข version ยังกำหนดเป็น "รอบบันทึกผ่าน Word หลังฉบับตั้งต้น" โดย v0 เป็นทางเลือกของ flow เดิม. Field sync เริ่มทำงานที่ v1 จากค่าที่ extract ใน Word. Object ไฟล์ร่างที่ไม่ถูกเปิดถูก lifecycle ลบในรอบวัน (ลิงก์ presigned อายุสั้นอยู่แล้ว การเปิดซ้ำหลังผ่านไปนานต้องสร้างไฟล์ร่างใหม่) diff --git a/docs/plans/officejs-implementation-plan.md b/docs/plans/officejs-implementation-plan.md index 6404ef1..6a1dd74 100644 --- a/docs/plans/officejs-implementation-plan.md +++ b/docs/plans/officejs-implementation-plan.md @@ -640,9 +640,10 @@ npm test # vitest (46 ตัว) รายละเอียดเต็ม: [fr16-draft-generation-plan.md](fr16-draft-generation-plan.md) — decisions จาก grill session (stateless, stamp TemplateId ตัวเดียว, ผูก template ผ่าน form field `templateId` ตอน Start Versioning, deactivated ผูกได้, คง flow v0, ศัพท์ "ไฟล์ร่าง") - [x] Spec v8 + glossary + ADR 0002 -- [x] Backend: `POST /api/templates/{id}/generate-draft` (stateless) + `DocxGenerationService.GenerateDraft` + แยก `TemplateFieldValidation` + form field `templateId` ของ upload + CORS expose `Content-Disposition` +- [x] Backend: `POST /api/templates/{id}/generate-draft` (ตอน v8 คืนไฟล์ตรงๆ — ปรับใน v9) + `DocxGenerationService.GenerateDraft` + แยก `TemplateFieldValidation` + form field `templateId` ของ upload - [x] Add-in: อ่าน `TemplateId` + ส่ง `templateId` เฉพาะ Start Versioning + hint ไฟล์ร่างใน task pane -- [x] Web: ปุ่ม "สร้างไฟล์ร่าง" + `downloadFile` รับ `init` (POST → blob) +- [x] Web: ปุ่ม "สร้างไฟล์ร่าง" - [x] Tests/build (backend 82 / add-in 66 / web 59 + build ทั้ง add-in/web) - [x] QA reviewer PASS (2 rounds, 2026-09-10) -- [ ] ตรวจ Word จริง (manual): เว็บสร้างไฟล์ร่าง → เปิดใน Word → untracked + hint → Start Versioning → v1 ผูก template + fields sync · รวมในรายการค้างเดิมของ Word for Windows / Word on the web +- [x] **v9 (feedback จากการใช้จริง): เปิดใน Word ทันที** — เก็บ object ใต้ `drafts/` + lifecycle รายวัน + คืน presigned URL + `useVersionOpen.openDocxUrl` · **แก้ตัดคำไทย** — ค่าเขียนที่ run ข้อความ placeholder จริง (complex script) + SDT เปล่าสืบทอด sdtPr rPr (backend 84 / web 56) + QA reviewer PASS (2 rounds, 2026-09-10) +- [ ] ตรวจ Word จริง (manual): เว็บสร้างไฟล์ร่าง → **Word เปิดทันที + ตัดคำไทยถูก** → untracked + hint → Start Versioning → v1 ผูก template + fields sync · รวมในรายการค้างเดิมของ Word for Windows / Word on the web diff --git a/specs/spec-officejs.md b/specs/spec-officejs.md index 8a9a4a2..805c15d 100644 --- a/specs/spec-officejs.md +++ b/specs/spec-officejs.md @@ -1,5 +1,6 @@ # Spec: Word Versioning Add-in (Office.js Cross-Platform + .NET Backend) +> ฉบับแก้ไข v9 (2026-09-10): ปรับ FR-16 ตามผลใช้งานจริง — ไฟล์ร่างเก็บชั่วคราวใน MinIO (prefix `drafts/` + lifecycle ลบรายวัน) เพื่อคืน presigned URL ให้เว็บ**เปิดใน Word ทันที** (เดิมคืนไฟล์ตรงๆ ผู้ใช้ต้องเปิดเอง) + ค่าที่แทนที่เขียนที่ run ข้อความ placeholder จริง (complex script ไทย) แก้การตัดคำภาษาไทย > ฉบับแก้ไข v8 (2026-09-10): เพิ่ม FR-16 — ไฟล์ร่าง (draft): สร้าง .docx จาก template แบบ stateless (ไม่สร้าง Document/version, stamp property `TemplateId` ตัวเดียว) + ผูก template ตอน Start Versioning ผ่าน form field `templateId` ทางเลือก — ไฟล์ร่างเริ่ม versioning ที่ v1 โดยไม่มี v0 > ฉบับแก้ไข v7 (2026-09-08): FR-15 — PDF จาก Word แบบ best-effort; generate เป็น v0, add-in เริ่ม v1; เว็บเปิด Word/PDF พร้อมดาวน์โหลดสำรอง > ฉบับแก้ไข v6 (2026-08-22): เพิ่ม FR-14 — editor ต่อ field (รูปแบบช่องกรอกในฟอร์มสร้างเอกสาร): `text` เลือก `textbox`/`textarea` ได้, `number`/`date` ใช้ control ตาม type — presentation metadata เท่านั้น ค่า field ยังเป็น string ทั้ง flow @@ -293,13 +294,14 @@ POST /api/documents/generate { templateId, values: { : } } - เว็บ history ทั้ง desktop/mobile เปิด docx ผ่าน `ms-word:ofe|u|` (ไม่ encode URL ทั้งก้อนซ้ำตาม Microsoft Office URI Schemes); PDF แท็บใหม่; มีลิงก์ดาวน์โหลดสำรองและ heuristic blur/visibilitychange ~1.5s ซึ่งไม่ใช่หลักฐานว่า Word เปิดสำเร็จ - หน้า generate success เปิด v0 ใน Word พร้อมลิงก์ดาวน์โหลดสำรอง; PDF fidelity และ tracked changes ต้องยืนยันบน Word จริง -### FR-16: ไฟล์ร่าง (draft) — สร้าง .docx จาก template โดยไม่สร้าง Document (เพิ่มเติม v8) -- **`POST /api/templates/{templateId}/generate-draft`** body `{ values }` — **stateless**: ไม่สร้าง `Document`/`DocumentVersion`/`DocumentFieldValue` และไม่เขียน MinIO; คืนไฟล์ .docx ทันที (200 + attachment, ชื่อ `ชื่อtemplate_yyyyMMdd-HHmmss.docx` — ไม่ใช่ 201 เพราะไม่มีอะไรถูกสร้าง) +### FR-16: ไฟล์ร่าง (draft) — สร้าง .docx จาก template โดยไม่สร้าง Document (เพิ่มเติม v8 · ปรับ v9) +- **`POST /api/templates/{templateId}/generate-draft`** body `{ values }` — ไม่สร้าง `Document`/`DocumentVersion`/`DocumentFieldValue` ใน DB; generate ไฟล์แล้ว**เก็บชั่วคราว**ใน bucket `docx-versions` ใต้ prefix `drafts/{templateId}/{yyyyMMddHHmmss}_{guid}.docx` ซึ่ง lifecycle rule ลบให้เอง (default 1 วัน — config `Minio__DraftRetentionDays`, backend ตั้ง rule ตอน startup แบบ idempotent/best-effort) แล้วคืน **200 `{ url, expiresAt }`** เป็น presigned GET URL อายุสั้น (attachment + ชื่อไฟล์ไทย RFC 5987) ให้เว็บเปิดใน Word ทันที (v8 เดิมคืนไฟล์เป็น stream ตรงๆ — ผู้ใช้ต้องเปิดจาก Downloads เอง) - validate เหมือน FR-10 (required + type + format; ส่งเกินนิยาม ignore) · template ไม่พบ → 404, deactivate → 400 ตามนโยบาย FR-10 · ไม่มี `message` (ไม่มี version จึงไม่มี commit message) - stamp custom property **`TemplateId` = GUID ของ template ตัวเดียว** — ไม่ stamp `SavedByAddIn`/`DocumentTrackingId` ไฟล์ร่างจึงเป็น untracked ในสายตา add-in (เลี่ยงสถานะ "tracked แต่ history 404") +- **ค่าที่แทนที่เขียนที่ run ของ "ข้อความ placeholder จริง"** (v9 — ใช้ร่วมทั้ง generate และ generate-draft): template ไทยมักแตก placeholder เป็น `[` / ข้อความ / `]` หลาย run โดย run ข้อความถือ `w:hint="cs"` + `w:cs` (complex script) ที่ Word ใช้ตัดคำ/กระจายภาษาไทย — เขียนค่าที่ run ที่มีข้อความยาวสุด (ไม่ใช่ run แรกที่เป็นปีกกา) · SDT เปล่าให้ run ใหม่สืบทอด `rPr` จาก `sdtPr` (เหมือนพฤติกรรม Word ตอนพิมพ์ลง control) - **ผูก template ตอน Start Versioning:** `POST /api/documents/{documentId}/versions` รับ `templateId` (multipart form field ทางเลือก) — ใช้เฉพาะตอน auto-create `Document` (field sync ทำงานเต็มระบบที่ v1); document มีอยู่แล้ว → เฉย (แต่ validate ทุกครั้งที่ส่งมา) · ไม่ใช่ GUID / ไม่พบใน DB → 400 · template ที่ deactivate → **ผูกได้** (ไฟล์ร่างถูกสร้างตอน template ยัง active — นโยบายเดียวกับ FR-9/FR-12) · upload แรกของ Document ใหม่ = **version 1** (max+1 — ไม่มี v0) - Add-in: อ่าน property `TemplateId` ตอน untracked แล้วแนบ `templateId` **เฉพาะตอน Start Versioning** (Save & Version ไม่ส่ง) + แสดง hint ว่าไฟล์นี้เป็นไฟล์ร่างจาก template -- Web Portal: ปุ่ม "สร้างไฟล์ร่าง" ที่สองบนฟอร์มสร้างเอกสารเดิม → ดาวน์โหลดไฟล์ทันที (browser download ผ่าน Bearer — ชื่อไฟล์อ่านจาก `Content-Disposition` ที่ CORS ต้อง expose); ฟอร์มอยู่ต่อ ไม่มี documentId/หน้าผลลัพธ์/ปุ่มเปิดใน Word (ไม่มี URL ให้ `ms-word:ofe`) · ช่องคำอธิบาย version ใช้กับ flow สร้างเอกสาร v0 เท่านั้น · flow v0 (FR-10) และ re-generate (FR-12) คงเดิม +- Web Portal: ปุ่ม "สร้างไฟล์ร่าง" ที่สองบนฟอร์มสร้างเอกสารเดิม → **เปิดใน Word ทันที** ผ่าน `ms-word:ofe|u|` พร้อมลิงก์ดาวน์โหลดสำรองอายุสั้น + heuristic เตือนเมื่อไม่เห็นการสลับแอป (กลไกเดียวกับ flow เปิด v0 — `useVersionOpen`); ฟอร์มอยู่ต่อ ไม่มี documentId/หน้าผลลัพธ์ · ช่องคำอธิบาย version ใช้กับ flow สร้างเอกสาร v0 เท่านั้น · flow v0 (FR-10) และ re-generate (FR-12) คงเดิม ## 7. Data Model (Backend) @@ -429,7 +431,7 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi --- 18. (v7, AC-18) generate ได้ v0 ไม่มี PDF → Save & Version ได้ v1 พร้อม PDF; Start Versioning ได้ v1 พร้อม PDF; PDF capture/storage ล้มยังบันทึก docx สำเร็จพร้อมคำเตือน; history แสดง PDF เฉพาะที่มี; download/proxy/presigned PDF ถูก bytes/type/ชื่อ และไม่มี → 404; เว็บเปิด Word/PDF พร้อม fallback; ตรวจ fidelity/markup บน Word จริง -19. (v8, AC-19) สร้างไฟล์ร่างจากฟอร์มเว็บ → ได้ .docx ค่าครบ ชื่อไทยถูกต้อง โดย**ไม่เกิด Document/version ใดๆ ในระบบ** → เปิดใน Word แล้ว add-in แสดงสถานะ untracked พร้อม hint ไฟล์ร่าง → กด Start Versioning → Document ถูกสร้าง ผูก template ต้นทาง + **version 1** + field values sync ครบ → templateId ไม่ใช่ GUID/ไม่พบ → 400 · template deactivate ผูกได้ · Save & Version ถัดไปได้ v2, v3… โดยไม่ส่ง templateId · flow สร้างเอกสาร v0 (FR-10) ยังทำงานได้คู่กัน +19. (v8, AC-19 · ปรับ v9) สร้างไฟล์ร่างจากฟอร์มเว็บ → **เปิดใน Word ทันที** (ms-word:ofe + ลิงก์สำรองอายุสั้น) ได้ .docx ค่าครบ **จัดหน้า/ตัดคำไทยถูกต้องตาม template** โดยไม่เกิด Document/version ใน DB (object ใต้ `drafts/` หมดอายุรายวัน) → add-in แสดง untracked + hint ไฟล์ร่าง → Start Versioning → Document + template + **v1** + fields sync ครบ → templateId ไม่ใช่ GUID/ไม่พบ → 400 · deactivate ผูกได้ · Save & Version ถัดไป v2, v3… · flow v0 (FR-10) ยังทำงานคู่กัน ## 11. งานที่ Claude Code ควรทำ @@ -476,6 +478,10 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi - Web: เปิด Word/PDF + fallback + history v0/v1+ ### เพิ่มเติม v8 (FR-16) -- Backend: `POST /api/templates/{id}/generate-draft` (stateless) + `DocxGenerationService.GenerateDraft` (stamp เฉพาะ `TemplateId`) + แยก `TemplateFieldValidation` ใช้ร่วมกับ generate + form field `templateId` ทางเลือกของ upload (ใช้ตอน auto-create) + CORS expose `Content-Disposition` +- Backend: `POST /api/templates/{id}/generate-draft` (ตอน v8 คืนไฟล์ตรงๆ — ปรับเป็นเก็บชั่วคราว + presigned URL ใน v9) + `DocxGenerationService.GenerateDraft` (stamp เฉพาะ `TemplateId`) + แยก `TemplateFieldValidation` ใช้ร่วมกับ generate + form field `templateId` ทางเลือกของ upload (ใช้ตอน auto-create) - Add-in: อ่าน `TemplateId` ใน `readTrackedProperties` + ส่ง `templateId` เฉพาะ `startVersioning` + hint ไฟล์ร่างใน task pane -- Web: ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม + `downloadFile` รับ `init` (POST + JSON → blob) +- Web: ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม + +### เพิ่มเติม v9 (ปรับ FR-16 ตามผลใช้งานจริง) +- Backend: generate-draft เก็บ object ใต้ `drafts/` + คืน `{ url, expiresAt }` (presigned) + `EnsurePrefixExpiryAsync` ตั้ง lifecycle ตอน startup (idempotent/best-effort) + `ReplaceSdtContent` เขียนค่าที่ run ข้อความยาวสุด (complex script ไทย) + SDT เปล่าสืบทอด `sdtPr` rPr +- Web: `useVersionOpen.openDocxUrl` — แยกแกนเปิด docx URL ออกจาก `openVersion` ให้ไฟล์ร่างใช้ร่วม (ถอด CORS expose และ `downloadFile` init ที่เลิกใช้) diff --git a/web/app/composables/useApi.ts b/web/app/composables/useApi.ts index 36d736f..50af547 100644 --- a/web/app/composables/useApi.ts +++ b/web/app/composables/useApi.ts @@ -69,41 +69,5 @@ export function useApi() { return (await apiJson(path, { method, body: form })) as T } - /** - * ดาวน์โหลดไฟล์ผ่าน Bearer แล้วเซฟด้วย anchor download - * (เว็บรันใน browser จริง — ไม่ใช่ webview ของ Word จึงใช้ blob download ได้ ต่างจาก add-in) - * init ใช้ override method/body ได้ — ไฟล์ร่าง (FR-16) เป็น POST + JSON body ที่คืนไฟล์ - */ - async function downloadFile(path: string, fallbackName: string, init: RequestInit = {}): Promise { - const res = await request(path, init) - if (!res.ok) throw await toApiError(res) - - const blob = await res.blob() - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = filenameFrom(res.headers.get('Content-Disposition'), fallbackName) - document.body.append(a) - a.click() - a.remove() - setTimeout(() => URL.revokeObjectURL(url), 10_000) - } - - return { request, apiJson, apiUpload, downloadFile } -} - -/** เอาชื่อไฟล์จาก Content-Disposition (รองรับ filename*=UTF-8 ที่ .NET ใส่มาสำหรับชื่อไทย) */ -function filenameFrom(header: string | null, fallback: string): string { - if (!header) return fallback - const utf8 = header.match(/filename\*=(?:UTF-8'')?([^;]+)/i) - if (utf8) { - try { - return decodeURIComponent(utf8[1].replace(/^"|"$/g, '')) - } catch { - /* ผ่านต่อ */ - } - } - const plain = header.match(/filename=(?:"([^"]+)"|([^;]+))/i) - if (plain) return (plain[1] ?? plain[2]).trim() - return fallback + return { request, apiJson, apiUpload } } diff --git a/web/app/composables/useVersionOpen.ts b/web/app/composables/useVersionOpen.ts index bd183a8..71e3527 100644 --- a/web/app/composables/useVersionOpen.ts +++ b/web/app/composables/useVersionOpen.ts @@ -23,6 +23,28 @@ export function useVersionOpen() { openNotice.value = null } + /** เปิด presigned docx URL ใน Word ผ่าน ms-word:ofe พร้อม heuristic เตือนเมื่อไม่เห็นการสลับแอป — + * ใช้กับลิงก์ที่ถืออยู่เองได้เลย (ไฟล์ร่าง FR-16: response ของ generate-draft คือ URL แล้ว) */ + function openDocxUrl(url: string) { + resetOpen() + fallbackUrl.value = url + let switched = false + const onBlur = () => { switched = true } + const onVisibility = () => { if (document.hidden) switched = true } + window.addEventListener('blur', onBlur) + document.addEventListener('visibilitychange', onVisibility) + const timer = window.setTimeout(() => { + if (!switched) openNotice.value = 'หาก Word ไม่เปิด กรุณาดาวน์โหลดไฟล์แล้วเปิดใน Word' + cleanup() + }, 1500) + cleanup = () => { + window.clearTimeout(timer) + window.removeEventListener('blur', onBlur) + window.removeEventListener('visibilitychange', onVisibility) + } + window.location.assign(wordOpenUrl(url)) + } + async function openVersion(documentId: string, versionId: string, format: VersionFormat = 'docx') { if (openingVersionId.value) return resetOpen() @@ -45,21 +67,7 @@ export function useVersionOpen() { } else openNotice.value = 'เบราว์เซอร์บล็อกแท็บใหม่ กรุณากดลิงก์เปิดไฟล์ด้านล่าง' } else { - let switched = false - const onBlur = () => { switched = true } - const onVisibility = () => { if (document.hidden) switched = true } - window.addEventListener('blur', onBlur) - document.addEventListener('visibilitychange', onVisibility) - const timer = window.setTimeout(() => { - if (!switched) openNotice.value = 'หาก Word ไม่เปิด กรุณาดาวน์โหลดไฟล์แล้วเปิดใน Word' - cleanup() - }, 1500) - cleanup = () => { - window.clearTimeout(timer) - window.removeEventListener('blur', onBlur) - document.removeEventListener('visibilitychange', onVisibility) - } - window.location.assign(wordOpenUrl(link.url)) + openDocxUrl(link.url) } } catch (err) { tab?.close() @@ -71,5 +79,5 @@ export function useVersionOpen() { } onBeforeUnmount(resetOpen) - return { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, resetOpen } + return { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, openDocxUrl, resetOpen } } diff --git a/web/app/pages/documents/create/[templateId].vue b/web/app/pages/documents/create/[templateId].vue index 95e6476..6adfcde 100644 --- a/web/app/pages/documents/create/[templateId].vue +++ b/web/app/pages/documents/create/[templateId].vue @@ -5,8 +5,8 @@ import type { DocumentDetail, GenerateDocumentResponse, TemplateDetail } from '~/types/api' const route = useRoute() -const { apiJson, downloadFile } = useApi() -const { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, resetOpen } = useVersionOpen() +const { apiJson } = useApi() +const { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, openDocxUrl, resetOpen } = useVersionOpen() const templateId = computed(() => String(route.params.templateId)) const detail = ref(null) @@ -82,7 +82,7 @@ async function load() { /** validate ฝั่ง client (required/type) ก่อนยิง backend — backend ตรวจซ้ำอีกชั้นเสมอ */ async function submit() { submitError.value = null - downloadError.value = null + resetOpen() // เคลียร์ state ของการเปิดไฟล์ครั้งก่อน (รวม fallbackUrl/openNotice ของไฟล์ร่างที่อาจค้าง) draftSuccess.value = null draftError.value = null issues.value = Object.fromEntries( @@ -105,7 +105,8 @@ async function submit() { } } -/** FR-16 — สร้างไฟล์ร่าง (draft): validate ชุดเดียวกันแล้ว POST ค่าไปขอไฟล์กลับมาดาวน์โหลดทันที +/** FR-16 — สร้างไฟล์ร่าง (draft): validate ชุดเดียวกันแล้ว POST ค่าไปขอ presigned URL + * แล้วเปิดใน Word ทันทีผ่าน ms-word:ofe พร้อมลิงก์ดาวน์โหลดสำรอง (เหมือน flow เปิด v0) * backend ไม่สร้าง Document/version — เอกสารจะเกิดตอนกด Start Versioning ใน Word (เป็น v1 พร้อมผูก template นี้) */ async function submitDraft() { draftSuccess.value = null @@ -118,13 +119,17 @@ async function submitDraft() { draftSubmitting.value = true try { - await downloadFile(`/api/templates/${templateId.value}/generate-draft`, `${detail.value?.name ?? 'เอกสาร'}.docx`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ values: values.value }), - }) + const link = (await apiJson<{ url: string; expiresAt: string }>( + `/api/templates/${templateId.value}/generate-draft`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ values: values.value }), + }, + ))! + openDocxUrl(link.url) draftSuccess.value = - 'ดาวน์โหลดไฟล์ร่างแล้ว — เปิดไฟล์ใน Word แล้วกด Start Versioning This Document เพื่อเริ่ม track (เอกสารจะถูกผูกกับ template นี้และบันทึกเป็น version 1)' + 'กำลังเปิดไฟล์ร่างใน Word — เปิด task pane ของ add-in แล้วกด Start Versioning This Document เพื่อเริ่ม track (เอกสารจะถูกผูกกับ template นี้และบันทึกเป็น version 1)' } catch (err) { draftError.value = err instanceof Error ? err.message : 'สร้างไฟล์ร่างไม่สำเร็จ' } finally { @@ -301,8 +306,13 @@ onMounted(load) {{ submitError }} - + {{ draftSuccess }} + +

{{ openNotice }}

+ ดาวน์โหลดไฟล์ร่างสำรอง (.docx) +

ลิงก์มีอายุสั้น หากหมดอายุให้กดสร้างไฟล์ร่างอีกครั้ง

+
{{ draftError }}
@@ -323,7 +333,7 @@ onMounted(load)

- ไฟล์ร่าง = ได้ .docx ทันทีโดยยังไม่เกิดเอกสารในระบบ — เริ่ม versioning ตอนเปิดใน Word (version 1) + ไฟล์ร่าง = เปิดใน Word ทันทีโดยยังไม่เกิดเอกสารในระบบ — เริ่ม versioning ตอนกด Start Versioning ใน Word (version 1)

diff --git a/web/test/useApi.spec.ts b/web/test/useApi.spec.ts deleted file mode 100644 index c7090dc..0000000 --- a/web/test/useApi.spec.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { ApiError, useApi } from '../app/composables/useApi' - -/** - * useApi — fetch wrapper ของ web portal: ไฟล์ร่าง (FR-16) ใช้ downloadFile แบบ - * POST + JSON body ที่คืนไฟล์ (ชื่อไทยอ่านจาก Content-Disposition filename* ที่ CORS expose ไว้) - */ - -const getAccessToken = vi.fn() -const fetchMock = vi.fn() - -/** Response ปลอม — ไม่พึ่ง global Response ของ environment (happy-dom) */ -function fakeResponse(options: { - ok?: boolean - status?: number - headers?: Record - body?: string -}): Response { - const lower = Object.fromEntries( - Object.entries(options.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v]), - ) - return { - ok: options.ok ?? true, - status: options.status ?? 200, - headers: { get: (name: string) => lower[name.toLowerCase()] ?? null }, - blob: async () => new Blob([options.body ?? 'docx-bytes']), - text: async () => options.body ?? '', - } as unknown as Response -} - -let clicked: HTMLAnchorElement | null = null -const originalCreateObjectURL = URL.createObjectURL -const originalRevokeObjectURL = URL.revokeObjectURL - -beforeEach(() => { - vi.stubGlobal('useRuntimeConfig', () => ({ public: { apiBaseUrl: 'https://api.test/' } })) - vi.stubGlobal('useAuth', () => ({ getAccessToken })) - vi.stubGlobal('fetch', fetchMock) - getAccessToken.mockReset().mockResolvedValue('token-1') - fetchMock.mockReset() - clicked = null - vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function click(this: HTMLAnchorElement) { - clicked = this - }) - URL.createObjectURL = vi.fn(() => 'blob:mock') - URL.revokeObjectURL = vi.fn() -}) - -afterEach(() => { - vi.unstubAllGlobals() - vi.restoreAllMocks() - URL.createObjectURL = originalCreateObjectURL - URL.revokeObjectURL = originalRevokeObjectURL -}) - -describe('downloadFile (FR-16 ไฟล์ร่าง — POST + JSON body คืนไฟล์)', () => { - it('forwards method/body to fetch with Bearer and saves the Thai filename from Content-Disposition', async () => { - fetchMock.mockResolvedValue( - fakeResponse({ - headers: { - // .NET ใส่ทั้ง filename และ filename* (RFC 5987) สำหรับชื่อไทย - 'Content-Disposition': - "attachment; filename=\"draft.docx\"; filename*=UTF-8''%E0%B9%84%E0%B8%9F%E0%B8%A5%E0%B9%8C%E0%B8%A3%E0%B9%88%E0%B8%B2%E0%B8%87.docx", - }, - }), - ) - - await useApi().downloadFile('/api/templates/abc/generate-draft', 'fallback.docx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ values: { employee_name: 'สมหญิง' } }), - }) - - expect(fetchMock).toHaveBeenCalledWith('https://api.test/api/templates/abc/generate-draft', { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: 'Bearer token-1' }, - body: JSON.stringify({ values: { employee_name: 'สมหญิง' } }), - }) - expect(clicked?.download).toBe('ไฟล์ร่าง.docx') - }) - - it('falls back to the given name when Content-Disposition is missing (CORS ไม่ expose)', async () => { - fetchMock.mockResolvedValue(fakeResponse({})) - - await useApi().downloadFile('/api/templates/abc/generate-draft', 'fallback.docx', { method: 'POST' }) - - expect(clicked?.download).toBe('fallback.docx') - }) - - it('retries once with a refreshed token on 401 before saving', async () => { - getAccessToken.mockImplementation(async (forceRefresh?: boolean) => (forceRefresh ? 'token-2' : 'token-1')) - fetchMock - .mockResolvedValueOnce(fakeResponse({ ok: false, status: 401 })) - .mockResolvedValueOnce(fakeResponse({ headers: { 'Content-Disposition': 'attachment; filename="draft.docx"' } })) - - await useApi().downloadFile('/api/templates/abc/generate-draft', 'fallback.docx', { method: 'POST' }) - - expect(fetchMock).toHaveBeenCalledTimes(2) - expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe('Bearer token-2') - expect(clicked?.download).toBe('draft.docx') - }) - - it('throws ApiError with the backend message when the response is not ok', async () => { - fetchMock.mockResolvedValue( - fakeResponse({ ok: false, status: 400, body: 'กรอก required fields ให้ครบ: employee_name' }), - ) - - await expect( - useApi().downloadFile('/api/templates/abc/generate-draft', 'fallback.docx', { method: 'POST' }), - ).rejects.toMatchObject({ - constructor: ApiError, - status: 400, - message: 'กรอก required fields ให้ครบ: employee_name', - }) - expect(clicked).toBeNull() // ไม่มีการดาวน์โหลดเกิดขึ้น - }) -}) diff --git a/web/test/useVersionOpen.spec.ts b/web/test/useVersionOpen.spec.ts index c9f9e20..91e8150 100644 --- a/web/test/useVersionOpen.spec.ts +++ b/web/test/useVersionOpen.spec.ts @@ -97,4 +97,14 @@ describe('version open lifecycle', () => { app.unmount() expect(remove).toHaveBeenCalledWith('blur', expect.any(Function)) }) + it('openDocxUrl opens a URL the caller already holds (ไฟล์ร่าง FR-16) without extra request', async () => { + const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {}) + const state = mount() + state.openDocxUrl('https://files.example/draft.docx?sig=a%2Fb') + expect(apiJson).not.toHaveBeenCalled() + expect(assign).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/draft.docx?sig=a%2Fb') + expect(state.fallbackUrl.value).toBe('https://files.example/draft.docx?sig=a%2Fb') + vi.advanceTimersByTime(1500) + expect(state.openNotice.value).toBeTruthy() + }) }) From b9b227926dfb8756cde80d929f3970f686b45f0d Mon Sep 17 00:00:00 2001 From: Suphonchai Phoonsawat Date: Thu, 10 Sep 2026 12:07:50 +0700 Subject: [PATCH 02/10] Hotfix: strip invalid XML control chars from field values (FR-16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ผู้ใช้ paste ข้อความที่มี vertical tab (0x0B) เข้าฟอร์ม — XML 1.0 ห้าม อักขระนี้ ทำให้ XmlWriter โยน ArgumentException ตอน save package และ หลุดเป็น 500 stack เต็มจอ (catch เดิมครอบแค่ OpenXmlPackageException) - DocxGenerationService.SanitizeForXml: ตัดอักขระที่ XML 1.0 ห้าม (0x00–0x1F ยกเว้น tab/LF/CR, lone surrogate, 0xFFFE/0xFFFF — คู่ surrogate ครบ เช่น อีโมจิ คงไว้) ที่จุดเขียนค่าทั้งสองของ ReplaceSdtContent → ครอบทั้ง generate และ generate-draft - Controllers: broad catch (ยกเว้น cancellation) → 502 ข้อความอ่านออก แทน 500 ดิบ สำหรับ exception ที่ไม่ได้ตั้งใจจาก OpenXML - Test: Draft_ValueWithInvalidXmlControlChars_IsStrippedNot500 (draft + generate v0) — reviewer พิสูจน์แล้วว่า fail จริงบนโค้ดก่อนแก้ - backend 85/85; QA reviewer PASS (1 round, minors 3: แก้ 1 ปฏิเสธ 2 พร้อมเหตุผล — บันทึกใน acceptance-review.md) Co-Authored-By: Claude Code --- CLAUDE.md | 2 +- .../DraftTests.cs | 32 ++++++++++++- .../Controllers/DocumentsController.cs | 7 +++ .../Controllers/TemplatesController.cs | 8 ++++ .../Services/DocxGenerationService.cs | 45 ++++++++++++++++++- docs/acceptance-review.md | 5 ++- docs/plans/officejs-implementation-plan.md | 2 +- specs/spec-officejs.md | 2 +- 8 files changed, 96 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2f059b9..d58c576 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`) -**สถานะปัจจุบัน: Phase 0–12 — FR-16 ไฟล์ร่าง: automated QA PASS (backend 84 / add-in 66 / web 56 + build ครบ · v9: เปิดใน Word ทันที + แก้ตัดคำไทย), ค้าง manual บน Word จริง · Phase 0–11 เสร็จครบ (FR-15 ผ่าน QA + manual บน Word for Mac 2026-09-08)** — ค้างยืนยันบน Word for Windows / Word on the web ตามรายการใน `docs/acceptance-review.md` +**สถานะปัจจุบัน: Phase 0–12 — FR-16 ไฟล์ร่าง: automated QA PASS (backend 85 / add-in 66 / web 56 + build ครบ · v9: เปิดใน Word ทันที + แก้ตัดคำไทย), ค้าง manual บน Word จริง · Phase 0–11 เสร็จครบ (FR-15 ผ่าน QA + manual บน Word for Mac 2026-09-08)** — ค้างยืนยันบน Word for Windows / Word on the web ตามรายการใน `docs/acceptance-review.md` ## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ) diff --git a/backend/WordVersioning.Api.IntegrationTests/DraftTests.cs b/backend/WordVersioning.Api.IntegrationTests/DraftTests.cs index 7e11217..d851398 100644 --- a/backend/WordVersioning.Api.IntegrationTests/DraftTests.cs +++ b/backend/WordVersioning.Api.IntegrationTests/DraftTests.cs @@ -89,7 +89,8 @@ public sealed class DraftTests(IntegrationTestFixture fixture) } /// สร้าง template จาก BuildThaiTemplateDocx พร้อมตั้ง field defs (detail1 required, remark1 ทางเลือก) - private async Task CreateThaiTemplateAsync() { + private async Task CreateThaiTemplateAsync() + { using var client = fixture.CreateClient(); using var create = await client.PostAsync( "api/templates", @@ -191,6 +192,35 @@ public sealed class DraftTests(IntegrationTestFixture fixture) Assert.Equal(templateId.ToString(), TestDocx.ReadCustomProperty(fileBytes, AddInMetadata.TemplateIdPropertyName)); } + [Fact] + public async Task Draft_ValueWithInvalidXmlControlChars_IsStrippedNot500() + { + // ผู้ใช้ paste ข้อความที่มีอักขระควบคุม (vertical tab 0x0B / form feed 0x0C) มาในค่า field — + // XML 1.0 ห้ามอักขระพวกนี้ เดิม XmlWriter โยน ArgumentException ตอน save ทั้ง package กลายเป็น 500 + // ต้องตัดออกเงียบๆ แล้วสร้างไฟล์สำเร็จ (อักขระมองไม่เห็นอยู่แล้ว) — ครอบทั้ง draft และ generate v0 + using var client = fixture.CreateClient(); + var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "paste control chars"); + var values = new Dictionary(TestFlow.DefaultValues) + { + ["position"] = "วิศวกร\u000Bซอฟต์\u000Cแวร์", // VT/FF ฝังเป็น escape กันอักขระล่องหนหายระหว่างแก้ไขไฟล์ + }; + + // ไฟล์ร่าง + using var draft = await client.PostAsJsonAsync( + $"api/templates/{templateId}/generate-draft", new { values }, IntegrationTestFixture.Json); + Assert.Equal(HttpStatusCode.OK, draft.StatusCode); + var link = (await draft.Content.ReadFromJsonAsync(IntegrationTestFixture.Json))!; + using var browser = new HttpClient(); + var draftBytes = await (await browser.GetAsync(link.Url)).Content.ReadAsByteArrayAsync(); + Assert.Equal("วิศวกรซอฟต์แวร์", TestDocx.ReadSdtText(draftBytes, "position")); + + // generate v0 ใช้ ReplaceSdtContent ตัวเดียวกัน — ตรวจซ้ำจุดเดียวกันผ่านอีก endpoint + var doc = await TestFlow.GenerateDocumentAsync(client, templateId, values); + using var download = await client.GetAsync(doc.DownloadUrl); + var docBytes = await download.Content.ReadAsByteArrayAsync(); + Assert.Equal("วิศวกรซอฟต์แวร์", TestDocx.ReadSdtText(docBytes, "position")); + } + [Fact] public async Task Draft_LifecycleRule_Idempotent_AndSyncsRetentionDays() { diff --git a/backend/WordVersioning.Api/Controllers/DocumentsController.cs b/backend/WordVersioning.Api/Controllers/DocumentsController.cs index 630ef0a..8b82b8a 100644 --- a/backend/WordVersioning.Api/Controllers/DocumentsController.cs +++ b/backend/WordVersioning.Api/Controllers/DocumentsController.cs @@ -307,6 +307,13 @@ public class DocumentsController( template.Id, template.ObjectKey); return StatusCode(StatusCodes.Status502BadGateway, "ไฟล์ template เสียหาย (เปิดด้วย OpenXML ไม่ได้)"); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // กันหน้า: OpenXML โยนอะไรก็ตามที่เราไม่ได้เจอมาก่อน — ให้เป็น 502 ที่อ่านออกแทน 500 stack เต็มจอ + logger.LogError(ex, "Generate failed templateId={TemplateId} userId={UserId}", + template.Id, currentUser.UserId); + return StatusCode(StatusCodes.Status502BadGateway, "สร้างเอกสารไม่สำเร็จ (ไฟล์ template หรือค่าที่กรอกมีปัญหา)"); + } var now = DateTimeOffset.UtcNow; using (var hashStream = new MemoryStream(fileBytes)) diff --git a/backend/WordVersioning.Api/Controllers/TemplatesController.cs b/backend/WordVersioning.Api/Controllers/TemplatesController.cs index 4204d98..70eabd3 100644 --- a/backend/WordVersioning.Api/Controllers/TemplatesController.cs +++ b/backend/WordVersioning.Api/Controllers/TemplatesController.cs @@ -366,6 +366,14 @@ public class TemplatesController( template.Id, template.ObjectKey); return StatusCode(StatusCodes.Status502BadGateway, "ไฟล์ template เสียหาย (เปิดด้วย OpenXML ไม่ได้)"); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // กันหน้า: OpenXML โยนอะไรก็ตามที่เราไม่ได้เจอมาก่อน (เช่น ArgumentException จากอักขระที่ XML ใช้ไม่ได้ + // ตอน v9 แรกๆ หลุดเป็น 500 stack เต็มจอ) — ให้เป็น 502 ที่อ่านออกแทน + logger.LogError(ex, "Draft generation failed templateId={TemplateId} userId={UserId}", + template.Id, currentUser.UserId); + return StatusCode(StatusCodes.Status502BadGateway, "สร้างไฟล์ร่างไม่สำเร็จ (ไฟล์ template หรือค่าที่กรอกมีปัญหา)"); + } // เก็บชั่วคราวใต้ drafts/ — lifecycle ลบให้เอง (Program.cs ตั้ง rule ตอน startup) var now = DateTimeOffset.UtcNow; diff --git a/backend/WordVersioning.Api/Services/DocxGenerationService.cs b/backend/WordVersioning.Api/Services/DocxGenerationService.cs index 4646f77..18dff73 100644 --- a/backend/WordVersioning.Api/Services/DocxGenerationService.cs +++ b/backend/WordVersioning.Api/Services/DocxGenerationService.cs @@ -108,6 +108,47 @@ public class DocxGenerationService : IDocxGenerationService } } + /// ตัดอักขระที่ XML 1.0 ห้าม (เช่น vertical tab 0x0B จากการ paste ข้อความ) ออกจากค่าก่อนเขียนลงไฟล์ — + /// XmlWriter โยน ArgumentException ตอน save ทั้ง package ถ้าปล่อยไว้ (XML รับได้เฉพาะ #x9/#xA/#xD/ + /// [#x20-#xD7FF]/[#xE000-#xFFFD]/คู่ surrogate ครบ) — ค่าที่ตัดไปเป็นอักขระที่มองไม่เห็นอยู่แล้ว + private static string SanitizeForXml(string value) + { + // ทางเลือกให้เร็ว: ไม่มีอักขระต้องตัดก็คืนของเดิม (กรณีทั่วไป) + var hasInvalid = false; + foreach (var c in value) + { + if (IsInvalidXmlChar(c)) + { + hasInvalid = true; + break; + } + } + if (!hasInvalid) + return value; + + var sb = new System.Text.StringBuilder(value.Length); + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) + { + sb.Append(c).Append(value[i + 1]); // คู่ surrogate ครบ = XML ใช้ได้ (เช่น อีโมจิ) + i++; + } + else if (!IsInvalidXmlChar(c)) + { + sb.Append(c); + } + } + return sb.ToString(); + + // lone surrogate (คู่ไม่ครบ) จัดการเป็นคู่ที่ loop ด้านบน — ที่นี่ surrogate ทุกตัวถือ invalid + static bool IsInvalidXmlChar(char c) => + (c < 0x20 && c is not '\t' and not '\n' and not '\r') // 0x00–0x1F ยกเว้น tab/LF/CR + || char.IsSurrogate(c) + || c == '\uFFFE' || c == '\uFFFF'; + } + /// แทนที่ข้อความใน SDT ที่ run level — เขียนค่าที่ Run ของ "ข้อความ placeholder จริง" /// (run ที่มีข้อความยาวสุด) เพื่อสืบทอด rPr ของมัน แล้วลบ Text ที่เหลือ ไม่ทับทั้ง paragraph /// (spec ส่วน 9 เพิ่มเติม v2 · v9: template ไทยมักแตก placeholder เป็น [ / ข้อความ / ] หลาย run — @@ -127,7 +168,7 @@ public class DocxGenerationService : IDocxGenerationService // SDT ไม่มีข้อความเลย (เช่น ที่ว่างมา) — สร้าง Run ใหม่ใน paragraph แรก (block) หรือใน content ตรงๆ (run) // โดยสืบทอด rPr จาก sdtPr ถ้ามี (คือ formatting ที่ผู้แต่ง template กำหนดให้เนื้อหาของ control นี้ — // เช่นเดียวกับที่ Word ใช้ตอนผู้ใช้พิมพ์ลง control ไม่ใช่ปล่อยเป็น default font) - var run = new Run(new Text(value) { Space = SpaceProcessingModeValues.Preserve }); + var run = new Run(new Text(SanitizeForXml(value)) { Space = SpaceProcessingModeValues.Preserve }); if (sdtPr?.Elements().FirstOrDefault() is { } sdtRunProperties) run.RunProperties = (RunProperties)sdtRunProperties.CloneNode(true); if (content is SdtContentBlock) @@ -152,7 +193,7 @@ public class DocxGenerationService : IDocxGenerationService var donorText = texts.OrderByDescending(t => t.Text?.Length ?? 0).First(); // เขียนที่ Text ของ donor แล้วเก็บ space ไว้กรณีค่ามีช่องว่างหัว/ท้าย - donorText.Text = value; + donorText.Text = SanitizeForXml(value); donorText.Space = SpaceProcessingModeValues.Preserve; // Text อื่นทั้งหมด (รวม donor ถ้ามีหลาย Text ใน run เดียวกัน ไม่เกิด) ลบทิ้ง — field = ค่าเดียว diff --git a/docs/acceptance-review.md b/docs/acceptance-review.md index 418b074..6d7c245 100644 --- a/docs/acceptance-review.md +++ b/docs/acceptance-review.md @@ -145,6 +145,7 @@ QA reviewer: **PASS**, 2 rounds, 3 findings fixed (build compatibility, pending | สร้างไฟล์ร่างแล้วไม่เกิด Document/version/field values ใน DB + ค่าครบ + formatting รักษาไว้ + **presigned URL ดึงไฟล์จริงได้ทันที** (v9) | `DraftTests.Draft_FillsAllFields_PreservesFormatting_StampsOnlyTemplateId_CreatesNothing` | | stamp เฉพาะ `TemplateId` — ไม่มี SavedByAddIn/DocumentTrackingId | test เดียวกัน (อ่าน custom properties จากไฟล์จริง) | | **ค่าที่แทนที่ได้ complex script ของ run ข้อความไทย (w:hint="cs"+w:cs) · SDT เปล่าสืบทอด sdtPr rPr** (v9 — แก้การตัดคำไทย) | `DraftTests.Draft_ThaiPlaceholder_UsesComplexScriptRun_ForWordBreaking` (template จำลองโครงสร้าง T14) | +| **ค่าที่มีอักขระควบคุมที่ XML ห้าม (เช่น 0x0B จากการ paste) ถูกตัดออก สร้างไฟล์สำเร็จ ไม่กลายเป็น 500** (v9 — bug จริงจากการใช้งาน) | `DraftTests.Draft_ValueWithInvalidXmlControlChars_IsStrippedNot500` (ครอบทั้ง draft และ generate v0) | | validate เหมือน FR-10: required ขาด/type ผิด → 400, unknown → 404, deactivate → 400, ค่าเกินนิยาม ignore | `DraftTests` ที่เหลือ (5 tests) | | Start Versioning ของไฟล์ร่าง: upload + templateId → **v1** + Document ผูก template + field sync ครบ + snapshot แบบมีนิยาม | `TemplateLinkingTests.Upload_WithTemplateId_OnNewDocument_CreatesLinkedDocV1WithFieldSync` | | upload ถัดไปไม่ส่ง templateId → v2, TemplateId คงเดิม | test เดียวกัน (upload รอบสอง) | @@ -155,7 +156,7 @@ QA reviewer: **PASS**, 2 rounds, 3 findings fixed (build compatibility, pending | add-in: อ่าน TemplateId / ส่งเฉพาะ startVersioning / append form field | `metadataService.test.ts` (ไฟล์ร่างอ่าน TemplateId), `versionFlow.test.ts` (แนบ/ไม่แนบ templateId), `versioningService.test.ts` (form field) | | เว็บ: เปิด docx URL ที่ถืออยู่เอง (ไฟล์ร่าง) ผ่าน ms-word + fallback + notice | `useVersionOpen.spec.ts` `openDocxUrl` test | -ชุดเต็ม (v9): backend **84 ผ่าน**, add-in **66 ผ่าน**, web **56 ผ่าน** + production build ผ่าน (backend 82 / web 59 ของ v8 + แก้ v9) +ชุดเต็ม (v9): backend **85 ผ่าน**, add-in **66 ผ่าน**, web **56 ผ่าน** + production build ผ่าน (backend 82 / web 59 ของ v8 + แก้ v9) ### ค้างยืนยันด้วยมือ (ต้องมี Word จริง) @@ -167,3 +168,5 @@ QA reviewer: **PASS**, 2 rounds, 3 findings fixed (build compatibility, pending QA reviewer: **PASS**, 2 rounds, 8 findings แก้ครบไม่มีที่ถูกปฏิเสธ (2026-09-10) — round 1: blocker `.at(-1)` ใน test ใหม่ตัดกับ tsconfig `lib: es2020` ทำ `addin npm run build` พัง (jest ไม่ type-check จึงรอดมา) + minors 4 (นับจำนวน test ผิดในตารางนี้, stale notices ระหว่างสองปุ่ม submit, AlertBox สำเร็จไม่ใช่ `type="success"`, `isTrackedDocument` ไม่มี production caller → เก็บไว้พร้อมหมายเหตุเหตุผล) · round 2: minors 2 + pre-existing 1 (หัวเอกสาร/สถานะเก่าใน acceptance-review, CLAUDE.md, implementation-plan header). ค้าง manual บน Word จริงตามรายการด้านบน. QA reviewer รอบ v9 (เปิดใน Word + ตัดคำไทย): **PASS**, 2 rounds, 10 findings แก้ครบไม่มีที่ถูกปฏิเสธ (2026-09-10) — round 1: minors 6 (dead `downloadFile`/`filenameFrom`, glossary "stateless" เก่า, lifecycle config เปลี่ยนแล้วเป็น no-op เงียบๆ + days ไม่ validate, ไม่มี test คุม lifecycle, comment เก่าใน TestDocx, typo ใน spec) · round 2: minors 2 + nits 2 (comment เก่าอีก 2 จุด, "(stateless)" ตกค้าง 2 บรรทัด, lifecycle test เพิ่ม assertion ผ่านค่าคืน `true/false`, ตัว verdict นี้เอง) — และ test ใหม่จับ bug จริงได้: `Configuration.Rules` เป็น null ได้ทำให้ startup ตั้ง rule ล้มแบบเงียบมาตั้งแต่แรก (โดน catch กลืน) แก้ด้วย `?? []`. + +QA reviewer รอบ hotfix อักขระควบคุม 0x0B จากการ paste: **PASS**, 1 round (2026-09-10) — reviewer พิสูจน์ว่า test ใหม่ fail จริงบนโค้ดก่อนแก้ · minors 3: แก้ 1 (เปลี่ยนอักขระ VT/FF ล่องหนใน test เป็น escape sequence \u000B/\u000C กันหายระหว่างแก้ไขไฟล์) · ปฏิเสธ 2 พร้อมเหตุผล: (1) fast-path ตรวจ surrogate คู่ครบเป็น invalid ทำให้ค่ามีอีโมจิเดิน slow path — ผลลัพธ์ยังถูก (loop ประกอบคู่กลับคืน) และค่า field สั้นจึงแพงแค่หยดเดียว ไม่คุ้มจะ duplicate pair-logic (2) broad catch ให้ 502 แทน 500 สำหรับ bug ภายใน — สอดคล้อง convention 502 ของ "ไฟล์ template/ค่ามีปัญหา" ที่มีอยู่ และเหตุการณ์จริงที่ catch นี้มีไว้กันคือ input-triggered แท้ๆ (stack ยัง log ฝั่ง server) diff --git a/docs/plans/officejs-implementation-plan.md b/docs/plans/officejs-implementation-plan.md index 6a1dd74..d66d47f 100644 --- a/docs/plans/officejs-implementation-plan.md +++ b/docs/plans/officejs-implementation-plan.md @@ -645,5 +645,5 @@ npm test # vitest (46 ตัว) - [x] Web: ปุ่ม "สร้างไฟล์ร่าง" - [x] Tests/build (backend 82 / add-in 66 / web 59 + build ทั้ง add-in/web) - [x] QA reviewer PASS (2 rounds, 2026-09-10) -- [x] **v9 (feedback จากการใช้จริง): เปิดใน Word ทันที** — เก็บ object ใต้ `drafts/` + lifecycle รายวัน + คืน presigned URL + `useVersionOpen.openDocxUrl` · **แก้ตัดคำไทย** — ค่าเขียนที่ run ข้อความ placeholder จริง (complex script) + SDT เปล่าสืบทอด sdtPr rPr (backend 84 / web 56) + QA reviewer PASS (2 rounds, 2026-09-10) +- [x] **v9 (feedback จากการใช้จริง): เปิดใน Word ทันที** — เก็บ object ใต้ `drafts/` + lifecycle รายวัน + คืน presigned URL + `useVersionOpen.openDocxUrl` · **แก้ตัดคำไทย** — ค่าเขียนที่ run ข้อความ placeholder จริง (complex script) + SDT เปล่าสืบทอด sdtPr rPr (backend 85 / web 56) + QA reviewer PASS (2 rounds, 2026-09-10) - [ ] ตรวจ Word จริง (manual): เว็บสร้างไฟล์ร่าง → **Word เปิดทันที + ตัดคำไทยถูก** → untracked + hint → Start Versioning → v1 ผูก template + fields sync · รวมในรายการค้างเดิมของ Word for Windows / Word on the web diff --git a/specs/spec-officejs.md b/specs/spec-officejs.md index 805c15d..173aa5b 100644 --- a/specs/spec-officejs.md +++ b/specs/spec-officejs.md @@ -296,7 +296,7 @@ POST /api/documents/generate { templateId, values: { : } } ### FR-16: ไฟล์ร่าง (draft) — สร้าง .docx จาก template โดยไม่สร้าง Document (เพิ่มเติม v8 · ปรับ v9) - **`POST /api/templates/{templateId}/generate-draft`** body `{ values }` — ไม่สร้าง `Document`/`DocumentVersion`/`DocumentFieldValue` ใน DB; generate ไฟล์แล้ว**เก็บชั่วคราว**ใน bucket `docx-versions` ใต้ prefix `drafts/{templateId}/{yyyyMMddHHmmss}_{guid}.docx` ซึ่ง lifecycle rule ลบให้เอง (default 1 วัน — config `Minio__DraftRetentionDays`, backend ตั้ง rule ตอน startup แบบ idempotent/best-effort) แล้วคืน **200 `{ url, expiresAt }`** เป็น presigned GET URL อายุสั้น (attachment + ชื่อไฟล์ไทย RFC 5987) ให้เว็บเปิดใน Word ทันที (v8 เดิมคืนไฟล์เป็น stream ตรงๆ — ผู้ใช้ต้องเปิดจาก Downloads เอง) -- validate เหมือน FR-10 (required + type + format; ส่งเกินนิยาม ignore) · template ไม่พบ → 404, deactivate → 400 ตามนโยบาย FR-10 · ไม่มี `message` (ไม่มี version จึงไม่มี commit message) +- validate เหมือน FR-10 (required + type + format; ส่งเกินนิยาม ignore) · template ไม่พบ → 404, deactivate → 400 ตามนโยบาย FR-10 · ไม่มี `message` (ไม่มี version จึงไม่มี commit message) · **ค่าที่กรอกจะถูกตัดอักขระที่ XML 1.0 ห้ามออกก่อนเขียนลงไฟล์** (0x00–0x1F ยกเว้น tab/LF/CR, lone surrogate, 0xFFFE/0xFFFF — มักติดมากับการ paste เช่น vertical tab 0x0B ซึ่งทำ XmlWriter โยนตอน save) ใช้ร่วมทั้ง generate และ generate-draft · OpenXML โยน exception ที่ไม่ได้ตั้งใจ → 502 พร้อมข้อความอ่านออก ไม่ปล่อย 500 stack - stamp custom property **`TemplateId` = GUID ของ template ตัวเดียว** — ไม่ stamp `SavedByAddIn`/`DocumentTrackingId` ไฟล์ร่างจึงเป็น untracked ในสายตา add-in (เลี่ยงสถานะ "tracked แต่ history 404") - **ค่าที่แทนที่เขียนที่ run ของ "ข้อความ placeholder จริง"** (v9 — ใช้ร่วมทั้ง generate และ generate-draft): template ไทยมักแตก placeholder เป็น `[` / ข้อความ / `]` หลาย run โดย run ข้อความถือ `w:hint="cs"` + `w:cs` (complex script) ที่ Word ใช้ตัดคำ/กระจายภาษาไทย — เขียนค่าที่ run ที่มีข้อความยาวสุด (ไม่ใช่ run แรกที่เป็นปีกกา) · SDT เปล่าให้ run ใหม่สืบทอด `rPr` จาก `sdtPr` (เหมือนพฤติกรรม Word ตอนพิมพ์ลง control) - **ผูก template ตอน Start Versioning:** `POST /api/documents/{documentId}/versions` รับ `templateId` (multipart form field ทางเลือก) — ใช้เฉพาะตอน auto-create `Document` (field sync ทำงานเต็มระบบที่ v1); document มีอยู่แล้ว → เฉย (แต่ validate ทุกครั้งที่ส่งมา) · ไม่ใช่ GUID / ไม่พบใน DB → 400 · template ที่ deactivate → **ผูกได้** (ไฟล์ร่างถูกสร้างตอน template ยัง active — นโยบายเดียวกับ FR-9/FR-12) · upload แรกของ Document ใหม่ = **version 1** (max+1 — ไม่มี v0) From e0558e3cf49babd4b17d84f1a033f91ccd84dbe3 Mon Sep 17 00:00:00 2001 From: Suphonchai Phoonsawat Date: Thu, 10 Sep 2026 12:22:19 +0700 Subject: [PATCH 03/10] Hotfix: auto-download fallback when Word cold-start drops the open URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ผู้ใช้จริง (Mac): กด "เปิดใน Word" ตอน Word ยังไม่เปิดค้างอยู่ — Word cold-start แล้ว URL event หลุด แสดงหน้า Recent แทนเอกสาร (ข้อจำกัด ms-word:ofe บน macOS; กลไกนี้ผ่าน manual test ตอน Word เปิดอยู่ก่อนแล้ว) - useVersionOpen.openDocxUrl: หน้าต่างรอ 1.5s → 3s และเมื่อไม่เห็นการ สลับแอป → ดาวน์โหลดไฟล์ให้อัตโนมัติ (anchor → presigned attachment) + ข้อความแนะนำเปิดจาก Downloads หรือกดซ้ำเมื่อ Word เปิดอยู่แล้ว — ใช้ร่วมทุกจุดเปิด docx (version rows, v0, ไฟล์ร่าง) PDF ไม่เกี่ยว - Tests: timing edge 2999/3000 + href + notice, ไม่ download เมื่อสลับ แอปจริง, re-click ยกเลิก timer เก่า (web 59/59 + build) - Spec FR-15/16 (~3s + auto-download + หมายเหตุ cold-start) + acceptance-review เพิ่ม manual check item - QA reviewer PASS (1 round; minors 3 — แก้ 1 ยอมรับ 2 พร้อมเหตุผล) Co-Authored-By: Claude Code --- CLAUDE.md | 2 +- docs/acceptance-review.md | 5 ++- docs/plans/officejs-implementation-plan.md | 2 +- specs/spec-officejs.md | 4 +- web/app/composables/useVersionOpen.ts | 28 ++++++++++-- web/test/useVersionOpen.spec.ts | 51 +++++++++++++++++++++- 6 files changed, 82 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d58c576..c576802 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`) -**สถานะปัจจุบัน: Phase 0–12 — FR-16 ไฟล์ร่าง: automated QA PASS (backend 85 / add-in 66 / web 56 + build ครบ · v9: เปิดใน Word ทันที + แก้ตัดคำไทย), ค้าง manual บน Word จริง · Phase 0–11 เสร็จครบ (FR-15 ผ่าน QA + manual บน Word for Mac 2026-09-08)** — ค้างยืนยันบน Word for Windows / Word on the web ตามรายการใน `docs/acceptance-review.md` +**สถานะปัจจุบัน: Phase 0–12 — FR-16 ไฟล์ร่าง: automated QA PASS (backend 85 / add-in 66 / web 59 + build ครบ · v9: เปิดใน Word ทันที + แก้ตัดคำไทย), ค้าง manual บน Word จริง · Phase 0–11 เสร็จครบ (FR-15 ผ่าน QA + manual บน Word for Mac 2026-09-08)** — ค้างยืนยันบน Word for Windows / Word on the web ตามรายการใน `docs/acceptance-review.md` ## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ) diff --git a/docs/acceptance-review.md b/docs/acceptance-review.md index 6d7c245..42e8e62 100644 --- a/docs/acceptance-review.md +++ b/docs/acceptance-review.md @@ -156,11 +156,12 @@ QA reviewer: **PASS**, 2 rounds, 3 findings fixed (build compatibility, pending | add-in: อ่าน TemplateId / ส่งเฉพาะ startVersioning / append form field | `metadataService.test.ts` (ไฟล์ร่างอ่าน TemplateId), `versionFlow.test.ts` (แนบ/ไม่แนบ templateId), `versioningService.test.ts` (form field) | | เว็บ: เปิด docx URL ที่ถืออยู่เอง (ไฟล์ร่าง) ผ่าน ms-word + fallback + notice | `useVersionOpen.spec.ts` `openDocxUrl` test | -ชุดเต็ม (v9): backend **85 ผ่าน**, add-in **66 ผ่าน**, web **56 ผ่าน** + production build ผ่าน (backend 82 / web 59 ของ v8 + แก้ v9) +ชุดเต็ม (v9): backend **85 ผ่าน**, add-in **66 ผ่าน**, web **59 ผ่าน** + production build ผ่าน (backend 82 / web 59 ของ v8 + แก้ v9) ### ค้างยืนยันด้วยมือ (ต้องมี Word จริง) - [ ] Mac: เว็บสร้างไฟล์ร่าง → **Word เปิดไฟล์ทันที** พร้อมลิงก์สำรอง → ตรวจการตัดคำ/จัดหน้าภาษาไทยของ field ที่ค่ายาว (เช่น Detail ของ T14) ตรงตาม template → task pane แสดง untracked + hint ไฟล์ร่าง → Start Versioning → v1 + หน้า document detail ของเว็บแสดง template และค่า fields +- [ ] Mac: **Word cold-start** (ปิด Word ทิ้งแล้วกด "เปิดใน Word" ครั้งแรก) — ยืนยันว่า fallback ดาวน์โหลดไฟล์ให้อัตโนมัติ + กดครั้งที่สองขณะ Word เปิดอยู่แล้วเอกสารเปิดสำเร็จ (v9.1 แก้ตาม feedback จริง: ครั้งแรก Word แสดงหน้า Recent แทนเอกสาร — ข้อจำกัด `ms-word:ofe` บน macOS ตอน cold-start) - [ ] Word for Windows / Word on the web — ตามข้อค้างเดิมตั้งแต่ AC-1 รายการเต็ม: [แผน FR-16](plans/fr16-draft-generation-plan.md). @@ -170,3 +171,5 @@ QA reviewer: **PASS**, 2 rounds, 8 findings แก้ครบไม่มีท QA reviewer รอบ v9 (เปิดใน Word + ตัดคำไทย): **PASS**, 2 rounds, 10 findings แก้ครบไม่มีที่ถูกปฏิเสธ (2026-09-10) — round 1: minors 6 (dead `downloadFile`/`filenameFrom`, glossary "stateless" เก่า, lifecycle config เปลี่ยนแล้วเป็น no-op เงียบๆ + days ไม่ validate, ไม่มี test คุม lifecycle, comment เก่าใน TestDocx, typo ใน spec) · round 2: minors 2 + nits 2 (comment เก่าอีก 2 จุด, "(stateless)" ตกค้าง 2 บรรทัด, lifecycle test เพิ่ม assertion ผ่านค่าคืน `true/false`, ตัว verdict นี้เอง) — และ test ใหม่จับ bug จริงได้: `Configuration.Rules` เป็น null ได้ทำให้ startup ตั้ง rule ล้มแบบเงียบมาตั้งแต่แรก (โดน catch กลืน) แก้ด้วย `?? []`. QA reviewer รอบ hotfix อักขระควบคุม 0x0B จากการ paste: **PASS**, 1 round (2026-09-10) — reviewer พิสูจน์ว่า test ใหม่ fail จริงบนโค้ดก่อนแก้ · minors 3: แก้ 1 (เปลี่ยนอักขระ VT/FF ล่องหนใน test เป็น escape sequence \u000B/\u000C กันหายระหว่างแก้ไขไฟล์) · ปฏิเสธ 2 พร้อมเหตุผล: (1) fast-path ตรวจ surrogate คู่ครบเป็น invalid ทำให้ค่ามีอีโมจิเดิน slow path — ผลลัพธ์ยังถูก (loop ประกอบคู่กลับคืน) และค่า field สั้นจึงแพงแค่หยดเดียว ไม่คุ้มจะ duplicate pair-logic (2) broad catch ให้ 502 แทน 500 สำหรับ bug ภายใน — สอดคล้อง convention 502 ของ "ไฟล์ template/ค่ามีปัญหา" ที่มีอยู่ และเหตุการณ์จริงที่ catch นี้มีไว้กันคือ input-triggered แท้ๆ (stack ยัง log ฝั่ง server) + +QA reviewer รอบ hotfix "เปิดใน Word ตอน cold-start" (v9.1): **PASS**, 1 round (2026-09-10) — reviewer stash-verify ว่า test ใหม่ fail จริงบนโค้ดก่อนแก้ · minors 3: แก้ 1 (เพิ่ม test re-click ยกเลิก auto-download ค้าง — save ครั้งเดียว URL ล่าสุด) · ยอมรับ 2 พร้อมเหตุผล: (1) ถ้า Word cold-start มาถึงหน้าต่างภายใน 3 วิ heuristic เห็น "สลับแอป" จึงไม่ auto-download ทั้งที่เอกสารไม่เปิด — เป็นข้อจำกัดเชิงหลักการของ heuristic (แยก "Word เปิด" กับ "เปิดเอกสาร" ไม่ได้จาก web) spec ระบุชัดว่าไม่ใช่หลักฐานความสำเร็จ + มี manual check item ครอบ (2) ถ้า Word เปิดเอกสารสำเร็จแต่ช้ากว่า 3 วิ ผู้ใช้ได้ไฟล์ซ้ำใน Downloads — ต้นทุนต่ำยอมรับได้ · ตรวจแล้ว: PDF path ไม่โดน auto-download, ทุก docx entry point (version rows/v0/ไฟล์ร่าง) ผ่าน openDocxUrl ครบ diff --git a/docs/plans/officejs-implementation-plan.md b/docs/plans/officejs-implementation-plan.md index d66d47f..fdf182e 100644 --- a/docs/plans/officejs-implementation-plan.md +++ b/docs/plans/officejs-implementation-plan.md @@ -645,5 +645,5 @@ npm test # vitest (46 ตัว) - [x] Web: ปุ่ม "สร้างไฟล์ร่าง" - [x] Tests/build (backend 82 / add-in 66 / web 59 + build ทั้ง add-in/web) - [x] QA reviewer PASS (2 rounds, 2026-09-10) -- [x] **v9 (feedback จากการใช้จริง): เปิดใน Word ทันที** — เก็บ object ใต้ `drafts/` + lifecycle รายวัน + คืน presigned URL + `useVersionOpen.openDocxUrl` · **แก้ตัดคำไทย** — ค่าเขียนที่ run ข้อความ placeholder จริง (complex script) + SDT เปล่าสืบทอด sdtPr rPr (backend 85 / web 56) + QA reviewer PASS (2 rounds, 2026-09-10) +- [x] **v9 (feedback จากการใช้จริง): เปิดใน Word ทันที** — เก็บ object ใต้ `drafts/` + lifecycle รายวัน + คืน presigned URL + `useVersionOpen.openDocxUrl` · **แก้ตัดคำไทย** — ค่าเขียนที่ run ข้อความ placeholder จริง (complex script) + SDT เปล่าสืบทอด sdtPr rPr (backend 85 / web 59) + QA reviewer PASS (2 rounds, 2026-09-10) - [ ] ตรวจ Word จริง (manual): เว็บสร้างไฟล์ร่าง → **Word เปิดทันที + ตัดคำไทยถูก** → untracked + hint → Start Versioning → v1 ผูก template + fields sync · รวมในรายการค้างเดิมของ Word for Windows / Word on the web diff --git a/specs/spec-officejs.md b/specs/spec-officejs.md index 173aa5b..ccc1128 100644 --- a/specs/spec-officejs.md +++ b/specs/spec-officejs.md @@ -291,7 +291,7 @@ POST /api/documents/generate { templateId, values: { : } } - เก็บ PDF ใน bucket docx-versions, sibling key `{documentId}/v{N:D4}_{clientVersionId}.pdf`; `PdfObjectKey` nullable - docx upload ล้มใช้ error เดิม; PDF upload ล้มเป็น best-effort → version สำเร็จ `hasPdf:false`; task pane เตือน “บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ” และแนะนำ Save & Version ใหม่ - upload/history response มี `hasPdf`; ปุ่ม PDF แสดงเฉพาะเมื่อมี PDF; add-in เปิด presigned URL ผ่าน `openBrowserWindow` -- เว็บ history ทั้ง desktop/mobile เปิด docx ผ่าน `ms-word:ofe|u|` (ไม่ encode URL ทั้งก้อนซ้ำตาม Microsoft Office URI Schemes); PDF แท็บใหม่; มีลิงก์ดาวน์โหลดสำรองและ heuristic blur/visibilitychange ~1.5s ซึ่งไม่ใช่หลักฐานว่า Word เปิดสำเร็จ +- เว็บ history ทั้ง desktop/mobile เปิด docx ผ่าน `ms-word:ofe|u|` (ไม่ encode URL ทั้งก้อนซ้ำตาม Microsoft Office URI Schemes); PDF แท็บใหม่; มีลิงก์ดาวน์โหลดสำรองและ heuristic blur/visibilitychange ~3s ซึ่งไม่ใช่หลักฐานว่า Word เปิดสำเร็จ — ถ้าไม่เห็นการสลับแอปภายในหน้าต่างรอ ให้**ดาวน์โหลดไฟล์ให้อัตโนมัติ** + แนะนำเปิดจาก Downloads หรือกดซ้ำ (v9.1: `ms-word:ofe` บน macOS ตอน Word cold-start มักทำ URL event หลุด → เห็นหน้า Recent แทนเอกสาร — กดครั้งที่สองขณะ Word เปิดอยู่แล้วจะสำเร็จ) - หน้า generate success เปิด v0 ใน Word พร้อมลิงก์ดาวน์โหลดสำรอง; PDF fidelity และ tracked changes ต้องยืนยันบน Word จริง ### FR-16: ไฟล์ร่าง (draft) — สร้าง .docx จาก template โดยไม่สร้าง Document (เพิ่มเติม v8 · ปรับ v9) @@ -301,7 +301,7 @@ POST /api/documents/generate { templateId, values: { : } } - **ค่าที่แทนที่เขียนที่ run ของ "ข้อความ placeholder จริง"** (v9 — ใช้ร่วมทั้ง generate และ generate-draft): template ไทยมักแตก placeholder เป็น `[` / ข้อความ / `]` หลาย run โดย run ข้อความถือ `w:hint="cs"` + `w:cs` (complex script) ที่ Word ใช้ตัดคำ/กระจายภาษาไทย — เขียนค่าที่ run ที่มีข้อความยาวสุด (ไม่ใช่ run แรกที่เป็นปีกกา) · SDT เปล่าให้ run ใหม่สืบทอด `rPr` จาก `sdtPr` (เหมือนพฤติกรรม Word ตอนพิมพ์ลง control) - **ผูก template ตอน Start Versioning:** `POST /api/documents/{documentId}/versions` รับ `templateId` (multipart form field ทางเลือก) — ใช้เฉพาะตอน auto-create `Document` (field sync ทำงานเต็มระบบที่ v1); document มีอยู่แล้ว → เฉย (แต่ validate ทุกครั้งที่ส่งมา) · ไม่ใช่ GUID / ไม่พบใน DB → 400 · template ที่ deactivate → **ผูกได้** (ไฟล์ร่างถูกสร้างตอน template ยัง active — นโยบายเดียวกับ FR-9/FR-12) · upload แรกของ Document ใหม่ = **version 1** (max+1 — ไม่มี v0) - Add-in: อ่าน property `TemplateId` ตอน untracked แล้วแนบ `templateId` **เฉพาะตอน Start Versioning** (Save & Version ไม่ส่ง) + แสดง hint ว่าไฟล์นี้เป็นไฟล์ร่างจาก template -- Web Portal: ปุ่ม "สร้างไฟล์ร่าง" ที่สองบนฟอร์มสร้างเอกสารเดิม → **เปิดใน Word ทันที** ผ่าน `ms-word:ofe|u|` พร้อมลิงก์ดาวน์โหลดสำรองอายุสั้น + heuristic เตือนเมื่อไม่เห็นการสลับแอป (กลไกเดียวกับ flow เปิด v0 — `useVersionOpen`); ฟอร์มอยู่ต่อ ไม่มี documentId/หน้าผลลัพธ์ · ช่องคำอธิบาย version ใช้กับ flow สร้างเอกสาร v0 เท่านั้น · flow v0 (FR-10) และ re-generate (FR-12) คงเดิม +- Web Portal: ปุ่ม "สร้างไฟล์ร่าง" ที่สองบนฟอร์มสร้างเอกสารเดิม → **เปิดใน Word ทันที** ผ่าน `ms-word:ofe|u|` พร้อมลิงก์ดาวน์โหลดสำรองอายุสั้น + heuristic 3 วิ (สลับแอปไม่ได้ = ดาวน์โหลดให้อัตโนมัติ — กลไกเดียวกับ flow เปิด v0 `useVersionOpen`); ฟอร์มอยู่ต่อ ไม่มี documentId/หน้าผลลัพธ์ · ช่องคำอธิบาย version ใช้กับ flow สร้างเอกสาร v0 เท่านั้น · flow v0 (FR-10) และ re-generate (FR-12) คงเดิม ## 7. Data Model (Backend) diff --git a/web/app/composables/useVersionOpen.ts b/web/app/composables/useVersionOpen.ts index 71e3527..7214b06 100644 --- a/web/app/composables/useVersionOpen.ts +++ b/web/app/composables/useVersionOpen.ts @@ -12,6 +12,13 @@ export function useVersionOpen() { let generation = 0 let pendingTab: Window | null = null + /** + * รอสังเกตการสลับไป Word (blur/visibilitychange) กี่มิลลิวินาทีก่อนสรุปว่าเปิดไม่สำเร็จ — + * ยาวพอให้ Word cold-start โอกาสเปิดได้ทัน แต่ไม่นานจนผู้ใช้งง (ms-word:ofe บน macOS ตอน Word + * ยังไม่เปิดค้างอยู่มักทำ URL event หลุด → เห็นหน้า Recent แทนเอกสาร — ข้อจำกัดของ platform) + */ + const WORD_SWITCH_WAIT_MS = 3000 + function resetOpen() { generation++ pendingTab?.close() @@ -23,7 +30,18 @@ export function useVersionOpen() { openNotice.value = null } - /** เปิด presigned docx URL ใน Word ผ่าน ms-word:ofe พร้อม heuristic เตือนเมื่อไม่เห็นการสลับแอป — + /** ให้เบราว์เซอร์เซฟ URL ลง Downloads (presigned URL ส่ง disposition: attachment อยู่แล้ว — + * หน้าเว็บอยู่ที่เดิม ไม่ navigate ไปไหน) */ + function saveToDownloads(url: string) { + const a = document.createElement('a') + a.href = url + document.body.append(a) + a.click() + a.remove() + } + + /** เปิด presigned docx URL ใน Word ผ่าน ms-word:ofe — ถ้าไม่เห็นการสลับแอปภายใน WORD_SWITCH_WAIT_MS + * ถือว่าเปิดไม่สำเร็จ: ดาวน์โหลดไฟล์ให้อัตโนมัติ + แจ้งผู้ใช้ (กดซ้ำตอน Word เปิดอยู่แล้วจะสำเร็จ) * ใช้กับลิงก์ที่ถืออยู่เองได้เลย (ไฟล์ร่าง FR-16: response ของ generate-draft คือ URL แล้ว) */ function openDocxUrl(url: string) { resetOpen() @@ -34,9 +52,13 @@ export function useVersionOpen() { window.addEventListener('blur', onBlur) document.addEventListener('visibilitychange', onVisibility) const timer = window.setTimeout(() => { - if (!switched) openNotice.value = 'หาก Word ไม่เปิด กรุณาดาวน์โหลดไฟล์แล้วเปิดใน Word' + if (!switched) { + openNotice.value = + 'Word ไม่ได้เปิดเอกสารอัตโนมัติ (มักเกิดตอน Word เพิ่งถูกเปิดขึ้นครั้งแรก) — ดาวน์โหลดไฟล์ให้แล้วที่โฟลเดอร์ Downloads ให้เปิดจากที่นั่น หรือกดเปิดใน Word อีกครั้งเมื่อ Word เปิดอยู่แล้ว' + saveToDownloads(url) + } cleanup() - }, 1500) + }, WORD_SWITCH_WAIT_MS) cleanup = () => { window.clearTimeout(timer) window.removeEventListener('blur', onBlur) diff --git a/web/test/useVersionOpen.spec.ts b/web/test/useVersionOpen.spec.ts index 91e8150..8d285ac 100644 --- a/web/test/useVersionOpen.spec.ts +++ b/web/test/useVersionOpen.spec.ts @@ -87,11 +87,12 @@ describe('version open lifecycle', () => { it('offers Word fallback after timeout and removes listeners on unmount', async () => { const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {}) const remove = vi.spyOn(window, 'removeEventListener') + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) // กัน auto-download click จริง apiJson.mockResolvedValue({ url: 'https://files.example/v.docx?signature=a%2Fb' }) const state = mount() await state.openVersion('d', 'v') expect(assign).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/v.docx?signature=a%2Fb') - vi.advanceTimersByTime(1500) + vi.advanceTimersByTime(3000) expect(state.openNotice.value).toBeTruthy() expect(state.fallbackUrl.value).toBeTruthy() app.unmount() @@ -99,12 +100,58 @@ describe('version open lifecycle', () => { }) it('openDocxUrl opens a URL the caller already holds (ไฟล์ร่าง FR-16) without extra request', async () => { const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {}) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) const state = mount() state.openDocxUrl('https://files.example/draft.docx?sig=a%2Fb') expect(apiJson).not.toHaveBeenCalled() expect(assign).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/draft.docx?sig=a%2Fb') expect(state.fallbackUrl.value).toBe('https://files.example/draft.docx?sig=a%2Fb') - vi.advanceTimersByTime(1500) + vi.advanceTimersByTime(3000) expect(state.openNotice.value).toBeTruthy() }) + it('auto-downloads the file to Downloads when Word did not take over within the wait window', () => { + const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {}) + const saved: HTMLAnchorElement[] = [] + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function clicked(this: HTMLAnchorElement) { + saved.push(this) + }) + const state = mount() + state.openDocxUrl('https://files.example/draft.docx?sig=a%2Fb') + // ยังไม่มีอะไรเกิดก่อนหมดเวลารอ + vi.advanceTimersByTime(2999) + expect(saved).toHaveLength(0) + expect(state.openNotice.value).toBeNull() + // ครบหน้าต่างรอ (ไม่มีการ blur = Word ไม่ได้เปิด) → เซฟไฟล์ให้อัตโนมัติ + แจ้งเหตุผล + vi.advanceTimersByTime(1) + expect(saved).toHaveLength(1) + expect(saved[0].href).toBe('https://files.example/draft.docx?sig=a%2Fb') + expect(state.openNotice.value).toContain('Downloads') + }) + it('does not auto-download when the app switch was detected (Word เปิดจริง)', async () => { + const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {}) + const saved: HTMLAnchorElement[] = [] + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function clicked(this: HTMLAnchorElement) { + saved.push(this) + }) + const state = mount() + state.openDocxUrl('https://files.example/draft.docx?sig=a%2Fb') + window.dispatchEvent(new Event('blur')) // ผู้ใช้สลับไป Word + vi.advanceTimersByTime(3000) + expect(saved).toHaveLength(0) + expect(state.openNotice.value).toBeNull() + }) + it('re-click cancels the pending auto-download — only the latest URL is saved once', () => { + const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {}) + const saved: HTMLAnchorElement[] = [] + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function clicked(this: HTMLAnchorElement) { + saved.push(this) + }) + const state = mount() + state.openDocxUrl('https://files.example/first.docx') + vi.advanceTimersByTime(2000) + state.openDocxUrl('https://files.example/second.docx') // resetOpen ยกเลิก timer อันเก่า + vi.advanceTimersByTime(3000) + expect(saved).toHaveLength(1) + expect(saved[0].href).toBe('https://files.example/second.docx') + }) }) From ee4d8573eed9db3da8b859833d5f63b5ce4b6ac3 Mon Sep 17 00:00:00 2001 From: Suphonchai Phoonsawat Date: Thu, 10 Sep 2026 13:39:23 +0700 Subject: [PATCH 04/10] Phase 12 v9.2: download-first + backend open-links (Word 16.101+ broke ms-word) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit การทดสอบ 6 รอบบนเครื่องจริง (Mac, Word 16.101) พิสูจน์ว่า ms-word:ofe ถูกเมินแบบไม่ deterministic ทุกรูปแบบ (http/https/query/ไม่มี query/ file:// — Word ไม่ยิง request ถึง server เลย เด้งหน้าเลือกไฟล์แทน) open -a Word ใช้ได้แต่เรียกจากเว็บไม่ได้ (sandbox + backend รัน server กลางใน production) → สรุปทางเดียวที่ deterministic คือ download-first - Backend: OpenLinkService (HMAC อายุสั้น ValidateOnStart) + OpenController /api/open/{t}/{e}/v/{did}/{vid}/file.docx และ /d/{objectKey} — ทุกอย่างอยู่ใน path ไม่มี query string (Word เมิน URL มี query), [AllowAnonymous] ด้วยลายเซ็นแทน Bearer, stream จาก MinIO + disposition ชื่อไทยเหมือนเดิม · download-link + generate-draft ออก open-link แทน presigned ของ MinIO (http) · ลบ dead code presigned ทิ้ง - Web: openDocxUrl = ดาวน์โหลดทันทีทุกครั้ง + ยิง ms-word ควบคู่แบบ best-effort + notice "ไฟล์อยู่ Downloads" — เอา heuristic สลับแอป ออก (โดนหน้าเลือกไฟล์ของ Word หลอกจนไม่ดาวน์โหลด) - Spec v9.2 + ตารางทดสอบเครื่องจริงใน acceptance-review + CLAUDE.md กฎ 13 (ms-word = best-effort เท่านั้น) + ทางเลือก helper อนาคต - backend 86 / web 58 / add-in 66 + build ครบ; QA reviewer PASS (1 round, minors 4 แก้ครบ) หมายเหตุ deploy: ต้องตั้ง OpenLinks__Secret (ค่าลับจริง) และ OpenLinks__BaseUrl (https domain ของ backend) — fail ดังตั้งแต่สตาร์ทถ้าไม่ตั้ง Co-Authored-By: Claude Code --- CLAUDE.md | 11 +- .../DraftTests.cs | 42 +++++-- .../IntegrationTestFixture.cs | 3 + .../VersionPdfTests.cs | 9 +- .../Controllers/DocumentsController.cs | 21 ++-- .../Controllers/OpenController.cs | 106 ++++++++++++++++++ .../Controllers/TemplatesController.cs | 12 +- backend/WordVersioning.Api/Program.cs | 7 ++ .../Services/MinioOptions.cs | 3 - .../Services/ObjectStorageService.cs | 27 ----- .../Services/OpenLinkOptions.cs | 18 +++ .../Services/OpenLinkService.cs | 78 +++++++++++++ .../appsettings.Development.json | 7 ++ backend/WordVersioning.Api/appsettings.json | 7 ++ docs/acceptance-review.md | 18 ++- docs/plans/officejs-implementation-plan.md | 3 +- specs/spec-officejs.md | 19 +++- web/app/composables/useVersionOpen.ts | 40 ++----- .../pages/documents/create/[templateId].vue | 6 +- web/app/utils/versionDownload.ts | 4 +- web/test/useVersionOpen.spec.ts | 44 ++------ 21 files changed, 340 insertions(+), 145 deletions(-) create mode 100644 backend/WordVersioning.Api/Controllers/OpenController.cs create mode 100644 backend/WordVersioning.Api/Services/OpenLinkOptions.cs create mode 100644 backend/WordVersioning.Api/Services/OpenLinkService.cs diff --git a/CLAUDE.md b/CLAUDE.md index c576802..ff88f5c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`) -**สถานะปัจจุบัน: Phase 0–12 — FR-16 ไฟล์ร่าง: automated QA PASS (backend 85 / add-in 66 / web 59 + build ครบ · v9: เปิดใน Word ทันที + แก้ตัดคำไทย), ค้าง manual บน Word จริง · Phase 0–11 เสร็จครบ (FR-15 ผ่าน QA + manual บน Word for Mac 2026-09-08)** — ค้างยืนยันบน Word for Windows / Word on the web ตามรายการใน `docs/acceptance-review.md` +**สถานะปัจจุบัน: Phase 0–12 — FR-16 ไฟล์ร่าง: automated QA PASS (backend 86 / add-in 66 / web 58 + build ครบ · v9.2: download-first + open-link ของ backend), ค้าง manual บน Word จริง · Phase 0–11 เสร็จครบ (FR-15 ผ่าน QA + manual บน Word for Mac 2026-09-08)** — ค้างยืนยันบน Word for Windows / Word on the web ตามรายการใน `docs/acceptance-review.md` ## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ) @@ -37,9 +37,10 @@ Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQ 9. **Config (DB/MinIO/Keycloak) แยกเป็น env/appsettings เท่านั้น** — ห้าม hardcode; `userId` เอาจาก JWT claims เท่านั้น ห้ามรับจาก client 10. **ทุก Office.js call ต้อง catch** — ห้ามปล่อย unhandled promise rejection 11. **Dev: ทุก service ที่ task pane (หน้า https) เรียกต้องเป็น HTTPS ด้วย cert ของ `office-addin-dev-certs`** — Keycloak = `https://localhost:8443` (Office Dialog API ปฏิเสธ http:// โดยสมบูรณ์), backend = `https://localhost:7232` + `http://localhost:5075` (WKWebView บล็อก mixed content → "Load failed") — รัน `npx office-addin-dev-certs install` ก่อน `docker compose up` -12. **ดาวน์โหลดไฟล์จาก add-in ใช้ presigned URL + `Office.context.ui.openBrowserWindow` เท่านั้น** — webview ของ Word ทิ้ง blob download (`a[download]`) แบบเงียบๆ ไฟล์ไม่ถูกเขียนที่ไหนเลย; และ**ห้าม `cp` ทับ manifest ใน wef folder** (`npm start` ใช้ symlink — ทับแล้วครั้งหน้า EEXIST, ให้แก้ที่ `addin/manifest.xml` ต้นทาง) -13. **`slice.data` จาก `getSliceAsync` มีหลายรูปแบบตาม host** (ArrayBuffer / view / base64 string / `number[]`) — ต้องผ่าน `fileService.sliceDataToBytes()` เสมอ ห้าม assume รูปเดียว -14. **`@nuxtjs/tailwindcss` บน Nuxt 4 ต้องระบุ `cssPath: '~/assets/css/tailwind.css'` ใน nuxt.config.ts เสมอ** — default แบบ relative resolve ต่อ rootDir แล้ว fallback ไปไฟล์ default ของ tailwindcss package **แบบเงียบ** (token/component classes ทั้งหมดหายจาก build โดยไม่มี error) · แก้ `tailwind.config.ts` แล้วต้อง restart dev server · สีทั้งหมดเป็น CSS variables RGB triplet ใน tailwind.css — ห้ามใส่ hex (break `/opacity` utilities เงียบๆ) +12. **ดาวน์โหลดไฟล์จาก add-in ใช้ลิงก์เปิดอายุสั้นจาก download-link (open-link ของ backend) + `Office.context.ui.openBrowserWindow` เท่านั้น** — webview ของ Word ทิ้ง blob download (`a[download]`) แบบเงียบๆ ไฟล์ไม่ถูกเขียนที่ไหนเลย; และ**ห้าม `cp` ทับ manifest ใน wef folder** (`npm start` ใช้ symlink — ทับแล้วครั้งหน้า EEXIST, ให้แก้ที่ `addin/manifest.xml` ต้นทาง) +13. **`ms-word:ofe` ใช้เป็น best-effort เท่านั้น — ห้ามพึ่งเป็นกลไกหลัก** (v9.2): Word for Mac 16.101+ (ก.ย. 2026) เมิน URL ทุกรูปแบบ (http/https/query/ไม่มี query/file://) โดยเด้งหน้าเลือกไฟล์และไม่ยิง request ถึง server เลย — การเปิดไฟล์จากเว็บต้อง **download-first** (ดาวน์โหลดทันที + ยิง ms-word ควบคู่) · ลิงก์เปิดไฟล์ใช้ **open-link ของ backend** (`/api/open/{t}/{e}/...` HMAC ใน path ไม่มี query string — ฝั่ง MinIO presigned เป็น http ใช้กับ Word ไม่ได้) · prod ต้องตั้ง `OpenLinks__Secret`/`OpenLinks__BaseUrl` (https) +14. **`slice.data` จาก `getSliceAsync` มีหลายรูปแบบตาม host** (ArrayBuffer / view / base64 string / `number[]`) — ต้องผ่าน `fileService.sliceDataToBytes()` เสมอ ห้าม assume รูปเดียว +15. **`@nuxtjs/tailwindcss` บน Nuxt 4 ต้องระบุ `cssPath: '~/assets/css/tailwind.css'` ใน nuxt.config.ts เสมอ** — default แบบ relative resolve ต่อ rootDir แล้ว fallback ไปไฟล์ default ของ tailwindcss package **แบบเงียบ** (token/component classes ทั้งหมดหายจาก build โดยไม่มี error) · แก้ `tailwind.config.ts` แล้วต้อง restart dev server · สีทั้งหมดเป็น CSS variables RGB triplet ใน tailwind.css — ห้ามใส่ hex (break `/opacity` utilities เงียบๆ) ## สถาปัตยกรรม — flow หลักที่ต้องเข้าใจ @@ -99,4 +100,4 @@ npx vitest run test/useTheme.spec.ts # รัน test เดี ## ไฟล์ร่าง (FR-16 / spec v8 · ปรับ v9) -ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม → `POST /api/templates/{id}/generate-draft` — ไม่สร้าง Document/version ใน DB; generate แล้ว**เก็บชั่วคราว**ใน bucket `docx-versions` ใต้ prefix `drafts/` (lifecycle ลบรายวัน — `Minio__DraftRetentionDays`, backend ตั้ง rule ตอน startup) แล้วคืน **presigned URL อายุสั้น** ให้เว็บเปิดใน Word ทันทีผ่าน `ms-word:ofe` + ลิงก์สำรอง (`useVersionOpen.openDocxUrl`) — validate ชุดเดียวกับ generate, ไม่มี `message`. ไฟล์ถูก stamp custom property **`TemplateId` ตัวเดียว** (ไม่มี SavedByAddIn/DocumentTrackingId) จึงเป็น untracked ในสายตา add-in. เมื่อกด Start Versioning, add-in อ่าน TemplateId แล้วแนบ form field `templateId` (ใช้เฉพาะตอน auto-create Document; validate ทุกครั้งที่ส่ง: ไม่ใช่ GUID/ไม่พบ → 400, deactivated → ผูกได้) → **Document ผูก template + v1** (ไม่มี v0) + field sync เต็มระบบ. การแทนที่ค่าเขียนที่ **run ข้อความ placeholder จริง** (run ที่ถือ `w:hint="cs"`+`w:cs` — template ไทยแตก placeholder เป็น `[`/ข้อความ/`]` หลาย run) และ SDT เปล่าสืบทอด rPr จาก sdtPr ไม่งั้น Word ตัดคำไทยผิด. การตัดสินใจ: `docs/adr/0002-draft-stateless.md`. +ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม → `POST /api/templates/{id}/generate-draft` — ไม่สร้าง Document/version ใน DB; generate แล้ว**เก็บชั่วคราว**ใน bucket `docx-versions` ใต้ prefix `drafts/` (lifecycle ลบรายวัน — `Minio__DraftRetentionDays`, backend ตั้ง rule ตอน startup) แล้วคืน **open-link อายุสั้นของ backend** (HMAC ใน path — ดูกฎ 13) ให้เว็บ download-first ทันที + ยิง `ms-word:ofe` ควบคู่ (`useVersionOpen.openDocxUrl`) — validate ชุดเดียวกับ generate, ไม่มี `message`. ไฟล์ถูก stamp custom property **`TemplateId` ตัวเดียว** (ไม่มี SavedByAddIn/DocumentTrackingId) จึงเป็น untracked ในสายตา add-in. เมื่อกด Start Versioning, add-in อ่าน TemplateId แล้วแนบ form field `templateId` (ใช้เฉพาะตอน auto-create Document; validate ทุกครั้งที่ส่ง: ไม่ใช่ GUID/ไม่พบ → 400, deactivated → ผูกได้) → **Document ผูก template + v1** (ไม่มี v0) + field sync เต็มระบบ. การแทนที่ค่าเขียนที่ **run ข้อความ placeholder จริง** (run ที่ถือ `w:hint="cs"`+`w:cs` — template ไทยแตก placeholder เป็น `[`/ข้อความ/`]` หลาย run) และ SDT เปล่าสืบทอด rPr จาก sdtPr ไม่งั้น Word ตัดคำไทยผิด. การตัดสินใจ: `docs/adr/0002-draft-stateless.md`. diff --git a/backend/WordVersioning.Api.IntegrationTests/DraftTests.cs b/backend/WordVersioning.Api.IntegrationTests/DraftTests.cs index d851398..d0b329d 100644 --- a/backend/WordVersioning.Api.IntegrationTests/DraftTests.cs +++ b/backend/WordVersioning.Api.IntegrationTests/DraftTests.cs @@ -25,8 +25,8 @@ public sealed class DraftTests(IntegrationTestFixture fixture) var link = (await response.Content.ReadFromJsonAsync(IntegrationTestFixture.Json))!; Assert.False(string.IsNullOrWhiteSpace(link.Url)); Assert.True(link.ExpiresAt > DateTimeOffset.UtcNow.AddSeconds(30)); - using var browser = new HttpClient(); - using var file = await browser.GetAsync(link.Url); + using var anon = fixture.CreateAnonymousClient(); // /api/open ไม่ต้อง Bearer — ลิงก์ลายเซ็นแทน + using var file = await anon.GetAsync(new Uri(link.Url).PathAndQuery); Assert.Equal(HttpStatusCode.OK, file.StatusCode); var fileBytes = await file.Content.ReadAsByteArrayAsync(); @@ -70,8 +70,8 @@ public sealed class DraftTests(IntegrationTestFixture fixture) IntegrationTestFixture.Json); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var link = (await response.Content.ReadFromJsonAsync(IntegrationTestFixture.Json))!; - using var browser = new HttpClient(); - using var file = await browser.GetAsync(link.Url); + using var anon = fixture.CreateAnonymousClient(); // /api/open ไม่ต้อง Bearer — ลิงก์ลายเซ็นแทน + using var file = await anon.GetAsync(new Uri(link.Url).PathAndQuery); Assert.Equal(HttpStatusCode.OK, file.StatusCode); var fileBytes = await file.Content.ReadAsByteArrayAsync(); @@ -185,8 +185,8 @@ public sealed class DraftTests(IntegrationTestFixture fixture) $"api/templates/{templateId}/generate-draft", new { values }, IntegrationTestFixture.Json); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var link = (await response.Content.ReadFromJsonAsync(IntegrationTestFixture.Json))!; - using var browser = new HttpClient(); - using var file = await browser.GetAsync(link.Url); + using var anon = fixture.CreateAnonymousClient(); // /api/open ไม่ต้อง Bearer — ลิงก์ลายเซ็นแทน + using var file = await anon.GetAsync(new Uri(link.Url).PathAndQuery); Assert.Equal(HttpStatusCode.OK, file.StatusCode); var fileBytes = await file.Content.ReadAsByteArrayAsync(); Assert.Equal(templateId.ToString(), TestDocx.ReadCustomProperty(fileBytes, AddInMetadata.TemplateIdPropertyName)); @@ -210,8 +210,8 @@ public sealed class DraftTests(IntegrationTestFixture fixture) $"api/templates/{templateId}/generate-draft", new { values }, IntegrationTestFixture.Json); Assert.Equal(HttpStatusCode.OK, draft.StatusCode); var link = (await draft.Content.ReadFromJsonAsync(IntegrationTestFixture.Json))!; - using var browser = new HttpClient(); - var draftBytes = await (await browser.GetAsync(link.Url)).Content.ReadAsByteArrayAsync(); + using var anon = fixture.CreateAnonymousClient(); // /api/open ไม่ต้อง Bearer — ลิงก์ลายเซ็นแทน + var draftBytes = await (await anon.GetAsync(new Uri(link.Url).PathAndQuery)).Content.ReadAsByteArrayAsync(); Assert.Equal("วิศวกรซอฟต์แวร์", TestDocx.ReadSdtText(draftBytes, "position")); // generate v0 ใช้ ReplaceSdtContent ตัวเดียวกัน — ตรวจซ้ำจุดเดียวกันผ่านอีก endpoint @@ -221,6 +221,32 @@ public sealed class DraftTests(IntegrationTestFixture fixture) Assert.Equal("วิศวกรซอฟต์แวร์", TestDocx.ReadSdtText(docBytes, "position")); } + [Fact] + public async Task Draft_OpenLink_BadToken_Returns404_AndNoBearerNeeded() + { + // /api/open ใช้ลายเซ็นใน path แทน Bearer ([AllowAnonymous] โดยเจตนา — Word/เบราว์เซอร์แนบ header ไม่ได้) + // รูป URL: {BaseUrl}/api/open/{token}/{expiry}/d/{objectKey} — ไม่มี query string เพราะ Word 16.101+ + // บน Mac เมิน URL ที่มี query แบบเงียบๆ (v9.2) · token ผิดหรือหมดอายุ → 404 พร้อมข้อความบอกกดเปิดใหม่ + using var client = fixture.CreateClient(); + var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "openlink bad token"); + using var draft = await client.PostAsJsonAsync( + $"api/templates/{templateId}/generate-draft", + new { values = TestFlow.DefaultValues }, IntegrationTestFixture.Json); + var link = (await draft.Content.ReadFromJsonAsync(IntegrationTestFixture.Json))!; + Assert.Contains("/api/open/", link.Url); + Assert.DoesNotContain("?", link.Url); // ทุกอย่างอยู่ใน path — ไม่มี query string + Assert.StartsWith("https://test-host/api/open/", link.Url); // ออกผ่าน BaseUrl (https) ของ backend ไม่ใช่ presigned MinIO + + // แทน token ใน path ด้วยขยะ (segment ที่ 3 หลัง /api/open/) + var segments = new Uri(link.Url).AbsolutePath.Split('/'); + segments[3] = "garbage-token"; + using var anon = fixture.CreateAnonymousClient(); + using var bad = await anon.GetAsync("/" + string.Join("/", segments.Skip(1))); + Assert.Equal(HttpStatusCode.NotFound, bad.StatusCode); + var body = await bad.Content.ReadAsStringAsync(); + Assert.Contains("หมดอายุ", body); + } + [Fact] public async Task Draft_LifecycleRule_Idempotent_AndSyncsRetentionDays() { diff --git a/backend/WordVersioning.Api.IntegrationTests/IntegrationTestFixture.cs b/backend/WordVersioning.Api.IntegrationTests/IntegrationTestFixture.cs index f01215a..194f051 100644 --- a/backend/WordVersioning.Api.IntegrationTests/IntegrationTestFixture.cs +++ b/backend/WordVersioning.Api.IntegrationTests/IntegrationTestFixture.cs @@ -113,6 +113,9 @@ public sealed class IntegrationTestFixture : IAsyncLifetime ["Authentication:ValidAudiences:0"] = "word-addin-officejs", ["Authentication:ValidAudiences:1"] = "word-versioning-web", ["Cors:AllowedOrigins:0"] = "https://localhost:3000", + // open-link (v9.2): BaseUrl เป็น dummy — test เอา PathAndQuery ของ URL ไป GET ผ่าน host ของ factory + ["OpenLinks:Secret"] = "test-open-link-secret", + ["OpenLinks:BaseUrl"] = "https://test-host", })); }); diff --git a/backend/WordVersioning.Api.IntegrationTests/VersionPdfTests.cs b/backend/WordVersioning.Api.IntegrationTests/VersionPdfTests.cs index 2a3c0b3..3bcf645 100644 --- a/backend/WordVersioning.Api.IntegrationTests/VersionPdfTests.cs +++ b/backend/WordVersioning.Api.IntegrationTests/VersionPdfTests.cs @@ -33,14 +33,14 @@ public sealed class VersionPdfTests(IntegrationTestFixture fixture) Assert.Equal("application/pdf", download.Content.Headers.ContentType!.MediaType); Assert.Equal("คำร้อง.pdf", download.Content.Headers.ContentDisposition!.FileNameStar); var link = await client.GetFromJsonAsync($"api/documents/{id}/versions/{result.VersionId}/download-link?format=pdf"); - using var browser = new HttpClient(); - using var signedDownload = await browser.GetAsync(link!.Url); + using var anon = fixture.CreateAnonymousClient(); // /api/open ไม่ต้อง Bearer — ลิงก์ลายเซ็น (HMAC) แทน + using var signedDownload = await anon.GetAsync(new Uri(link!.Url).PathAndQuery); Assert.Equal(HttpStatusCode.OK, signedDownload.StatusCode); Assert.Equal(Pdf, await signedDownload.Content.ReadAsByteArrayAsync()); Assert.Equal("inline", signedDownload.Content.Headers.ContentDisposition!.DispositionType); Assert.Equal("คำร้อง_v1.pdf", signedDownload.Content.Headers.ContentDisposition.FileNameStar); var docxLink = await client.GetFromJsonAsync($"api/documents/{id}/versions/{result.VersionId}/download-link"); - using var signedDocx = await browser.GetAsync(docxLink!.Url); + using var signedDocx = await anon.GetAsync(new Uri(docxLink!.Url).PathAndQuery); Assert.Equal(HttpStatusCode.OK, signedDocx.StatusCode); Assert.Equal("attachment", signedDocx.Content.Headers.ContentDisposition!.DispositionType); Assert.Equal("คำร้อง_v1.docx", signedDocx.Content.Headers.ContentDisposition.FileNameStar); @@ -114,7 +114,7 @@ public sealed class VersionPdfTests(IntegrationTestFixture fixture) var user = new TestUser(); var controller = new WordVersioning.Api.Controllers.DocumentsController(db, storage, new WordVersioning.Api.Services.HashService(), null!, - new WordVersioning.Api.Services.FieldSyncService(db, user), user, + new WordVersioning.Api.Services.FieldSyncService(db, user), null!, user, Microsoft.Extensions.Options.Options.Create(new WordVersioning.Api.Services.MinioOptions()), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); var id = Guid.NewGuid(); @@ -165,7 +165,6 @@ public sealed class VersionPdfTests(IntegrationTestFixture fixture) return Task.CompletedTask; } public Task GetStreamAsync(string bucket, string key, CancellationToken ct = default) => throw new NotSupportedException(); - public string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry, string? contentDisposition = null) => throw new NotSupportedException(); public Task EnsurePrefixExpiryAsync(string bucket, string prefix, int days, string ruleId, CancellationToken ct = default) => Task.FromResult(false); } diff --git a/backend/WordVersioning.Api/Controllers/DocumentsController.cs b/backend/WordVersioning.Api/Controllers/DocumentsController.cs index 8b82b8a..fdffcca 100644 --- a/backend/WordVersioning.Api/Controllers/DocumentsController.cs +++ b/backend/WordVersioning.Api/Controllers/DocumentsController.cs @@ -23,6 +23,7 @@ public class DocumentsController( IHashService hasher, IDocxGenerationService docxGeneration, IFieldSyncService fieldSync, + IOpenLinkService openLinks, ICurrentUser currentUser, IOptions minioOptions, ILogger logger) : ControllerBase @@ -514,9 +515,9 @@ public class DocumentsController( isPdf ? Path.ChangeExtension(downloadName, ".pdf") : downloadName); } - /// FR-7/FR-15 — presigned URL with filename; format=pdf selects inline PDF (404 when absent). - /// ออก URL ให้ add-in เปิดในเบราว์เซอร์ของผู้ใช้โดยตรง — webview ของ Word (โดยเฉพาะ WKWebView บน Mac) - /// ทิ้ง blob download แบบเงียบๆ จึงต้องให้เบราว์เซอร์จริงจัดการการดาวน์โหลดแทน + /// FR-7/FR-15 — ลิงก์เปิดไฟล์อายุสั้น (HMAC) ที่วิ่งผ่าน https ของ backend; format=pdf = inline PDF (404 เมื่อไม่มี). + /// ออก URL ให้ add-in/เว็บเปิดในเบราว์เซอร์หรือ ms-word:ofe ของผู้ใช้ — webview ของ Word ทิ้ง blob download + /// เงียบๆ และ Word รุ่นใหม่เมิน URL ที่เป็น http จึงไม่ใช้ presigned ของ MinIO ตรงๆ อีก (v9.2) [HttpGet("{documentId:guid}/versions/{versionId:guid}/download-link")] [ProducesResponseType(typeof(VersionDownloadLinkResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -536,18 +537,14 @@ public class DocumentsController( var objectKey = isPdf ? row.PdfObjectKey : row.ObjectKey; if (objectKey is null) return NotFound("version นี้ไม่มีสำเนา PDF"); - var baseName = row.FileName.EndsWith(".docx", StringComparison.OrdinalIgnoreCase) - ? row.FileName[..^5] : row.FileName; - var fileName = $"{baseName}_v{row.VersionNumber}.{(isPdf ? "pdf" : "docx")}"; - var disposition = $"{(isPdf ? "inline" : "attachment")}; filename*=UTF-8''{Uri.EscapeDataString(fileName)}"; - var expirySeconds = Math.Clamp(minioOptions.Value.PresignedExpirySeconds, 30, 600); - var url = storage.GetPresignedGetUrl(minioOptions.Value.VersionsBucket, objectKey, TimeSpan.FromSeconds(expirySeconds), disposition); + + var url = openLinks.CreateLink($"v:{documentId}:{versionId}", isPdf ? "pdf" : "docx", out var expiresAt); logger.LogInformation( - "Version download link issued documentId={DocumentId} versionId={VersionId} userId={UserId} expirySeconds={ExpirySeconds}", - documentId, versionId, currentUser.UserId, expirySeconds); + "Version download link issued documentId={DocumentId} versionId={VersionId} userId={UserId} expiresAt={ExpiresAt}", + documentId, versionId, currentUser.UserId, expiresAt); - return Ok(new VersionDownloadLinkResponse(url, DateTimeOffset.UtcNow + TimeSpan.FromSeconds(expirySeconds))); + return Ok(new VersionDownloadLinkResponse(url, expiresAt)); } /// ถอนการเปลี่ยน DocumentFieldValue ที่ stage ไว้กลับเป็นค่าก่อน request diff --git a/backend/WordVersioning.Api/Controllers/OpenController.cs b/backend/WordVersioning.Api/Controllers/OpenController.cs new file mode 100644 index 0000000..3446f79 --- /dev/null +++ b/backend/WordVersioning.Api/Controllers/OpenController.cs @@ -0,0 +1,106 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using WordVersioning.Api.Data; +using WordVersioning.Api.Services; + +namespace WordVersioning.Api.Controllers; + +/// FR-7/FR-15/FR-16 — endpoint เปิดไฟล์อายุสั้นของ backend เอง (ลายเซ็น HMAC ใน path แทน Bearer) +/// ให้ ms-word:ofe / เบราว์เซอร์ดึงไฟล์ผ่าน https ของ backend โดยตรง — Word for Mac รุ่นใหม่ (16.101+) +/// ยอมรับเฉพาะ URL ที่เป็น https และไม่มี query string จึงฝัง token/expiry/รหัสไฟล์ลง path ทั้งหมด +[ApiController] +[Route("api/open")] +public class OpenController( + AppDbContext db, + IObjectStorageService storage, + IOpenLinkService openLinks, + IOptions minioOptions, + ILogger logger) : ControllerBase +{ + /// เปิดไฟล์ของ version: /api/open/{t}/{e}/v/{documentId}/{versionId}/file.{docx|pdf} + [HttpGet("{t}/{e}/v/{documentId:guid}/{versionId:guid}/{fileName}")] + [AllowAnonymous] // การรับรองคือลายเซ็นใน URL (วิ่งเข้า Word/เบราว์เซอร์ที่แนบ header ไม่ได้) + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task OpenVersion( + string t, string e, Guid documentId, Guid versionId, string fileName, CancellationToken ct) + { + var format = fileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase) ? "pdf" : "docx"; + if (!openLinks.Validate($"v:{documentId}:{versionId}", format, t, e)) + return Expired(); + + var isPdf = format == "pdf"; + var row = await db.DocumentVersions + .AsNoTracking() + .Where(v => v.DocumentId == documentId && v.Id == versionId) + .Join(db.Documents, v => v.DocumentId, d => d.Id, + (v, d) => new { v.ObjectKey, v.PdfObjectKey, v.VersionNumber, d.FileName }) + .FirstOrDefaultAsync(ct); + if (row is null) + return NotFound($"ไม่พบ version {versionId} ของ document {documentId}"); + + var objectKey = isPdf ? row.PdfObjectKey : row.ObjectKey; + if (objectKey is null) + return NotFound("version นี้ไม่มีสำเนา PDF"); + + var baseName = row.FileName.EndsWith(".docx", StringComparison.OrdinalIgnoreCase) + ? row.FileName[..^5] : row.FileName; + return await StreamFromStorage(objectKey, $"{baseName}_v{row.VersionNumber}.{(isPdf ? "pdf" : "docx")}", isPdf, ct); + } + + /// เปิดไฟล์ร่าง (FR-16): /api/open/{t}/{e}/d/drafts/{templateId}/{ไฟล์}.docx + [HttpGet("{t}/{e}/d/{**objectKey}")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task OpenDraft(string t, string e, string objectKey, CancellationToken ct) + { + // objectKey = drafts/{templateId}/{yyyyMMddHHmmss}_{guid}.docx (จบ .docx อยู่แล้ว — ชื่อไทยจริง + // ประกอบจากชื่อ template ตอน stream ไม่ใช่จาก URL ซึ่งต้องเป็น ascii) + if (!objectKey.StartsWith("drafts/", StringComparison.Ordinal) + || !openLinks.Validate($"d:{objectKey}", "docx", t, e)) + return Expired(); + + var parts = objectKey.Split('/'); + if (parts.Length != 3 || !Guid.TryParse(parts[1], out var templateId)) + return Expired(); + var template = await db.Templates.AsNoTracking() + .Where(x => x.Id == templateId) + .Select(x => x.Name) + .FirstOrDefaultAsync(ct); + if (template is null) + return NotFound("ไม่พบ template ต้นทางของไฟล์ร่าง"); + + var timestamp = parts[2].Split('_').FirstOrDefault() ?? string.Empty; + var fileName = DocxGenerationService.BuildGeneratedFileName(template, + DateTimeOffset.TryParseExact(timestamp, "yyyyMMddHHmmss", null, + System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, + out var created) ? created : DateTimeOffset.UtcNow); + return await StreamFromStorage(objectKey, fileName, isPdf: false, ct); + } + + private IActionResult Expired() => + NotFound("ลิงก์หมดอายุหรือไม่ถูกต้อง — กรุณากดเปิดไฟล์ใหม่อีกครั้ง"); + + private async Task StreamFromStorage(string objectKey, string fileName, bool isPdf, CancellationToken ct) + { + Stream stream; + try + { + stream = await storage.GetStreamAsync(minioOptions.Value.VersionsBucket, objectKey, ct); + } + catch (KeyNotFoundException) + { + logger.LogError("Open link target missing in storage key={ObjectKey}", objectKey); + return NotFound("ไฟล์ไม่พบใน storage"); + } + + // disposition นโยบายเดียวกับ presigned เดิม: PDF inline, docx attachment ชื่อไทยตาม RFC 5987 + // (File() ไม่แตะ Content-Disposition เมื่อไม่ส่ง fileDownloadName — เราเซ็ตเองจึงอยู่รอด) + Response.Headers.ContentDisposition = + $"{(isPdf ? "inline" : "attachment")}; filename*=UTF-8''{Uri.EscapeDataString(fileName)}"; + return File(stream, isPdf ? PdfUploadValidation.PdfContentType : DocxUploadValidation.DocxContentType); + } +} diff --git a/backend/WordVersioning.Api/Controllers/TemplatesController.cs b/backend/WordVersioning.Api/Controllers/TemplatesController.cs index 70eabd3..714a934 100644 --- a/backend/WordVersioning.Api/Controllers/TemplatesController.cs +++ b/backend/WordVersioning.Api/Controllers/TemplatesController.cs @@ -18,6 +18,7 @@ public class TemplatesController( AppDbContext db, IObjectStorageService storage, IDocxGenerationService docxGeneration, + IOpenLinkService openLinks, ICurrentUser currentUser, IOptions minioOptions, ILogger logger) : ControllerBase @@ -390,17 +391,14 @@ public class TemplatesController( return StatusCode(StatusCodes.Status502BadGateway, "อัปโหลดไฟล์ขึ้น storage ไม่สำเร็จ"); } - // presigned อายุสั้น + ชื่อไฟล์ไทยตาม RFC 5987 (นโยบายเดียวกับ download-link ของ version) - var expirySeconds = Math.Clamp(minioOptions.Value.PresignedExpirySeconds, 30, 600); - var fileName = DocxGenerationService.BuildGeneratedFileName(template.Name, now); - var url = storage.GetPresignedGetUrl( - minioOptions.Value.VersionsBucket, objectKey, TimeSpan.FromSeconds(expirySeconds), - $"attachment; filename*=UTF-8''{Uri.EscapeDataString(fileName)}"); + // ลิงก์เปิดอายุสั้นผ่าน https ของ backend (HMAC) — ชื่อไฟล์ไทย/disposition จัดที่ /api/open + // (Word รุ่นใหม่เมิน URL ที่เป็น http เงียบๆ จึงไม่ใช้ presigned ของ MinIO ตรงๆ — v9.2) + var url = openLinks.CreateLink($"d:{objectKey}", "docx", out var expiresAt); logger.LogInformation("Draft generated templateId={TemplateId} userId={UserId} bytes={Size} key={ObjectKey}", template.Id, currentUser.UserId, fileBytes.Length, objectKey); - return Ok(new GenerateDraftResponse(url, now.AddSeconds(expirySeconds))); + return Ok(new GenerateDraftResponse(url, expiresAt)); } /// deactivate (soft-delete) — document เก่าที่อ้างอยู่ยังใช้ได้ต่อ แต่สร้างเอกสารใหม่ไม่ได้ diff --git a/backend/WordVersioning.Api/Program.cs b/backend/WordVersioning.Api/Program.cs index ebb2a34..53d8969 100644 --- a/backend/WordVersioning.Api/Program.cs +++ b/backend/WordVersioning.Api/Program.cs @@ -41,10 +41,17 @@ builder.Services.AddDbContext(options => // MinIO / hash / docx generation / field sync / current user builder.Services.Configure(builder.Configuration.GetSection(MinioOptions.SectionName)); +// OpenLinks ต้องตั้งครบตั้งแต่สตาร์ท — Secret ว่าง = ลายเซ็นปลอมได้, BaseUrl ไม่ใช่ https = Word เมิน (fail ดังกว่าเงียบ) +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(OpenLinkOptions.SectionName)) + .Validate(o => !string.IsNullOrEmpty(o.Secret), "OpenLinks:Secret ต้องตั้งค่า (env: OpenLinks__Secret)") + .Validate(o => o.BaseUrl.StartsWith("https://", StringComparison.Ordinal), "OpenLinks:BaseUrl ต้องเป็น https (Word 16.101+ เมิน http)") + .ValidateOnStart(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); diff --git a/backend/WordVersioning.Api/Services/MinioOptions.cs b/backend/WordVersioning.Api/Services/MinioOptions.cs index b27034c..bc5c1e7 100644 --- a/backend/WordVersioning.Api/Services/MinioOptions.cs +++ b/backend/WordVersioning.Api/Services/MinioOptions.cs @@ -19,9 +19,6 @@ public class MinioOptions public string TemplatesBucket { get; set; } = "docx-templates"; - /// อายุของ presigned download URL ที่ออกให้เบราว์เซอร์ (วินาที) — สั้นๆ ตาม spec - public int PresignedExpirySeconds { get; set; } = 120; - /// FR-16 — จำนวนวันที่ไฟล์ร่าง (object ใต้ prefix `drafts/` ของ VersionsBucket) ถูกลบโดย /// lifecycle rule (ผู้ใช้เปิดผ่าน presigned URL ในไม่กี่นาทีแรก หลังจากนั้นเป็นขยะ) public int DraftRetentionDays { get; set; } = 1; diff --git a/backend/WordVersioning.Api/Services/ObjectStorageService.cs b/backend/WordVersioning.Api/Services/ObjectStorageService.cs index 69d2c55..151f1eb 100644 --- a/backend/WordVersioning.Api/Services/ObjectStorageService.cs +++ b/backend/WordVersioning.Api/Services/ObjectStorageService.cs @@ -13,10 +13,6 @@ public interface IObjectStorageService /// ดึง object กลับมาเป็น stream (proxy ต่อให้ client) — stream นี้ dispose แล้วจะปิด S3 response ให้ด้วย Task GetStreamAsync(string bucket, string key, CancellationToken ct = default); - /// สร้าง presigned GET URL อายุสั้น — ให้เปิดในเบราว์เซอร์ของผู้ใช้โดยตรง - /// (webview ของ Word ทิ้ง blob download แบบเงียบๆ — spec อนุญาต presigned URL อายุสั้นสำหรับดาวน์โหลด) - string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry, string? contentDisposition = null); - /// FR-16 — ตั้ง lifecycle rule ให้ object ใต้ prefix หนึ่งหมดอายุเองตามจำนวนวัน /// (idempotent ตาม rule id และ sync ค่า days ใหม่ถ้า config เปลี่ยนหลัง boot แรก — days < 1 คือ throw) /// ใช้กับไฟล์ร่างที่เก็บชั่วคราวใน bucket versions ใต้ `drafts/` @@ -82,29 +78,6 @@ public class ObjectStorageService : IObjectStorageService return new S3ResponseStream(response); } - public string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry, string? contentDisposition = null) - { - var request = new GetPreSignedUrlRequest - { - BucketName = bucket, - Key = key, - Verb = HttpVerb.GET, - Expires = DateTime.UtcNow + expiry, - }; - if (contentDisposition is not null) - request.ResponseHeaderOverrides.ContentDisposition = contentDisposition; - // GetPreSignedURL เป็น sync (เซ็น SigV4 ฝั่ง client ไม่ยิง network) - var url = _s3.GetPreSignedURL(request); - // AWSSDK v4 บังคับ https ใน presigned URL เสมอ (ไม่สน UseHttp) — สลับ scheme เองตาม config ได้ - // เพราะ SigV4 เซ็นค่า host header (host:port) ไม่รวม scheme → signature ยังถูกต้อง - if (!_useHttps && url.StartsWith("https://", StringComparison.Ordinal)) - { - url = "http://" + url["https://".Length..]; - } - _logger.LogInformation("Issued presigned GET {Bucket}/{Key} expiry={Expiry}s", bucket, key, expiry.TotalSeconds); - return url; - } - public async Task EnsurePrefixExpiryAsync(string bucket, string prefix, int days, string ruleId, CancellationToken ct = default) { ArgumentOutOfRangeException.ThrowIfLessThan(days, 1); diff --git a/backend/WordVersioning.Api/Services/OpenLinkOptions.cs b/backend/WordVersioning.Api/Services/OpenLinkOptions.cs new file mode 100644 index 0000000..479027b --- /dev/null +++ b/backend/WordVersioning.Api/Services/OpenLinkOptions.cs @@ -0,0 +1,18 @@ +namespace WordVersioning.Api.Services; + +/// config ของ "open link" — ลิงก์เปิดไฟล์อายุสั้นที่ออกโดย backend เอง +/// bind จาก section "OpenLinks" ใน appsettings/env (rule: ห้าม hardcode) +public class OpenLinkOptions +{ + public const string SectionName = "OpenLinks"; + + /// secret ใช้เซ็น HMAC ของ token — deploy จริงต้องตั้งค่าเป็นค่าลับจริง (env: OpenLinks__Secret) + public string Secret { get; set; } = string.Empty; + + /// base URL ที่ใช้ประกอบเป็นลิงก์ (ต้องเป็น https — Word รุ่นใหม่เมิน URL ที่เป็น http เงียบๆ) + /// เช่น dev = https://localhost:7232 · prod = https://api.example.com + public string BaseUrl { get; set; } = string.Empty; + + /// อายุของลิงก์ (วินาที) — สั้นๆ ตามนโยบายเดียวกับ presigned เดิม + public int ExpirySeconds { get; set; } = 120; +} diff --git a/backend/WordVersioning.Api/Services/OpenLinkService.cs b/backend/WordVersioning.Api/Services/OpenLinkService.cs new file mode 100644 index 0000000..6e618db --- /dev/null +++ b/backend/WordVersioning.Api/Services/OpenLinkService.cs @@ -0,0 +1,78 @@ +using System.Security.Cryptography; +using Microsoft.Extensions.Options; + +namespace WordVersioning.Api.Services; + +public interface IOpenLinkService +{ + /// ออก URL เปิดไฟล์อายุสั้นที่วิ่งผ่าน backend (ตาม BaseUrl ที่ตั้ง ซึ่งต้องเป็น https) — + /// Word for Mac รุ่นใหม่ (16.101+, ก.ย. 2026) ยอมรับเฉพาะ URL ที่เป็น https และ "ไม่มี query string" + /// (มี query = เมินเงียบๆ ไม่ยิง request เลย) จึงฝังทุกอย่างลง path และปิดท้ายด้วยชื่อไฟล์ .docx/.pdf + /// รหัสระบุไฟล์: "v:{documentId}:{versionId}" หรือ "d:{objectKey}" (ไฟล์ร่าง) + /// docx | pdf — อยู่ในชื่อไฟล์ท้าย path และผูกกับลายเซ็น + /// เวลาหมดอายุ (UTC) ของลิงก์ที่ได้ + string CreateLink(string resource, string format, out DateTimeOffset expiresAt); + + /// validate token ของลิงก์ — true = ผ่าน (ยังไม่หมดอายุ และ HMAC ตรง resource+format) + bool Validate(string resource, string format, string token, string expiresUnix); +} + +/// ลิงก์เปิดไฟล์แบบเซ็น HMAC อายุสั้น (แนวคิดเดียวกับ presigned URL ของ S3 แต่ออกเอง +/// เพื่อคุม scheme/host ให้เป็น https ของ backend) — payload คือ "resource|format|expiry(unix)" +/// เซ็นด้วย HMACSHA256 ของ OpenLinks:Secret แบบ base64url +/// +/// รูป URL (ทุกอย่างอยู่ใน path — ห้ามมี query string): +/// version: {BaseUrl}/api/open/{t}/{e}/v/{documentId}/{versionId}/v{เลขอะไรก็ได้ปลอดภัย}.{ext} +/// draft: {BaseUrl}/api/open/{t}/{e}/d/{objectKey} (objectKey = drafts/…/….docx จบ .docx อยู่แล้ว) +public class OpenLinkService : IOpenLinkService +{ + private readonly OpenLinkOptions _options; + + public OpenLinkService(IOptions options) + { + _options = options.Value; + } + + public string CreateLink(string resource, string format, out DateTimeOffset expiresAt) + { + var expirySeconds = Math.Clamp(_options.ExpirySeconds, 30, 600); + expiresAt = DateTimeOffset.UtcNow.AddSeconds(expirySeconds); + var expiresUnix = expiresAt.ToUnixTimeSeconds().ToString(); + var token = Sign(resource, format, expiresUnix); + var baseUrl = _options.BaseUrl.TrimEnd('/'); + var ext = format == "pdf" ? "pdf" : "docx"; + string target; + if (resource.StartsWith("v:", StringComparison.Ordinal)) + { + // v:{documentId}:{versionId} — ชื่อไฟล์ท้าย path เป็นหน้าตาให้ Word เท่านั้น (ชื่อจริงมาจาก DB ตอน stream) + var parts = resource.Split(':'); + target = $"v/{parts[1]}/{parts[2]}/file.{ext}"; + } + else if (resource.StartsWith("d:", StringComparison.Ordinal)) + { + target = $"d/{resource["d:".Length..]}"; + } + else + throw new ArgumentException($"รูปแบบ resource ไม่รองรับ: {resource}", nameof(resource)); + + return $"{baseUrl}/api/open/{token}/{expiresUnix}/{target}"; + } + + public bool Validate(string resource, string format, string token, string expiresUnix) + { + if (!long.TryParse(expiresUnix, out var unix) || DateTimeOffset.UtcNow.ToUnixTimeSeconds() > unix) + return false; + var expected = Sign(resource, format, expiresUnix); + return CryptographicOperations.FixedTimeEquals( + System.Text.Encoding.UTF8.GetBytes(expected), + System.Text.Encoding.UTF8.GetBytes(token)); + } + + private string Sign(string resource, string format, string expiresUnix) + { + using var hmac = new HMACSHA256(System.Text.Encoding.UTF8.GetBytes(_options.Secret)); + var payload = $"{resource}|{format}|{expiresUnix}"; + var hash = hmac.ComputeHash(System.Text.Encoding.UTF8.GetBytes(payload)); + return Convert.ToBase64String(hash).TrimEnd('=').Replace('+', '-').Replace('/', '_'); // base64url + } +} diff --git a/backend/WordVersioning.Api/appsettings.Development.json b/backend/WordVersioning.Api/appsettings.Development.json index 720a686..b2292e3 100644 --- a/backend/WordVersioning.Api/appsettings.Development.json +++ b/backend/WordVersioning.Api/appsettings.Development.json @@ -22,5 +22,12 @@ "Enabled": true, "HttpPort": 5075, "HttpsPort": 7232 + }, + + "OpenLinks": { + "//": "ลิงก์เปิดไฟล์อายุสั้นที่ออกโดย backend (Word รุ่นใหม่เมิน URL http แบบเงียบๆ) — prod ต้องตั้ง Secret จริงและ BaseUrl เป็น https domain", + "Secret": "dev-open-link-secret-change-in-prod", + "BaseUrl": "https://localhost:7232", + "ExpirySeconds": 120 } } diff --git a/backend/WordVersioning.Api/appsettings.json b/backend/WordVersioning.Api/appsettings.json index 3cc2049..b1f47b4 100644 --- a/backend/WordVersioning.Api/appsettings.json +++ b/backend/WordVersioning.Api/appsettings.json @@ -27,5 +27,12 @@ "Cors": { "AllowedOrigins": [ "https://localhost:3000", "http://localhost:5173" ] + }, + + "OpenLinks": { + "//": "ลิงก์เปิดไฟล์อายุสั้น (HMAC) — prod ต้องตั้ง OpenLinks__Secret และ OpenLinks__BaseUrl (https) ผ่าน env", + "Secret": "", + "BaseUrl": "", + "ExpirySeconds": 120 } } diff --git a/docs/acceptance-review.md b/docs/acceptance-review.md index 42e8e62..27bdcdf 100644 --- a/docs/acceptance-review.md +++ b/docs/acceptance-review.md @@ -146,6 +146,7 @@ QA reviewer: **PASS**, 2 rounds, 3 findings fixed (build compatibility, pending | stamp เฉพาะ `TemplateId` — ไม่มี SavedByAddIn/DocumentTrackingId | test เดียวกัน (อ่าน custom properties จากไฟล์จริง) | | **ค่าที่แทนที่ได้ complex script ของ run ข้อความไทย (w:hint="cs"+w:cs) · SDT เปล่าสืบทอด sdtPr rPr** (v9 — แก้การตัดคำไทย) | `DraftTests.Draft_ThaiPlaceholder_UsesComplexScriptRun_ForWordBreaking` (template จำลองโครงสร้าง T14) | | **ค่าที่มีอักขระควบคุมที่ XML ห้าม (เช่น 0x0B จากการ paste) ถูกตัดออก สร้างไฟล์สำเร็จ ไม่กลายเป็น 500** (v9 — bug จริงจากการใช้งาน) | `DraftTests.Draft_ValueWithInvalidXmlControlChars_IsStrippedNot500` (ครอบทั้ง draft และ generate v0) | +| **open-link ของ backend (v9.2): ไม่มี query string + จบ .docx + `[AllowAnonymous]` ด้วย HMAC + token ผิด → 404** | `DraftTests.Draft_OpenLink_BadToken_Returns404_AndNoBearerNeeded` + `VersionPdfTests` (inline/attachment + ชื่อไทยผ่าน /api/open) | | validate เหมือน FR-10: required ขาด/type ผิด → 400, unknown → 404, deactivate → 400, ค่าเกินนิยาม ignore | `DraftTests` ที่เหลือ (5 tests) | | Start Versioning ของไฟล์ร่าง: upload + templateId → **v1** + Document ผูก template + field sync ครบ + snapshot แบบมีนิยาม | `TemplateLinkingTests.Upload_WithTemplateId_OnNewDocument_CreatesLinkedDocV1WithFieldSync` | | upload ถัดไปไม่ส่ง templateId → v2, TemplateId คงเดิม | test เดียวกัน (upload รอบสอง) | @@ -156,7 +157,7 @@ QA reviewer: **PASS**, 2 rounds, 3 findings fixed (build compatibility, pending | add-in: อ่าน TemplateId / ส่งเฉพาะ startVersioning / append form field | `metadataService.test.ts` (ไฟล์ร่างอ่าน TemplateId), `versionFlow.test.ts` (แนบ/ไม่แนบ templateId), `versioningService.test.ts` (form field) | | เว็บ: เปิด docx URL ที่ถืออยู่เอง (ไฟล์ร่าง) ผ่าน ms-word + fallback + notice | `useVersionOpen.spec.ts` `openDocxUrl` test | -ชุดเต็ม (v9): backend **85 ผ่าน**, add-in **66 ผ่าน**, web **59 ผ่าน** + production build ผ่าน (backend 82 / web 59 ของ v8 + แก้ v9) +ชุดเต็ม (v9.2): backend **86 ผ่าน**, add-in **66 ผ่าน**, web **58 ผ่าน** + production build ผ่าน ### ค้างยืนยันด้วยมือ (ต้องมี Word จริง) @@ -166,6 +167,19 @@ QA reviewer: **PASS**, 2 rounds, 3 findings fixed (build compatibility, pending รายการเต็ม: [แผน FR-16](plans/fr16-draft-generation-plan.md). +### การทดสอบ `ms-word:ofe` บนเครื่องจริง (Mac, Word 16.101.25092124 — 2026-09-10) + +| ทดสอบ | ผล | +|---|---| +| `ms-word:ofe|u|` | ❌ Word เด้งหน้าเลือกไฟล์ (ไม่ยิง request) | +| `ms-word:ofe|u|` | ❌ เหมือนกัน (เคยเปิดได้ 1 ครั้ง — ไม่ deterministic) | +| `ms-word:ofe|u|` | ❌ Word ไม่ยิง request ถึง server เลย (ยืนยันด้วย log) | +| `ms-word:ofe|u|file:///tmp/...` | ❌ หน้าเลือกไฟล์ | +| `open-word:` / `launch-word:` scheme | ❌ ไม่ยิง request | +| `open -a "Microsoft Word" /tmp/.../v1.docx` | ✅ เปิดได้ (แต่เรียกจากเว็บไม่ได้ — browser sandbox + backend รัน server กลางใน production) | + +→ ข้อสรุป: **download-first** คือกลไกเดียวที่ deterministic ทุกเครื่อง (commit ตามด้วย v9.2); `ms-word:ofe` เหลือเป็น best-effort ควบคู่ + QA reviewer: **PASS**, 2 rounds, 8 findings แก้ครบไม่มีที่ถูกปฏิเสธ (2026-09-10) — round 1: blocker `.at(-1)` ใน test ใหม่ตัดกับ tsconfig `lib: es2020` ทำ `addin npm run build` พัง (jest ไม่ type-check จึงรอดมา) + minors 4 (นับจำนวน test ผิดในตารางนี้, stale notices ระหว่างสองปุ่ม submit, AlertBox สำเร็จไม่ใช่ `type="success"`, `isTrackedDocument` ไม่มี production caller → เก็บไว้พร้อมหมายเหตุเหตุผล) · round 2: minors 2 + pre-existing 1 (หัวเอกสาร/สถานะเก่าใน acceptance-review, CLAUDE.md, implementation-plan header). ค้าง manual บน Word จริงตามรายการด้านบน. QA reviewer รอบ v9 (เปิดใน Word + ตัดคำไทย): **PASS**, 2 rounds, 10 findings แก้ครบไม่มีที่ถูกปฏิเสธ (2026-09-10) — round 1: minors 6 (dead `downloadFile`/`filenameFrom`, glossary "stateless" เก่า, lifecycle config เปลี่ยนแล้วเป็น no-op เงียบๆ + days ไม่ validate, ไม่มี test คุม lifecycle, comment เก่าใน TestDocx, typo ใน spec) · round 2: minors 2 + nits 2 (comment เก่าอีก 2 จุด, "(stateless)" ตกค้าง 2 บรรทัด, lifecycle test เพิ่ม assertion ผ่านค่าคืน `true/false`, ตัว verdict นี้เอง) — และ test ใหม่จับ bug จริงได้: `Configuration.Rules` เป็น null ได้ทำให้ startup ตั้ง rule ล้มแบบเงียบมาตั้งแต่แรก (โดน catch กลืน) แก้ด้วย `?? []`. @@ -173,3 +187,5 @@ QA reviewer รอบ v9 (เปิดใน Word + ตัดคำไทย): * QA reviewer รอบ hotfix อักขระควบคุม 0x0B จากการ paste: **PASS**, 1 round (2026-09-10) — reviewer พิสูจน์ว่า test ใหม่ fail จริงบนโค้ดก่อนแก้ · minors 3: แก้ 1 (เปลี่ยนอักขระ VT/FF ล่องหนใน test เป็น escape sequence \u000B/\u000C กันหายระหว่างแก้ไขไฟล์) · ปฏิเสธ 2 พร้อมเหตุผล: (1) fast-path ตรวจ surrogate คู่ครบเป็น invalid ทำให้ค่ามีอีโมจิเดิน slow path — ผลลัพธ์ยังถูก (loop ประกอบคู่กลับคืน) และค่า field สั้นจึงแพงแค่หยดเดียว ไม่คุ้มจะ duplicate pair-logic (2) broad catch ให้ 502 แทน 500 สำหรับ bug ภายใน — สอดคล้อง convention 502 ของ "ไฟล์ template/ค่ามีปัญหา" ที่มีอยู่ และเหตุการณ์จริงที่ catch นี้มีไว้กันคือ input-triggered แท้ๆ (stack ยัง log ฝั่ง server) QA reviewer รอบ hotfix "เปิดใน Word ตอน cold-start" (v9.1): **PASS**, 1 round (2026-09-10) — reviewer stash-verify ว่า test ใหม่ fail จริงบนโค้ดก่อนแก้ · minors 3: แก้ 1 (เพิ่ม test re-click ยกเลิก auto-download ค้าง — save ครั้งเดียว URL ล่าสุด) · ยอมรับ 2 พร้อมเหตุผล: (1) ถ้า Word cold-start มาถึงหน้าต่างภายใน 3 วิ heuristic เห็น "สลับแอป" จึงไม่ auto-download ทั้งที่เอกสารไม่เปิด — เป็นข้อจำกัดเชิงหลักการของ heuristic (แยก "Word เปิด" กับ "เปิดเอกสาร" ไม่ได้จาก web) spec ระบุชัดว่าไม่ใช่หลักฐานความสำเร็จ + มี manual check item ครอบ (2) ถ้า Word เปิดเอกสารสำเร็จแต่ช้ากว่า 3 วิ ผู้ใช้ได้ไฟล์ซ้ำใน Downloads — ต้นทุนต่ำยอมรับได้ · ตรวจแล้ว: PDF path ไม่โดน auto-download, ทุก docx entry point (version rows/v0/ไฟล์ร่าง) ผ่าน openDocxUrl ครบ + +QA reviewer รอบ v9.2 (download-first + open-link): **PASS**, 1 round (2026-09-10) — reviewer ตรวจ security ของ `/api/open` เชิงลึก (HMAC binding/fixed-time compare/path scoping ของ draft catch-all route — ผ่านทั้งหมด) + รัน suites เองได้ 86/58/66 ตรงตาม docs · minors 4 แก้ครบ: (1) คำ spec header ที่ขัดแย้งตารางทดสอบเอง (2) OpenLinks config ตรวจตั้งแต่สตาร์ทด้วย ValidateOnStart (Secret ว่าง/BaseUrl ไม่ใช่ https = fail ดัง) (3) เอา dead code presigned ออก (GetPresignedGetUrl/PresignedExpirySeconds) + comment/copy เก่าทั้ง web/add-in/CLAUDE.md (4) draft success copy นำด้วยการดาวน์โหลดแทน "กำลังเปิดใน Word" diff --git a/docs/plans/officejs-implementation-plan.md b/docs/plans/officejs-implementation-plan.md index fdf182e..fa3aaa2 100644 --- a/docs/plans/officejs-implementation-plan.md +++ b/docs/plans/officejs-implementation-plan.md @@ -646,4 +646,5 @@ npm test # vitest (46 ตัว) - [x] Tests/build (backend 82 / add-in 66 / web 59 + build ทั้ง add-in/web) - [x] QA reviewer PASS (2 rounds, 2026-09-10) - [x] **v9 (feedback จากการใช้จริง): เปิดใน Word ทันที** — เก็บ object ใต้ `drafts/` + lifecycle รายวัน + คืน presigned URL + `useVersionOpen.openDocxUrl` · **แก้ตัดคำไทย** — ค่าเขียนที่ run ข้อความ placeholder จริง (complex script) + SDT เปล่าสืบทอด sdtPr rPr (backend 85 / web 59) + QA reviewer PASS (2 rounds, 2026-09-10) -- [ ] ตรวจ Word จริง (manual): เว็บสร้างไฟล์ร่าง → **Word เปิดทันที + ตัดคำไทยถูก** → untracked + hint → Start Versioning → v1 ผูก template + fields sync · รวมในรายการค้างเดิมของ Word for Windows / Word on the web +- [x] **v9.2: Word 16.101+ เมิน `ms-word:ofe` ทุกรูปแบบ (ทดสอบ 6 รอบบนเครื่องจริง)** → download-first (ดาวน์โหลดทันที + ยิง scheme ควบคู่, เอา heuristic ออก) + `download-link`/draft ออก open-link ของ backend แทน presigned MinIO (`/api/open/{t}/{e}/...` HMAC ใน path ไม่มี query) — backend 86 / web 58 +- [ ] ตรวจ Word จริง (manual): เว็บสร้างไฟล์ร่าง → **ไฟล์ตก Downloads ทันที + ตัดคำไทยถูก** → เปิดไฟล์เอง → untracked + hint → Start Versioning → v1 ผูก template + fields sync · รวมในรายการค้างเดิมของ Word for Windows / Word on the web diff --git a/specs/spec-officejs.md b/specs/spec-officejs.md index ccc1128..2d9e29c 100644 --- a/specs/spec-officejs.md +++ b/specs/spec-officejs.md @@ -1,5 +1,6 @@ # Spec: Word Versioning Add-in (Office.js Cross-Platform + .NET Backend) +> ฉบับแก้ไข v9.2 (2026-09-10): **Word for Mac 16.101+ (ก.ย. 2026) ทำลาย `ms-word:ofe` บนเครื่องที่ทดสอบ** — ทุกรูปแบบถูกเมินแบบไม่ deterministic (https ไม่มี query เคยสำเร็จ 1 ครั้ง; ยืนยันด้วยการทดสอบ 6 รอบบนเครื่องจริง) → เปลี่ยน UX เปิดไฟล์เป็น **download-first** (ดาวน์โหลดทันที + ยิง ms-word ควบคู่แบบ best-effort เผื่อเครื่องที่ยังใช้ได้) + `download-link` เปลี่ยนจาก presigned ของ MinIO เป็น **open-link ของ backend เอง** (HMAC อายุสั้นฝังใน path ไม่มี query, วิ่งผ่าน https ของ backend, `[AllowAnonymous]` โดยใช้ลายเซ็นแทน Bearer) · เอา heuristic สลับแอปออก (โดนหลอกด้วยหน้าเลือกไฟล์ของ Word จนไม่ดาวน์โหลด) > ฉบับแก้ไข v9 (2026-09-10): ปรับ FR-16 ตามผลใช้งานจริง — ไฟล์ร่างเก็บชั่วคราวใน MinIO (prefix `drafts/` + lifecycle ลบรายวัน) เพื่อคืน presigned URL ให้เว็บ**เปิดใน Word ทันที** (เดิมคืนไฟล์ตรงๆ ผู้ใช้ต้องเปิดเอง) + ค่าที่แทนที่เขียนที่ run ข้อความ placeholder จริง (complex script ไทย) แก้การตัดคำภาษาไทย > ฉบับแก้ไข v8 (2026-09-10): เพิ่ม FR-16 — ไฟล์ร่าง (draft): สร้าง .docx จาก template แบบ stateless (ไม่สร้าง Document/version, stamp property `TemplateId` ตัวเดียว) + ผูก template ตอน Start Versioning ผ่าน form field `templateId` ทางเลือก — ไฟล์ร่างเริ่ม versioning ที่ v1 โดยไม่มี v0 > ฉบับแก้ไข v7 (2026-09-08): FR-15 — PDF จาก Word แบบ best-effort; generate เป็น v0, add-in เริ่ม v1; เว็บเปิด Word/PDF พร้อมดาวน์โหลดสำรอง @@ -182,13 +183,13 @@ GET /api/documents/{documentId}/versions - คืน list ของ version เรียงจากใหม่ไปเก่า พร้อม `hasPdf` ระบุว่ามีสำเนา PDF ### FR-7: ดาวน์โหลด version ที่ระบุ -- v7: ทั้ง `/download` และ `/download-link` รับ `?format=pdf` (case-insensitive; ค่าอื่น/default เป็น docx), ไม่มี PDF → 404; presigned URL มี Content-Disposition ชื่อ `{base}_v{N}.docx` (attachment) / `.pdf` (inline), รองรับชื่อไทยด้วย RFC 5987 +- v7: ทั้ง `/download` และ `/download-link` รับ `?format=pdf` (case-insensitive; ค่าอื่น/default เป็น docx), ไม่มี PDF → 404; v9.2: ลิงก์มี Content-Disposition ชื่อ `{base}_v{N}.docx` (attachment) / `.pdf` (inline), รองรับชื่อไทยด้วย RFC 5987 ``` GET /api/documents/{documentId}/versions/{versionId}/download # proxy stream (curl/Swagger) -GET /api/documents/{documentId}/versions/{versionId}/download-link # presigned URL อายุสั้น (add-in ใช้อันนี้) +GET /api/documents/{documentId}/versions/{versionId}/download-link # open-link อายุสั้นของ backend (add-in/เว็บใช้อันนี้) ``` - `download` — stream ไฟล์จาก MinIO กลับไป พร้อม content-type ที่ถูกต้อง -- `download-link` — ออก **presigned URL อายุสั้น** (config ได้, default ~2 นาที, clamp 30 วิ–10 นาที) ให้ client เปิดในเบราว์เซอร์ของเครื่อง — เป็น**กลไกหลักของ add-in** เพราะ webview ของ host ทิ้ง blob download แบบเงียบๆ (v3) · เข้าถึง MinIO โดยตรงโดยไม่ต้องแนบ token แต่หมดอายุทันทีเมื่อเกินเวลา +- `download-link` (v9.2) — ออก **open-link ของ backend เอง**: URL `https ของ backend` + token HMAC อายุสั้น (config ได้ `OpenLinks__Secret/BaseUrl/ExpirySeconds`, default ~2 นาที, clamp 30 วิ–10 นาที) ฝัง**ใน path** รูป `/api/open/{t}/{e}/v/{did}/{vid}/file.docx` (draft: `/d/{objectKey}`) — ไม่มี query string เพราะ Word for Mac 16.101+ เมิน URL ที่มี query · `/api/open` เป็น `[AllowAnonymous]` โดยใช้ลายเซ็นแทน Bearer (Word/เบราว์เซอร์แนบ header ไม่ได้) แล้ว stream ไฟล์จาก MinIO ผ่าน backend (ไม่ expose MinIO เท่าเดิม) · ยังเป็นกลไกหลักของ add-in เพราะ webview ของ host ทิ้ง blob download (v3) ### FR-8: Auth (Keycloak / OpenID Connect) @@ -291,17 +292,17 @@ POST /api/documents/generate { templateId, values: { : } } - เก็บ PDF ใน bucket docx-versions, sibling key `{documentId}/v{N:D4}_{clientVersionId}.pdf`; `PdfObjectKey` nullable - docx upload ล้มใช้ error เดิม; PDF upload ล้มเป็น best-effort → version สำเร็จ `hasPdf:false`; task pane เตือน “บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ” และแนะนำ Save & Version ใหม่ - upload/history response มี `hasPdf`; ปุ่ม PDF แสดงเฉพาะเมื่อมี PDF; add-in เปิด presigned URL ผ่าน `openBrowserWindow` -- เว็บ history ทั้ง desktop/mobile เปิด docx ผ่าน `ms-word:ofe|u|` (ไม่ encode URL ทั้งก้อนซ้ำตาม Microsoft Office URI Schemes); PDF แท็บใหม่; มีลิงก์ดาวน์โหลดสำรองและ heuristic blur/visibilitychange ~3s ซึ่งไม่ใช่หลักฐานว่า Word เปิดสำเร็จ — ถ้าไม่เห็นการสลับแอปภายในหน้าต่างรอ ให้**ดาวน์โหลดไฟล์ให้อัตโนมัติ** + แนะนำเปิดจาก Downloads หรือกดซ้ำ (v9.1: `ms-word:ofe` บน macOS ตอน Word cold-start มักทำ URL event หลุด → เห็นหน้า Recent แทนเอกสาร — กดครั้งที่สองขณะ Word เปิดอยู่แล้วจะสำเร็จ) +- เว็บ history ทั้ง desktop/mobile เปิด docx แบบ **download-first** (v9.2): กดปุ่ม → **ดาวน์โหลดไฟล์ทันทีทุกครั้ง** (ลิงก์เปิดของ backend, disposition attachment) + ยิง `ms-word:ofe|u|` ควบคู่แบบ best-effort เผื่อเครื่องที่ scheme ยังทำงาน (Windows / Word รุ่นก่อน) + ข้อความบอกว่าไฟล์อยู่ Downloads; PDF เปิดแท็บใหม่ · เลิกใช้ heuristic การสลับแอป (v9.1) เพราะ Word for Mac 16.101+ เมิน URL ทุกรูปแบบแต่เด้งหน้าเลือกไฟล์เหมือนสลับแอปจริง ทำให้ heuristic โดนหลอกจนไม่ดาวน์โหลด (ยืนยันด้วยการทดสอบบนเครื่องจริง 2026-09-10) - หน้า generate success เปิด v0 ใน Word พร้อมลิงก์ดาวน์โหลดสำรอง; PDF fidelity และ tracked changes ต้องยืนยันบน Word จริง ### FR-16: ไฟล์ร่าง (draft) — สร้าง .docx จาก template โดยไม่สร้าง Document (เพิ่มเติม v8 · ปรับ v9) -- **`POST /api/templates/{templateId}/generate-draft`** body `{ values }` — ไม่สร้าง `Document`/`DocumentVersion`/`DocumentFieldValue` ใน DB; generate ไฟล์แล้ว**เก็บชั่วคราว**ใน bucket `docx-versions` ใต้ prefix `drafts/{templateId}/{yyyyMMddHHmmss}_{guid}.docx` ซึ่ง lifecycle rule ลบให้เอง (default 1 วัน — config `Minio__DraftRetentionDays`, backend ตั้ง rule ตอน startup แบบ idempotent/best-effort) แล้วคืน **200 `{ url, expiresAt }`** เป็น presigned GET URL อายุสั้น (attachment + ชื่อไฟล์ไทย RFC 5987) ให้เว็บเปิดใน Word ทันที (v8 เดิมคืนไฟล์เป็น stream ตรงๆ — ผู้ใช้ต้องเปิดจาก Downloads เอง) +- **`POST /api/templates/{templateId}/generate-draft`** body `{ values }` — ไม่สร้าง `Document`/`DocumentVersion`/`DocumentFieldValue` ใน DB; generate ไฟล์แล้ว**เก็บชั่วคราว**ใน bucket `docx-versions` ใต้ prefix `drafts/{templateId}/{yyyyMMddHHmmss}_{guid}.docx` ซึ่ง lifecycle rule ลบให้เอง (default 1 วัน — config `Minio__DraftRetentionDays`, backend ตั้ง rule ตอน startup แบบ idempotent/best-effort) แล้วคืน **200 `{ url, expiresAt }`** เป็น open-link อายุสั้นของ backend (v9.2 — ดู FR-7; attachment + ชื่อไฟล์ไทย RFC 5987 ตอน stream) ให้เว็บดาวน์โหลด/เปิดต่อ (v8 เดิมคืนไฟล์เป็น stream ตรงๆ; v9.0–9.1 เคยเป็น presigned ของ MinIO ซึ่งเป็น http จึงเอาออก) - validate เหมือน FR-10 (required + type + format; ส่งเกินนิยาม ignore) · template ไม่พบ → 404, deactivate → 400 ตามนโยบาย FR-10 · ไม่มี `message` (ไม่มี version จึงไม่มี commit message) · **ค่าที่กรอกจะถูกตัดอักขระที่ XML 1.0 ห้ามออกก่อนเขียนลงไฟล์** (0x00–0x1F ยกเว้น tab/LF/CR, lone surrogate, 0xFFFE/0xFFFF — มักติดมากับการ paste เช่น vertical tab 0x0B ซึ่งทำ XmlWriter โยนตอน save) ใช้ร่วมทั้ง generate และ generate-draft · OpenXML โยน exception ที่ไม่ได้ตั้งใจ → 502 พร้อมข้อความอ่านออก ไม่ปล่อย 500 stack - stamp custom property **`TemplateId` = GUID ของ template ตัวเดียว** — ไม่ stamp `SavedByAddIn`/`DocumentTrackingId` ไฟล์ร่างจึงเป็น untracked ในสายตา add-in (เลี่ยงสถานะ "tracked แต่ history 404") - **ค่าที่แทนที่เขียนที่ run ของ "ข้อความ placeholder จริง"** (v9 — ใช้ร่วมทั้ง generate และ generate-draft): template ไทยมักแตก placeholder เป็น `[` / ข้อความ / `]` หลาย run โดย run ข้อความถือ `w:hint="cs"` + `w:cs` (complex script) ที่ Word ใช้ตัดคำ/กระจายภาษาไทย — เขียนค่าที่ run ที่มีข้อความยาวสุด (ไม่ใช่ run แรกที่เป็นปีกกา) · SDT เปล่าให้ run ใหม่สืบทอด `rPr` จาก `sdtPr` (เหมือนพฤติกรรม Word ตอนพิมพ์ลง control) - **ผูก template ตอน Start Versioning:** `POST /api/documents/{documentId}/versions` รับ `templateId` (multipart form field ทางเลือก) — ใช้เฉพาะตอน auto-create `Document` (field sync ทำงานเต็มระบบที่ v1); document มีอยู่แล้ว → เฉย (แต่ validate ทุกครั้งที่ส่งมา) · ไม่ใช่ GUID / ไม่พบใน DB → 400 · template ที่ deactivate → **ผูกได้** (ไฟล์ร่างถูกสร้างตอน template ยัง active — นโยบายเดียวกับ FR-9/FR-12) · upload แรกของ Document ใหม่ = **version 1** (max+1 — ไม่มี v0) - Add-in: อ่าน property `TemplateId` ตอน untracked แล้วแนบ `templateId` **เฉพาะตอน Start Versioning** (Save & Version ไม่ส่ง) + แสดง hint ว่าไฟล์นี้เป็นไฟล์ร่างจาก template -- Web Portal: ปุ่ม "สร้างไฟล์ร่าง" ที่สองบนฟอร์มสร้างเอกสารเดิม → **เปิดใน Word ทันที** ผ่าน `ms-word:ofe|u|` พร้อมลิงก์ดาวน์โหลดสำรองอายุสั้น + heuristic 3 วิ (สลับแอปไม่ได้ = ดาวน์โหลดให้อัตโนมัติ — กลไกเดียวกับ flow เปิด v0 `useVersionOpen`); ฟอร์มอยู่ต่อ ไม่มี documentId/หน้าผลลัพธ์ · ช่องคำอธิบาย version ใช้กับ flow สร้างเอกสาร v0 เท่านั้น · flow v0 (FR-10) และ re-generate (FR-12) คงเดิม +- Web Portal: ปุ่ม "สร้างไฟล์ร่าง" ที่สองบนฟอร์มสร้างเอกสารเดิม → **download-first เหมือน flow เปิด v0** (v9.2 — ดาวน์โหลดทันที + ยิง ms-word ควบคู่, กลไกเดียวกันผ่าน `useVersionOpen`); ฟอร์มอยู่ต่อ ไม่มี documentId/หน้าผลลัพธ์ · ช่องคำอธิบาย version ใช้กับ flow สร้างเอกสาร v0 เท่านั้น · flow v0 (FR-10) และ re-generate (FR-12) คงเดิม ## 7. Data Model (Backend) @@ -482,6 +483,12 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi - Add-in: อ่าน `TemplateId` ใน `readTrackedProperties` + ส่ง `templateId` เฉพาะ `startVersioning` + hint ไฟล์ร่างใน task pane - Web: ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม +### เพิ่มเติม v9.2 (Word 16.101+ เมิน ms-word ทุกรูปแบบ → download-first + open-link ของ backend) +- Backend: `OpenLinkService` (HMAC อายุสั้น `OpenLinks__*`) + `OpenController` `/api/open/{t}/{e}/v/{did}/{vid}/file.{docx|pdf}` และ `/d/{objectKey}` — ไม่มี query string, `[AllowAnonymous]` ด้วยลายเซ็น, stream จาก MinIO · `download-link` และ `generate-draft` ออก open-link แทน presigned ของ MinIO +- Web: `openDocxUrl` = ดาวน์โหลดทันที (anchor → attachment) + ยิง `ms-word:ofe` ควบคู่ + notice "ไฟล์อยู่ Downloads" — เอา heuristic blur/visibilitychange ออก +- การทดสอบบนเครื่องจริง (Mac, Word 16.101.25092124, 2026-09-10): `ms-word:ofe` + http → เมิน; + https (มี query หรือไม่มี) → เมิน (Word เด้งหน้าเลือกไฟล์ ไม่ยิง request ถึง server เลย); + `file://` → เมิน; `open -a Word ` → เปิดได้ (แต่ใช้จากเว็บไม่ได้เพราะ browser sandbox และ backend รันบน server กลางใน production) → เหลือ download-first เป็นทางเดียวที่ deterministic +- ทางเลือกอนาคต (ยังไม่ทำ): helper เล็กติดตั้งบนเครื่อง user รับคำสั่งผ่าน localhost แล้วดาวน์โหลดไป tmp + `open -a Word` — ได้ปุ่มเดียวเปิดอัตโนมัติจริง แต่ต้องมี installer/security/update (scope ใหม่) + ### เพิ่มเติม v9 (ปรับ FR-16 ตามผลใช้งานจริง) - Backend: generate-draft เก็บ object ใต้ `drafts/` + คืน `{ url, expiresAt }` (presigned) + `EnsurePrefixExpiryAsync` ตั้ง lifecycle ตอน startup (idempotent/best-effort) + `ReplaceSdtContent` เขียนค่าที่ run ข้อความยาวสุด (complex script ไทย) + SDT เปล่าสืบทอด `sdtPr` rPr - Web: `useVersionOpen.openDocxUrl` — แยกแกนเปิด docx URL ออกจาก `openVersion` ให้ไฟล์ร่างใช้ร่วม (ถอด CORS expose และ `downloadFile` init ที่เลิกใช้) diff --git a/web/app/composables/useVersionOpen.ts b/web/app/composables/useVersionOpen.ts index 7214b06..2321d6f 100644 --- a/web/app/composables/useVersionOpen.ts +++ b/web/app/composables/useVersionOpen.ts @@ -1,36 +1,27 @@ import { onBeforeUnmount, ref } from 'vue' import { versionDownloadPath, wordOpenUrl, type VersionFormat } from '../utils/versionDownload' -/** Shared by history and generate success. Links stay available even if the focus heuristic fires. */ +/** Shared by history and generate success — ลิงก์เปิดอายุสั้นของ backend (open-link) ใช้ทั้งดาวน์โหลดและเปิดแบบ best-effort */ export function useVersionOpen() { const { apiJson } = useApi() const openingVersionId = ref(null) const downloadError = ref(null) const fallbackUrl = ref(null) const openNotice = ref(null) - let cleanup = () => {} let generation = 0 let pendingTab: Window | null = null - /** - * รอสังเกตการสลับไป Word (blur/visibilitychange) กี่มิลลิวินาทีก่อนสรุปว่าเปิดไม่สำเร็จ — - * ยาวพอให้ Word cold-start โอกาสเปิดได้ทัน แต่ไม่นานจนผู้ใช้งง (ms-word:ofe บน macOS ตอน Word - * ยังไม่เปิดค้างอยู่มักทำ URL event หลุด → เห็นหน้า Recent แทนเอกสาร — ข้อจำกัดของ platform) - */ - const WORD_SWITCH_WAIT_MS = 3000 - function resetOpen() { generation++ pendingTab?.close() pendingTab = null - cleanup() openingVersionId.value = null downloadError.value = null fallbackUrl.value = null openNotice.value = null } - /** ให้เบราว์เซอร์เซฟ URL ลง Downloads (presigned URL ส่ง disposition: attachment อยู่แล้ว — + /** ให้เบราว์เซอร์เซฟ URL ลง Downloads (ลิงก์เปิดของ backend ส่ง disposition: attachment อยู่แล้ว — * หน้าเว็บอยู่ที่เดิม ไม่ navigate ไปไหน) */ function saveToDownloads(url: string) { const a = document.createElement('a') @@ -40,30 +31,17 @@ export function useVersionOpen() { a.remove() } - /** เปิด presigned docx URL ใน Word ผ่าน ms-word:ofe — ถ้าไม่เห็นการสลับแอปภายใน WORD_SWITCH_WAIT_MS - * ถือว่าเปิดไม่สำเร็จ: ดาวน์โหลดไฟล์ให้อัตโนมัติ + แจ้งผู้ใช้ (กดซ้ำตอน Word เปิดอยู่แล้วจะสำเร็จ) + /** เปิดไฟล์ docx แบบ download-first (v9.2): ดาวน์โหลดไฟล์ให้ทันทีทุกครั้ง แล้วยิง ms-word:ofe + * ควบคู่แบบ best-effort เผื่อเครื่องที่ scheme ยังทำงาน (Windows / Word รุ่นก่อน) — + * เลิกพึ่ง heuristic การสลับแอป เพราะ Word for Mac 16.101+ (ก.ย. 2026) เมิน URL ทุกรูปแบบ + * โดยเด้งหน้าเลือกไฟล์ (เหมือนสลับแอปจริง — heuristic โดนหลอกจนไม่ดาวน์โหลดให้) * ใช้กับลิงก์ที่ถืออยู่เองได้เลย (ไฟล์ร่าง FR-16: response ของ generate-draft คือ URL แล้ว) */ function openDocxUrl(url: string) { resetOpen() fallbackUrl.value = url - let switched = false - const onBlur = () => { switched = true } - const onVisibility = () => { if (document.hidden) switched = true } - window.addEventListener('blur', onBlur) - document.addEventListener('visibilitychange', onVisibility) - const timer = window.setTimeout(() => { - if (!switched) { - openNotice.value = - 'Word ไม่ได้เปิดเอกสารอัตโนมัติ (มักเกิดตอน Word เพิ่งถูกเปิดขึ้นครั้งแรก) — ดาวน์โหลดไฟล์ให้แล้วที่โฟลเดอร์ Downloads ให้เปิดจากที่นั่น หรือกดเปิดใน Word อีกครั้งเมื่อ Word เปิดอยู่แล้ว' - saveToDownloads(url) - } - cleanup() - }, WORD_SWITCH_WAIT_MS) - cleanup = () => { - window.clearTimeout(timer) - window.removeEventListener('blur', onBlur) - window.removeEventListener('visibilitychange', onVisibility) - } + saveToDownloads(url) + openNotice.value = + 'ดาวน์โหลดไฟล์แล้วที่โฟลเดอร์ Downloads — เปิดจากที่นั่นได้เลย (บางเครื่อง Word จะเปิดให้อัตโนมัติ)' window.location.assign(wordOpenUrl(url)) } diff --git a/web/app/pages/documents/create/[templateId].vue b/web/app/pages/documents/create/[templateId].vue index 6adfcde..8d80d1e 100644 --- a/web/app/pages/documents/create/[templateId].vue +++ b/web/app/pages/documents/create/[templateId].vue @@ -105,8 +105,8 @@ async function submit() { } } -/** FR-16 — สร้างไฟล์ร่าง (draft): validate ชุดเดียวกันแล้ว POST ค่าไปขอ presigned URL - * แล้วเปิดใน Word ทันทีผ่าน ms-word:ofe พร้อมลิงก์ดาวน์โหลดสำรอง (เหมือน flow เปิด v0) +/** FR-16 — สร้างไฟล์ร่าง (draft): validate ชุดเดียวกันแล้ว POST ค่าไปขอ open-link (อายุสั้น) + * แล้ว download-first เหมือน flow เปิด v0 (ดาวน์โหลดทันที + ยิง ms-word ควบคู่) * backend ไม่สร้าง Document/version — เอกสารจะเกิดตอนกด Start Versioning ใน Word (เป็น v1 พร้อมผูก template นี้) */ async function submitDraft() { draftSuccess.value = null @@ -129,7 +129,7 @@ async function submitDraft() { ))! openDocxUrl(link.url) draftSuccess.value = - 'กำลังเปิดไฟล์ร่างใน Word — เปิด task pane ของ add-in แล้วกด Start Versioning This Document เพื่อเริ่ม track (เอกสารจะถูกผูกกับ template นี้และบันทึกเป็น version 1)' + 'ดาวน์โหลดไฟล์ร่างให้แล้ว (โฟลเดอร์ Downloads) — เปิดไฟล์ใน Word แล้วกด Start Versioning This Document ใน task pane ของ add-in เพื่อเริ่ม track (เอกสารจะถูกผูกกับ template นี้และบันทึกเป็น version 1)' } catch (err) { draftError.value = err instanceof Error ? err.message : 'สร้างไฟล์ร่างไม่สำเร็จ' } finally { diff --git a/web/app/utils/versionDownload.ts b/web/app/utils/versionDownload.ts index 254d1f1..6ee1a3a 100644 --- a/web/app/utils/versionDownload.ts +++ b/web/app/utils/versionDownload.ts @@ -9,6 +9,6 @@ export function versionDownloadPath(documentId: string, versionId: string, forma } /** Office URI Schemes: preserve the signed URL, including its existing percent escapes. */ -export function wordOpenUrl(presignedUrl: string): string { - return `ms-word:ofe|u|${presignedUrl}` +export function wordOpenUrl(fileUrl: string): string { + return `ms-word:ofe|u|${fileUrl}` } diff --git a/web/test/useVersionOpen.spec.ts b/web/test/useVersionOpen.spec.ts index 8d285ac..4e03fc6 100644 --- a/web/test/useVersionOpen.spec.ts +++ b/web/test/useVersionOpen.spec.ts @@ -19,7 +19,6 @@ function mount() { } beforeEach(() => { - vi.useFakeTimers() vi.stubGlobal('useApi', () => ({ apiJson })) tab = { opener: {}, closed: false, location: { replace }, close } vi.spyOn(window, 'open').mockReturnValue(tab as unknown as Window) @@ -30,7 +29,6 @@ afterEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() vi.clearAllMocks() - vi.useRealTimers() }) describe('version open lifecycle', () => { @@ -84,19 +82,16 @@ describe('version open lifecycle', () => { expect(state.downloadError.value).toBe('popup error') expect(state.openingVersionId.value).toBeNull() }) - it('offers Word fallback after timeout and removes listeners on unmount', async () => { + it('openVersion (docx) ดาวน์โหลดทันที + ยิง ms-word + ตั้ง notice และเคลียร์ตอน unmount', async () => { const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {}) - const remove = vi.spyOn(window, 'removeEventListener') - vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) // กัน auto-download click จริง + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) // กัน download-first click จริง apiJson.mockResolvedValue({ url: 'https://files.example/v.docx?signature=a%2Fb' }) const state = mount() await state.openVersion('d', 'v') expect(assign).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/v.docx?signature=a%2Fb') - vi.advanceTimersByTime(3000) expect(state.openNotice.value).toBeTruthy() expect(state.fallbackUrl.value).toBeTruthy() app.unmount() - expect(remove).toHaveBeenCalledWith('blur', expect.any(Function)) }) it('openDocxUrl opens a URL the caller already holds (ไฟล์ร่าง FR-16) without extra request', async () => { const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {}) @@ -106,10 +101,9 @@ describe('version open lifecycle', () => { expect(apiJson).not.toHaveBeenCalled() expect(assign).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/draft.docx?sig=a%2Fb') expect(state.fallbackUrl.value).toBe('https://files.example/draft.docx?sig=a%2Fb') - vi.advanceTimersByTime(3000) expect(state.openNotice.value).toBeTruthy() }) - it('auto-downloads the file to Downloads when Word did not take over within the wait window', () => { + it('download-first: ดาวน์โหลดไฟล์ทันทีที่กด (ไม่ต้องรอ heuristic — Word 16.101+ บน Mac โดนหลอกด้วยหน้าเลือกไฟล์)', () => { const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {}) const saved: HTMLAnchorElement[] = [] vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function clicked(this: HTMLAnchorElement) { @@ -117,30 +111,13 @@ describe('version open lifecycle', () => { }) const state = mount() state.openDocxUrl('https://files.example/draft.docx?sig=a%2Fb') - // ยังไม่มีอะไรเกิดก่อนหมดเวลารอ - vi.advanceTimersByTime(2999) - expect(saved).toHaveLength(0) - expect(state.openNotice.value).toBeNull() - // ครบหน้าต่างรอ (ไม่มีการ blur = Word ไม่ได้เปิด) → เซฟไฟล์ให้อัตโนมัติ + แจ้งเหตุผล - vi.advanceTimersByTime(1) expect(saved).toHaveLength(1) expect(saved[0].href).toBe('https://files.example/draft.docx?sig=a%2Fb') expect(state.openNotice.value).toContain('Downloads') + // ยังยิง ms-word ควบคู่แบบ best-effort เผื่อเครื่องที่ scheme ยังใช้ได้ (Windows / Word รุ่นก่อน) + expect(assign).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/draft.docx?sig=a%2Fb') }) - it('does not auto-download when the app switch was detected (Word เปิดจริง)', async () => { - const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {}) - const saved: HTMLAnchorElement[] = [] - vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function clicked(this: HTMLAnchorElement) { - saved.push(this) - }) - const state = mount() - state.openDocxUrl('https://files.example/draft.docx?sig=a%2Fb') - window.dispatchEvent(new Event('blur')) // ผู้ใช้สลับไป Word - vi.advanceTimersByTime(3000) - expect(saved).toHaveLength(0) - expect(state.openNotice.value).toBeNull() - }) - it('re-click cancels the pending auto-download — only the latest URL is saved once', () => { + it('re-click downloads again with the latest URL', () => { const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {}) const saved: HTMLAnchorElement[] = [] vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function clicked(this: HTMLAnchorElement) { @@ -148,10 +125,9 @@ describe('version open lifecycle', () => { }) const state = mount() state.openDocxUrl('https://files.example/first.docx') - vi.advanceTimersByTime(2000) - state.openDocxUrl('https://files.example/second.docx') // resetOpen ยกเลิก timer อันเก่า - vi.advanceTimersByTime(3000) - expect(saved).toHaveLength(1) - expect(saved[0].href).toBe('https://files.example/second.docx') + state.openDocxUrl('https://files.example/second.docx') // resetOpen เคลียร์ notice เดิมแล้วดาวน์โหลดใหม่ + expect(saved).toHaveLength(2) + expect(saved[1].href).toBe('https://files.example/second.docx') + expect(state.fallbackUrl.value).toBe('https://files.example/second.docx') }) }) From 83a949cfa0ebd941d0ceaf177b5a408eeb0516a9 Mon Sep 17 00:00:00 2001 From: Suphonchai Phoonsawat Date: Thu, 10 Sep 2026 13:59:45 +0700 Subject: [PATCH 05/10] v9.2 follow-up: skip ms-word on Mac by default + sideload notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Web: ยิง ms-word:ofe คู่กับการดาวน์โหลดเฉพาะ non-Mac โดย default — บน Mac ไม่ยิงเพราะ Word 16.101+ เมิน URL แล้วเด้งหน้าเลือกไฟล์ ทุกครั้งจนรำคาญ (override ด้วย NUXT_PUBLIC_OPEN_IN_WORD=on|off) - CLAUDE.md: จดสูตร sideload บน Mac — ปิด Word ก่อน npm start (Word อ่าน manifest ตอน startup เท่านั้น), ปล่อย dev server รันทิ้ง, แก้โค้ดใช้ hot reload - Spec/plan sync + web 60/60 + build; QA reviewer PASS (1 round, minors 3 — แก้ 2 ปฏิเสธ 1 พร้อมเหตุผล) Co-Authored-By: Claude Code --- CLAUDE.md | 5 ++++- docs/acceptance-review.md | 4 +++- docs/plans/officejs-implementation-plan.md | 2 +- specs/spec-officejs.md | 2 +- web/app/composables/useVersionOpen.ts | 20 ++++++++++++----- web/nuxt.config.ts | 3 +++ web/test/useVersionOpen.spec.ts | 26 ++++++++++++++++++++++ 7 files changed, 52 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ff88f5c..9b32984 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`) -**สถานะปัจจุบัน: Phase 0–12 — FR-16 ไฟล์ร่าง: automated QA PASS (backend 86 / add-in 66 / web 58 + build ครบ · v9.2: download-first + open-link ของ backend), ค้าง manual บน Word จริง · Phase 0–11 เสร็จครบ (FR-15 ผ่าน QA + manual บน Word for Mac 2026-09-08)** — ค้างยืนยันบน Word for Windows / Word on the web ตามรายการใน `docs/acceptance-review.md` +**สถานะปัจจุบัน: Phase 0–12 — FR-16 ไฟล์ร่าง: automated QA PASS (backend 86 / add-in 66 / web 60 + build ครบ · v9.2: download-first + open-link ของ backend), ค้าง manual บน Word จริง · Phase 0–11 เสร็จครบ (FR-15 ผ่าน QA + manual บน Word for Mac 2026-09-08)** — ค้างยืนยันบน Word for Windows / Word on the web ตามรายการใน `docs/acceptance-review.md` ## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ) @@ -83,6 +83,9 @@ npx office-addin-dev-certs install # ครั้งแรก npm run build # webpack build npm start # dev server — sideload ผ่าน manifest (symlink) npm test +# ⚠️ sideload บน Mac: ปิด Word ทุกหน้าต่าง (Cmd+Q) ก่อน `npm start` — Word อ่าน manifest ตอน +# startup เท่านั้น ถ้าเปิดค้างไว้ manifest ใหม่จะไม่ถูกโหลด (ต้อง stop/start) · ปล่อย dev server +# รันทิ้งไว้ตลอด session (ถ้าตาย task pane โหลดไม่ได้) · แก้โค้ด add-in ใช้ hot reload ได้เลยไม่ต้อง restart npx jest src/services/metadataService.test.ts # รัน test เดี่ยว # web portal (http://localhost:5173 — ห้ามใช้ 3000) diff --git a/docs/acceptance-review.md b/docs/acceptance-review.md index 27bdcdf..b11979f 100644 --- a/docs/acceptance-review.md +++ b/docs/acceptance-review.md @@ -157,7 +157,7 @@ QA reviewer: **PASS**, 2 rounds, 3 findings fixed (build compatibility, pending | add-in: อ่าน TemplateId / ส่งเฉพาะ startVersioning / append form field | `metadataService.test.ts` (ไฟล์ร่างอ่าน TemplateId), `versionFlow.test.ts` (แนบ/ไม่แนบ templateId), `versioningService.test.ts` (form field) | | เว็บ: เปิด docx URL ที่ถืออยู่เอง (ไฟล์ร่าง) ผ่าน ms-word + fallback + notice | `useVersionOpen.spec.ts` `openDocxUrl` test | -ชุดเต็ม (v9.2): backend **86 ผ่าน**, add-in **66 ผ่าน**, web **58 ผ่าน** + production build ผ่าน +ชุดเต็ม (v9.2): backend **86 ผ่าน**, add-in **66 ผ่าน**, web **60 ผ่าน** + production build ผ่าน ### ค้างยืนยันด้วยมือ (ต้องมี Word จริง) @@ -189,3 +189,5 @@ QA reviewer รอบ hotfix อักขระควบคุม 0x0B จาก QA reviewer รอบ hotfix "เปิดใน Word ตอน cold-start" (v9.1): **PASS**, 1 round (2026-09-10) — reviewer stash-verify ว่า test ใหม่ fail จริงบนโค้ดก่อนแก้ · minors 3: แก้ 1 (เพิ่ม test re-click ยกเลิก auto-download ค้าง — save ครั้งเดียว URL ล่าสุด) · ยอมรับ 2 พร้อมเหตุผล: (1) ถ้า Word cold-start มาถึงหน้าต่างภายใน 3 วิ heuristic เห็น "สลับแอป" จึงไม่ auto-download ทั้งที่เอกสารไม่เปิด — เป็นข้อจำกัดเชิงหลักการของ heuristic (แยก "Word เปิด" กับ "เปิดเอกสาร" ไม่ได้จาก web) spec ระบุชัดว่าไม่ใช่หลักฐานความสำเร็จ + มี manual check item ครอบ (2) ถ้า Word เปิดเอกสารสำเร็จแต่ช้ากว่า 3 วิ ผู้ใช้ได้ไฟล์ซ้ำใน Downloads — ต้นทุนต่ำยอมรับได้ · ตรวจแล้ว: PDF path ไม่โดน auto-download, ทุก docx entry point (version rows/v0/ไฟล์ร่าง) ผ่าน openDocxUrl ครบ QA reviewer รอบ v9.2 (download-first + open-link): **PASS**, 1 round (2026-09-10) — reviewer ตรวจ security ของ `/api/open` เชิงลึก (HMAC binding/fixed-time compare/path scoping ของ draft catch-all route — ผ่านทั้งหมด) + รัน suites เองได้ 86/58/66 ตรงตาม docs · minors 4 แก้ครบ: (1) คำ spec header ที่ขัดแย้งตารางทดสอบเอง (2) OpenLinks config ตรวจตั้งแต่สตาร์ทด้วย ValidateOnStart (Secret ว่าง/BaseUrl ไม่ใช่ https = fail ดัง) (3) เอา dead code presigned ออก (GetPresignedGetUrl/PresignedExpirySeconds) + comment/copy เก่าทั้ง web/add-in/CLAUDE.md (4) draft success copy นำด้วยการดาวน์โหลดแทน "กำลังเปิดใน Word" + +QA reviewer รอบ follow-up v9.2 (เลิกยิง ms-word บน Mac default + จดสูตร sideload): **PASS**, 1 round (2026-09-10) — minors 3: แก้ 2 (sync implementation plan กับ spec + ตัวเลข 60, comment เก่าใน create page) · ปฏิเสธ 1 พร้อมเหตุผล: ปุ่มยังชื่อ "เปิดใน Word" บน Mac ทั้งที่ตอนนี้=ดาวน์โหลด — notice หลังกดอธิบายชัดและชื่อปุ่มเดิมมาก่อนการเปลี่ยนนี้ เปลี่ยนชื่อกระทบ UX ที่ทีมคุ้นเคย เก็บเป็นข้อสังเกตไว้ก่อน (จะเปลี่ยนเมื่อมี feedback ผู้ใช้จริง) diff --git a/docs/plans/officejs-implementation-plan.md b/docs/plans/officejs-implementation-plan.md index fa3aaa2..af12e6e 100644 --- a/docs/plans/officejs-implementation-plan.md +++ b/docs/plans/officejs-implementation-plan.md @@ -646,5 +646,5 @@ npm test # vitest (46 ตัว) - [x] Tests/build (backend 82 / add-in 66 / web 59 + build ทั้ง add-in/web) - [x] QA reviewer PASS (2 rounds, 2026-09-10) - [x] **v9 (feedback จากการใช้จริง): เปิดใน Word ทันที** — เก็บ object ใต้ `drafts/` + lifecycle รายวัน + คืน presigned URL + `useVersionOpen.openDocxUrl` · **แก้ตัดคำไทย** — ค่าเขียนที่ run ข้อความ placeholder จริง (complex script) + SDT เปล่าสืบทอด sdtPr rPr (backend 85 / web 59) + QA reviewer PASS (2 rounds, 2026-09-10) -- [x] **v9.2: Word 16.101+ เมิน `ms-word:ofe` ทุกรูปแบบ (ทดสอบ 6 รอบบนเครื่องจริง)** → download-first (ดาวน์โหลดทันที + ยิง scheme ควบคู่, เอา heuristic ออก) + `download-link`/draft ออก open-link ของ backend แทน presigned MinIO (`/api/open/{t}/{e}/...` HMAC ใน path ไม่มี query) — backend 86 / web 58 +- [x] **v9.2: Word 16.101+ เมิน `ms-word:ofe` ทุกรูปแบบ (ทดสอบ 6 รอบบนเครื่องจริง)** → download-first (ดาวน์โหลดทันที + ยิง scheme ควบคู่เฉพาะ non-Mac โดย default — `NUXT_PUBLIC_OPEN_IN_WORD` override, เอา heuristic ออก) + `download-link`/draft ออก open-link ของ backend แทน presigned MinIO (`/api/open/{t}/{e}/...` HMAC ใน path ไม่มี query) — backend 86 / web 60 - [ ] ตรวจ Word จริง (manual): เว็บสร้างไฟล์ร่าง → **ไฟล์ตก Downloads ทันที + ตัดคำไทยถูก** → เปิดไฟล์เอง → untracked + hint → Start Versioning → v1 ผูก template + fields sync · รวมในรายการค้างเดิมของ Word for Windows / Word on the web diff --git a/specs/spec-officejs.md b/specs/spec-officejs.md index 2d9e29c..ed5aaaf 100644 --- a/specs/spec-officejs.md +++ b/specs/spec-officejs.md @@ -292,7 +292,7 @@ POST /api/documents/generate { templateId, values: { : } } - เก็บ PDF ใน bucket docx-versions, sibling key `{documentId}/v{N:D4}_{clientVersionId}.pdf`; `PdfObjectKey` nullable - docx upload ล้มใช้ error เดิม; PDF upload ล้มเป็น best-effort → version สำเร็จ `hasPdf:false`; task pane เตือน “บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ” และแนะนำ Save & Version ใหม่ - upload/history response มี `hasPdf`; ปุ่ม PDF แสดงเฉพาะเมื่อมี PDF; add-in เปิด presigned URL ผ่าน `openBrowserWindow` -- เว็บ history ทั้ง desktop/mobile เปิด docx แบบ **download-first** (v9.2): กดปุ่ม → **ดาวน์โหลดไฟล์ทันทีทุกครั้ง** (ลิงก์เปิดของ backend, disposition attachment) + ยิง `ms-word:ofe|u|` ควบคู่แบบ best-effort เผื่อเครื่องที่ scheme ยังทำงาน (Windows / Word รุ่นก่อน) + ข้อความบอกว่าไฟล์อยู่ Downloads; PDF เปิดแท็บใหม่ · เลิกใช้ heuristic การสลับแอป (v9.1) เพราะ Word for Mac 16.101+ เมิน URL ทุกรูปแบบแต่เด้งหน้าเลือกไฟล์เหมือนสลับแอปจริง ทำให้ heuristic โดนหลอกจนไม่ดาวน์โหลด (ยืนยันด้วยการทดสอบบนเครื่องจริง 2026-09-10) +- เว็บ history ทั้ง desktop/mobile เปิด docx แบบ **download-first** (v9.2): กดปุ่ม → **ดาวน์โหลดไฟล์ทันทีทุกครั้ง** (ลิงก์เปิดของ backend, disposition attachment) + ยิง `ms-word:ofe|u|` ควบคู่แบบ best-effort **เฉพาะ non-Mac โดย default** — บน Mac ไม่ยิงเพราะ Word 16.101+ ไม่เพียงเมิน URL แต่เด้งหน้าเลือกไฟล์ทุกครั้งจนรำคาญ (override ได้ด้วย env `NUXT_PUBLIC_OPEN_IN_WORD=on|off`) + ข้อความบอกว่าไฟล์อยู่ Downloads; PDF เปิดแท็บใหม่ · เลิกใช้ heuristic การสลับแอป (v9.1) เพราะโดนหลอกจนไม่ดาวน์โหลด (ยืนยันด้วยการทดสอบบนเครื่องจริง 2026-09-10) - หน้า generate success เปิด v0 ใน Word พร้อมลิงก์ดาวน์โหลดสำรอง; PDF fidelity และ tracked changes ต้องยืนยันบน Word จริง ### FR-16: ไฟล์ร่าง (draft) — สร้าง .docx จาก template โดยไม่สร้าง Document (เพิ่มเติม v8 · ปรับ v9) diff --git a/web/app/composables/useVersionOpen.ts b/web/app/composables/useVersionOpen.ts index 2321d6f..126cfb0 100644 --- a/web/app/composables/useVersionOpen.ts +++ b/web/app/composables/useVersionOpen.ts @@ -4,6 +4,14 @@ import { versionDownloadPath, wordOpenUrl, type VersionFormat } from '../utils/v /** Shared by history and generate success — ลิงก์เปิดอายุสั้นของ backend (open-link) ใช้ทั้งดาวน์โหลดและเปิดแบบ best-effort */ export function useVersionOpen() { const { apiJson } = useApi() + // อ่านตอน setup (มี nuxt context) — จะยิง ms-word:ofe ควบคู่กับการดาวน์โหลดหรือไม่ + // (v9.2): Word for Mac 16.101+ เมิน URL ทุกรูปแบบและเด้งหน้าเลือกไฟล์ทุกครั้ง — default + // จึงไม่ยิงบน Mac; override ด้วย env NUXT_PUBLIC_OPEN_IN_WORD=on|off เพื่อบังคับทุกแพลตฟอร์ม + const openInWord = String(useRuntimeConfig().public.openInWord ?? '').toLowerCase() + const isMac = typeof navigator !== 'undefined' + && /Mac/i.test(navigator.platform || navigator.userAgent) + const fireWordScheme = openInWord === 'on' ? true : openInWord === 'off' ? false : !isMac + const openingVersionId = ref(null) const downloadError = ref(null) const fallbackUrl = ref(null) @@ -32,17 +40,17 @@ export function useVersionOpen() { } /** เปิดไฟล์ docx แบบ download-first (v9.2): ดาวน์โหลดไฟล์ให้ทันทีทุกครั้ง แล้วยิง ms-word:ofe - * ควบคู่แบบ best-effort เผื่อเครื่องที่ scheme ยังทำงาน (Windows / Word รุ่นก่อน) — - * เลิกพึ่ง heuristic การสลับแอป เพราะ Word for Mac 16.101+ (ก.ย. 2026) เมิน URL ทุกรูปแบบ - * โดยเด้งหน้าเลือกไฟล์ (เหมือนสลับแอปจริง — heuristic โดนหลอกจนไม่ดาวน์โหลดให้) + * ควบคู่แบบ best-effort เฉพาะเมื่อ fireWordScheme (default: ไม่ยิงบน Mac — ดูคอมเมนต์ด้านบน) * ใช้กับลิงก์ที่ถืออยู่เองได้เลย (ไฟล์ร่าง FR-16: response ของ generate-draft คือ URL แล้ว) */ function openDocxUrl(url: string) { resetOpen() fallbackUrl.value = url saveToDownloads(url) - openNotice.value = - 'ดาวน์โหลดไฟล์แล้วที่โฟลเดอร์ Downloads — เปิดจากที่นั่นได้เลย (บางเครื่อง Word จะเปิดให้อัตโนมัติ)' - window.location.assign(wordOpenUrl(url)) + openNotice.value = fireWordScheme + ? 'ดาวน์โหลดไฟล์แล้วที่โฟลเดอร์ Downloads — เปิดจากที่นั่นได้เลย (บางเครื่อง Word จะเปิดให้อัตโนมัติ)' + : 'ดาวน์โหลดไฟล์แล้วที่โฟลเดอร์ Downloads — เปิดจากที่นั่นได้เลย' + if (fireWordScheme) + window.location.assign(wordOpenUrl(url)) } async function openVersion(documentId: string, versionId: string, format: VersionFormat = 'docx') { diff --git a/web/nuxt.config.ts b/web/nuxt.config.ts index d9fecac..4f66d45 100644 --- a/web/nuxt.config.ts +++ b/web/nuxt.config.ts @@ -27,6 +27,9 @@ export default defineNuxtConfig({ // Keycloak dev ใช้ https:8443 (cert ของ office-addin-dev-certs — เชื่อถือได้ในเครื่อง) keycloakAuthority: 'https://localhost:8443/realms/word-versioning', keycloakClientId: 'word-versioning-web', + // ยิง ms-word:ofe ควบคู่กับการดาวน์โหลดหรือไม่ — '' = auto (ไม่ยิงบน Mac เพราะ Word 16.101+ + // เมิน URL ทุกรูปแบบและเด้งหน้าเลือกไฟล์รำคาญ, ยิงบน Windows/อื่นๆ), 'on'/'off' = บังคับทุกที่ (v9.2) + openInWord: '', }, }, diff --git a/web/test/useVersionOpen.spec.ts b/web/test/useVersionOpen.spec.ts index 4e03fc6..e684425 100644 --- a/web/test/useVersionOpen.spec.ts +++ b/web/test/useVersionOpen.spec.ts @@ -20,6 +20,8 @@ function mount() { beforeEach(() => { vi.stubGlobal('useApi', () => ({ apiJson })) + // default: auto — happy-dom ไม่ใช่ Mac UA จึงยิง ms-word ควบคู่ (แต่ละ test ที่ต้องการก็ stub ทับได้) + vi.stubGlobal('useRuntimeConfig', () => ({ public: { openInWord: '' } })) tab = { opener: {}, closed: false, location: { replace }, close } vi.spyOn(window, 'open').mockReturnValue(tab as unknown as Window) }) @@ -130,4 +132,28 @@ describe('version open lifecycle', () => { expect(saved[1].href).toBe('https://files.example/second.docx') expect(state.fallbackUrl.value).toBe('https://files.example/second.docx') }) + it('OPEN_IN_WORD=off: ไม่ยิง ms-word เลย — ดาวน์โหลดอย่างเดียว (Word 16.101+ บน Mac เด้งหน้าเลือกไฟล์ทุกครั้ง)', () => { + vi.stubGlobal('useRuntimeConfig', () => ({ public: { openInWord: 'off' } })) + const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {}) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + const state = mount() + state.openDocxUrl('https://files.example/draft.docx') + expect(assign).not.toHaveBeenCalled() + expect(state.openNotice.value).not.toContain('อัตโนมัติ') // ข้อความไม่เกี้ยวเรื่องเปิดอัตโนมัติ + }) + it('auto บน Mac: ไม่ยิง ms-word (default), override on แล้วยิงแม้บน Mac', () => { + vi.spyOn(window.navigator, 'platform', 'get').mockReturnValue('MacIntel') + let state = mount() // default '' → auto → Mac = ไม่ยิง + const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {}) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + state.openDocxUrl('https://files.example/draft.docx') + expect(assign).not.toHaveBeenCalled() + + app.unmount() + host.remove() + vi.stubGlobal('useRuntimeConfig', () => ({ public: { openInWord: 'on' } })) + state = mount() + state.openDocxUrl('https://files.example/draft.docx') + expect(assign).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/draft.docx') + }) }) From f4b2ff9d2820ec6ad7dbb44ba2aa16dd81861328 Mon Sep 17 00:00:00 2001 From: Suphonchai Phoonsawat Date: Thu, 10 Sep 2026 14:18:36 +0700 Subject: [PATCH 06/10] v9.2.1: real filenames back in open-link URLs (was literal "file") MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User report: downloaded/opened files named just "file" — v9.2 put a literal file.docx/file.pdf at the end of open-link URLs (treated as cosmetic since Content-Disposition carries the real name, but Word names its URL-copy from the URL path and browsers fall back to the URL filename) - OpenLinkService.CreateLink takes fileName and appends it (URL-encoded, Thai-safe) — version: {base}_v{N}.docx/.pdf per FR-15, draft: {template}_{timestamp}.docx per FR-10 naming policy - Draft route split into /d/{templateId}/{stamp}/{fileName} (rebuilds objectKey + HMAC-validates as before); version route unchanged shape with real name - Tests: URL tails assert real names (คำร้อง_v1.pdf/_v1.docx, draft template name), no /file.docx; new tamper tests — modified stamp/ templateId and swapped docx↔pdf extension (version route) → 404 - backend 89/89; QA reviewer PASS (1 round, minors 2 fixed) Co-Authored-By: Claude Code --- CLAUDE.md | 2 +- .../DraftTests.cs | 59 ++++++++++++++++++- .../VersionPdfTests.cs | 3 + .../Controllers/DocumentsController.cs | 6 +- .../Controllers/OpenController.cs | 26 ++++---- .../Controllers/TemplatesController.cs | 5 +- .../Services/OpenLinkService.cs | 30 ++++++---- docs/acceptance-review.md | 4 +- specs/spec-officejs.md | 4 +- 9 files changed, 104 insertions(+), 35 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9b32984..7632eef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`) -**สถานะปัจจุบัน: Phase 0–12 — FR-16 ไฟล์ร่าง: automated QA PASS (backend 86 / add-in 66 / web 60 + build ครบ · v9.2: download-first + open-link ของ backend), ค้าง manual บน Word จริง · Phase 0–11 เสร็จครบ (FR-15 ผ่าน QA + manual บน Word for Mac 2026-09-08)** — ค้างยืนยันบน Word for Windows / Word on the web ตามรายการใน `docs/acceptance-review.md` +**สถานะปัจจุบัน: Phase 0–12 — FR-16 ไฟล์ร่าง: automated QA PASS (backend 89 / add-in 66 / web 60 + build ครบ · v9.2: download-first + open-link ของ backend), ค้าง manual บน Word จริง · Phase 0–11 เสร็จครบ (FR-15 ผ่าน QA + manual บน Word for Mac 2026-09-08)** — ค้างยืนยันบน Word for Windows / Word on the web ตามรายการใน `docs/acceptance-review.md` ## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ) diff --git a/backend/WordVersioning.Api.IntegrationTests/DraftTests.cs b/backend/WordVersioning.Api.IntegrationTests/DraftTests.cs index d0b329d..1da5195 100644 --- a/backend/WordVersioning.Api.IntegrationTests/DraftTests.cs +++ b/backend/WordVersioning.Api.IntegrationTests/DraftTests.cs @@ -225,8 +225,8 @@ public sealed class DraftTests(IntegrationTestFixture fixture) public async Task Draft_OpenLink_BadToken_Returns404_AndNoBearerNeeded() { // /api/open ใช้ลายเซ็นใน path แทน Bearer ([AllowAnonymous] โดยเจตนา — Word/เบราว์เซอร์แนบ header ไม่ได้) - // รูป URL: {BaseUrl}/api/open/{token}/{expiry}/d/{objectKey} — ไม่มี query string เพราะ Word 16.101+ - // บน Mac เมิน URL ที่มี query แบบเงียบๆ (v9.2) · token ผิดหรือหมดอายุ → 404 พร้อมข้อความบอกกดเปิดใหม่ + // รูป URL: {BaseUrl}/api/open/{token}/{expiry}/d/{templateId}/{stamp}/{fileName} — ไม่มี query string + // เพราะ Word 16.101+ บน Mac เมิน URL ที่มี query แบบเงียบๆ (v9.2) · token ผิด/หมดอายุ → 404 using var client = fixture.CreateClient(); var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "openlink bad token"); using var draft = await client.PostAsJsonAsync( @@ -237,6 +237,13 @@ public sealed class DraftTests(IntegrationTestFixture fixture) Assert.DoesNotContain("?", link.Url); // ทุกอย่างอยู่ใน path — ไม่มี query string Assert.StartsWith("https://test-host/api/open/", link.Url); // ออกผ่าน BaseUrl (https) ของ backend ไม่ใช่ presigned MinIO + // ชื่อไฟล์ท้าย URL เป็นชื่อจริงตามหลักเดิม ({template}_{timestamp}.docx) ไม่ใช่คำว่า file — + // Word ตั้งชื่อ copy ตาม URL ตอนเปิด และ browser ใช้ URL เป็น fallback ตอนดาวน์โหลด + var tail = Uri.UnescapeDataString(new Uri(link.Url).AbsolutePath.Split('/')[^1]); + Assert.StartsWith("openlink bad token", tail); + Assert.EndsWith(".docx", tail); + Assert.DoesNotContain("/file.docx", new Uri(link.Url).AbsolutePath); + // แทน token ใน path ด้วยขยะ (segment ที่ 3 หลัง /api/open/) var segments = new Uri(link.Url).AbsolutePath.Split('/'); segments[3] = "garbage-token"; @@ -247,6 +254,54 @@ public sealed class DraftTests(IntegrationTestFixture fixture) Assert.Contains("หมดอายุ", body); } + [Theory] + [InlineData("stamp")] // แก้ stamp ของ object key (valid token) — resource เปลี่ยน ลายเซ็นต้องพัง + [InlineData("templateId")] // แก้ templateId ใน path — เช่นกัน + public async Task Draft_OpenLink_TamperedRouteSegments_Return404(string which) + { + using var client = fixture.CreateClient(); + var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, $"openlink tamper {which}"); + using var draft = await client.PostAsJsonAsync( + $"api/templates/{templateId}/generate-draft", + new { values = TestFlow.DefaultValues }, IntegrationTestFixture.Json); + var link = (await draft.Content.ReadFromJsonAsync(IntegrationTestFixture.Json))!; + + // path: /api/open/{t}/{e}/d/{templateId}/{stamp}/{fileName} + var segments = new Uri(link.Url).AbsolutePath.Split('/'); + switch (which) + { + case "stamp": + segments[6] = "20990101000000_" + Guid.NewGuid().ToString("N"); + break; + case "templateId": + segments[5] = Guid.NewGuid().ToString(); + break; + } + using var anon = fixture.CreateAnonymousClient(); + using var response = await anon.GetAsync("/" + string.Join("/", segments.Skip(1))); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Version_OpenLink_SwappedExtension_Returns404() + { + // นามสกุลท้าย URL ของ version เลือก format ซึ่งผูกกับลายเซ็น — เปลี่ยน .docx→.pdf ด้วย token เดิมต้อง 404 + // (draft ไม่มีเคสนี้: format เป็น docx เสมอและไม่มี pdf ให้สลับ) + using var client = fixture.CreateClient(); + var documentId = Guid.NewGuid(); + using var upload = await client.PostAsync($"api/documents/{documentId}/versions", + ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "คำร้อง.docx", Guid.NewGuid().ToString())); + var uploaded = (await upload.Content.ReadFromJsonAsync(IntegrationTestFixture.Json))!; + var link = await client.GetFromJsonAsync( + $"api/documents/{documentId}/versions/{uploaded.VersionId}/download-link"); + + var segments = new Uri(link!.Url).AbsolutePath.Split('/'); // /api/open/{t}/{e}/v/{did}/{vid}/{fileName} + segments[^1] = Uri.EscapeDataString("คำร้อง_v1.pdf"); // format signed เป็น docx + using var anon = fixture.CreateAnonymousClient(); + using var response = await anon.GetAsync("/" + string.Join("/", segments.Skip(1))); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + [Fact] public async Task Draft_LifecycleRule_Idempotent_AndSyncsRetentionDays() { diff --git a/backend/WordVersioning.Api.IntegrationTests/VersionPdfTests.cs b/backend/WordVersioning.Api.IntegrationTests/VersionPdfTests.cs index 3bcf645..a978263 100644 --- a/backend/WordVersioning.Api.IntegrationTests/VersionPdfTests.cs +++ b/backend/WordVersioning.Api.IntegrationTests/VersionPdfTests.cs @@ -33,6 +33,8 @@ public sealed class VersionPdfTests(IntegrationTestFixture fixture) Assert.Equal("application/pdf", download.Content.Headers.ContentType!.MediaType); Assert.Equal("คำร้อง.pdf", download.Content.Headers.ContentDisposition!.FileNameStar); var link = await client.GetFromJsonAsync($"api/documents/{id}/versions/{result.VersionId}/download-link?format=pdf"); + // ชื่อไฟล์จริง ({base}_v{N}.pdf) อยู่ท้าย URL ด้วย (Word/browser ตั้งชื่อตาม URL ไม่ใช่แค่ disposition) + Assert.EndsWith("คำร้อง_v1.pdf", Uri.UnescapeDataString(new Uri(link!.Url).AbsolutePath.Split('/')[^1])); using var anon = fixture.CreateAnonymousClient(); // /api/open ไม่ต้อง Bearer — ลิงก์ลายเซ็น (HMAC) แทน using var signedDownload = await anon.GetAsync(new Uri(link!.Url).PathAndQuery); Assert.Equal(HttpStatusCode.OK, signedDownload.StatusCode); @@ -40,6 +42,7 @@ public sealed class VersionPdfTests(IntegrationTestFixture fixture) Assert.Equal("inline", signedDownload.Content.Headers.ContentDisposition!.DispositionType); Assert.Equal("คำร้อง_v1.pdf", signedDownload.Content.Headers.ContentDisposition.FileNameStar); var docxLink = await client.GetFromJsonAsync($"api/documents/{id}/versions/{result.VersionId}/download-link"); + Assert.EndsWith("คำร้อง_v1.docx", Uri.UnescapeDataString(new Uri(docxLink!.Url).AbsolutePath.Split('/')[^1])); using var signedDocx = await anon.GetAsync(new Uri(docxLink!.Url).PathAndQuery); Assert.Equal(HttpStatusCode.OK, signedDocx.StatusCode); Assert.Equal("attachment", signedDocx.Content.Headers.ContentDisposition!.DispositionType); diff --git a/backend/WordVersioning.Api/Controllers/DocumentsController.cs b/backend/WordVersioning.Api/Controllers/DocumentsController.cs index fdffcca..eb9018c 100644 --- a/backend/WordVersioning.Api/Controllers/DocumentsController.cs +++ b/backend/WordVersioning.Api/Controllers/DocumentsController.cs @@ -538,7 +538,11 @@ public class DocumentsController( if (objectKey is null) return NotFound("version นี้ไม่มีสำเนา PDF"); - var url = openLinks.CreateLink($"v:{documentId}:{versionId}", isPdf ? "pdf" : "docx", out var expiresAt); + // ชื่อไฟล์ตามหลักเดิม — อยู่ท้าย URL (Word/browser ตั้งชื่อตาม) และใน Content-Disposition ตอน stream + var baseName = row.FileName.EndsWith(".docx", StringComparison.OrdinalIgnoreCase) + ? row.FileName[..^5] : row.FileName; + var fileName = $"{baseName}_v{row.VersionNumber}.{(isPdf ? "pdf" : "docx")}"; + var url = openLinks.CreateLink($"v:{documentId}:{versionId}", isPdf ? "pdf" : "docx", fileName, out var expiresAt); logger.LogInformation( "Version download link issued documentId={DocumentId} versionId={VersionId} userId={UserId} expiresAt={ExpiresAt}", diff --git a/backend/WordVersioning.Api/Controllers/OpenController.cs b/backend/WordVersioning.Api/Controllers/OpenController.cs index 3446f79..49695c6 100644 --- a/backend/WordVersioning.Api/Controllers/OpenController.cs +++ b/backend/WordVersioning.Api/Controllers/OpenController.cs @@ -19,7 +19,8 @@ public class OpenController( IOptions minioOptions, ILogger logger) : ControllerBase { - /// เปิดไฟล์ของ version: /api/open/{t}/{e}/v/{documentId}/{versionId}/file.{docx|pdf} + /// เปิดไฟล์ของ version: /api/open/{t}/{e}/v/{documentId}/{versionId}/{fileName} + /// (fileName = ชื่อจริงตามหลักเดิม เช่น คำร้อง_v1.docx — ใช้ตรวจ format + ให้ Word/browser ตั้งชื่อตาม) [HttpGet("{t}/{e}/v/{documentId:guid}/{versionId:guid}/{fileName}")] [AllowAnonymous] // การรับรองคือลายเซ็นใน URL (วิ่งเข้า Word/เบราว์เซอร์ที่แนบ header ไม่ได้) [ProducesResponseType(StatusCodes.Status200OK)] @@ -50,22 +51,19 @@ public class OpenController( return await StreamFromStorage(objectKey, $"{baseName}_v{row.VersionNumber}.{(isPdf ? "pdf" : "docx")}", isPdf, ct); } - /// เปิดไฟล์ร่าง (FR-16): /api/open/{t}/{e}/d/drafts/{templateId}/{ไฟล์}.docx - [HttpGet("{t}/{e}/d/{**objectKey}")] + /// เปิดไฟล์ร่าง (FR-16): /api/open/{t}/{e}/d/{templateId}/{stamp}/{fileName} + /// (stamp = yyyyMMddHHmmss_{guid} ของ object key; fileName = ชื่อจริงจากชื่อ template + timestamp) + [HttpGet("{t}/{e}/d/{templateId:guid}/{stamp}/{fileName}")] [AllowAnonymous] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task OpenDraft(string t, string e, string objectKey, CancellationToken ct) + public async Task OpenDraft( + string t, string e, Guid templateId, string stamp, string fileName, CancellationToken ct) { - // objectKey = drafts/{templateId}/{yyyyMMddHHmmss}_{guid}.docx (จบ .docx อยู่แล้ว — ชื่อไทยจริง - // ประกอบจากชื่อ template ตอน stream ไม่ใช่จาก URL ซึ่งต้องเป็น ascii) - if (!objectKey.StartsWith("drafts/", StringComparison.Ordinal) - || !openLinks.Validate($"d:{objectKey}", "docx", t, e)) + var objectKey = $"drafts/{templateId}/{stamp}.docx"; + if (!openLinks.Validate($"d:{objectKey}", "docx", t, e)) return Expired(); - var parts = objectKey.Split('/'); - if (parts.Length != 3 || !Guid.TryParse(parts[1], out var templateId)) - return Expired(); var template = await db.Templates.AsNoTracking() .Where(x => x.Id == templateId) .Select(x => x.Name) @@ -73,12 +71,12 @@ public class OpenController( if (template is null) return NotFound("ไม่พบ template ต้นทางของไฟล์ร่าง"); - var timestamp = parts[2].Split('_').FirstOrDefault() ?? string.Empty; - var fileName = DocxGenerationService.BuildGeneratedFileName(template, + var timestamp = stamp.Split('_').FirstOrDefault() ?? string.Empty; + var displayName = DocxGenerationService.BuildGeneratedFileName(template, DateTimeOffset.TryParseExact(timestamp, "yyyyMMddHHmmss", null, System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out var created) ? created : DateTimeOffset.UtcNow); - return await StreamFromStorage(objectKey, fileName, isPdf: false, ct); + return await StreamFromStorage(objectKey, displayName, isPdf: false, ct); } private IActionResult Expired() => diff --git a/backend/WordVersioning.Api/Controllers/TemplatesController.cs b/backend/WordVersioning.Api/Controllers/TemplatesController.cs index 714a934..65f539d 100644 --- a/backend/WordVersioning.Api/Controllers/TemplatesController.cs +++ b/backend/WordVersioning.Api/Controllers/TemplatesController.cs @@ -391,9 +391,10 @@ public class TemplatesController( return StatusCode(StatusCodes.Status502BadGateway, "อัปโหลดไฟล์ขึ้น storage ไม่สำเร็จ"); } - // ลิงก์เปิดอายุสั้นผ่าน https ของ backend (HMAC) — ชื่อไฟล์ไทย/disposition จัดที่ /api/open + // ลิงก์เปิดอายุสั้นผ่าน https ของ backend (HMAC) — ชื่อไฟล์จริง ({template}_{timestamp}.docx) อยู่ท้าย URL // (Word รุ่นใหม่เมิน URL ที่เป็น http เงียบๆ จึงไม่ใช้ presigned ของ MinIO ตรงๆ — v9.2) - var url = openLinks.CreateLink($"d:{objectKey}", "docx", out var expiresAt); + var fileName = DocxGenerationService.BuildGeneratedFileName(template.Name, now); + var url = openLinks.CreateLink($"d:{objectKey}", "docx", fileName, out var expiresAt); logger.LogInformation("Draft generated templateId={TemplateId} userId={UserId} bytes={Size} key={ObjectKey}", template.Id, currentUser.UserId, fileBytes.Length, objectKey); diff --git a/backend/WordVersioning.Api/Services/OpenLinkService.cs b/backend/WordVersioning.Api/Services/OpenLinkService.cs index 6e618db..5f97040 100644 --- a/backend/WordVersioning.Api/Services/OpenLinkService.cs +++ b/backend/WordVersioning.Api/Services/OpenLinkService.cs @@ -8,10 +8,12 @@ public interface IOpenLinkService /// ออก URL เปิดไฟล์อายุสั้นที่วิ่งผ่าน backend (ตาม BaseUrl ที่ตั้ง ซึ่งต้องเป็น https) — /// Word for Mac รุ่นใหม่ (16.101+, ก.ย. 2026) ยอมรับเฉพาะ URL ที่เป็น https และ "ไม่มี query string" /// (มี query = เมินเงียบๆ ไม่ยิง request เลย) จึงฝังทุกอย่างลง path และปิดท้ายด้วยชื่อไฟล์ .docx/.pdf - /// รหัสระบุไฟล์: "v:{documentId}:{versionId}" หรือ "d:{objectKey}" (ไฟล์ร่าง) + /// รหัสระบุไฟล์: "v:{documentId}:{versionId}" หรือ "d:drafts/{templateId}/{stamp}.docx" (ไฟล์ร่าง) /// docx | pdf — อยู่ในชื่อไฟล์ท้าย path และผูกกับลายเซ็น + /// ชื่อไฟล์ที่ใช้ปิดท้าย URL (ตามหลักเดิม เช่น คำร้อง_v1.docx) — + /// Word ตั้งชื่อ copy ตาม URL ตอนเปิด และ browser ใช้เป็น fallback ตอนดาวน์โหลด จึงต้องเป็นชื่อจริง ไม่ใช่ "file" /// เวลาหมดอายุ (UTC) ของลิงก์ที่ได้ - string CreateLink(string resource, string format, out DateTimeOffset expiresAt); + string CreateLink(string resource, string format, string fileName, out DateTimeOffset expiresAt); /// validate token ของลิงก์ — true = ผ่าน (ยังไม่หมดอายุ และ HMAC ตรง resource+format) bool Validate(string resource, string format, string token, string expiresUnix); @@ -21,9 +23,9 @@ public interface IOpenLinkService /// เพื่อคุม scheme/host ให้เป็น https ของ backend) — payload คือ "resource|format|expiry(unix)" /// เซ็นด้วย HMACSHA256 ของ OpenLinks:Secret แบบ base64url /// -/// รูป URL (ทุกอย่างอยู่ใน path — ห้ามมี query string): -/// version: {BaseUrl}/api/open/{t}/{e}/v/{documentId}/{versionId}/v{เลขอะไรก็ได้ปลอดภัย}.{ext} -/// draft: {BaseUrl}/api/open/{t}/{e}/d/{objectKey} (objectKey = drafts/…/….docx จบ .docx อยู่แล้ว) +/// รูป URL (ทุกอย่างอยู่ใน path — ห้ามมี query string; ชื่อไฟล์ท้าย path เป็นชื่อจริง URL-encoded): +/// version: {BaseUrl}/api/open/{t}/{e}/v/{documentId}/{versionId}/{fileName} +/// draft: {BaseUrl}/api/open/{t}/{e}/d/{templateId}/{stamp}/{fileName} (stamp = yyyyMMddHHmmss_{guid}) public class OpenLinkService : IOpenLinkService { private readonly OpenLinkOptions _options; @@ -33,29 +35,33 @@ public class OpenLinkService : IOpenLinkService _options = options.Value; } - public string CreateLink(string resource, string format, out DateTimeOffset expiresAt) + public string CreateLink(string resource, string format, string fileName, out DateTimeOffset expiresAt) { var expirySeconds = Math.Clamp(_options.ExpirySeconds, 30, 600); expiresAt = DateTimeOffset.UtcNow.AddSeconds(expirySeconds); var expiresUnix = expiresAt.ToUnixTimeSeconds().ToString(); var token = Sign(resource, format, expiresUnix); var baseUrl = _options.BaseUrl.TrimEnd('/'); - var ext = format == "pdf" ? "pdf" : "docx"; string target; if (resource.StartsWith("v:", StringComparison.Ordinal)) { - // v:{documentId}:{versionId} — ชื่อไฟล์ท้าย path เป็นหน้าตาให้ Word เท่านั้น (ชื่อจริงมาจาก DB ตอน stream) var parts = resource.Split(':'); - target = $"v/{parts[1]}/{parts[2]}/file.{ext}"; + target = $"v/{parts[1]}/{parts[2]}"; } - else if (resource.StartsWith("d:", StringComparison.Ordinal)) + else if (resource.StartsWith("d:drafts/", StringComparison.Ordinal)) { - target = $"d/{resource["d:".Length..]}"; + // d:drafts/{templateId}/{stamp}.docx → d/{templateId}/{stamp} (ตัดหัว drafts/ กับท้าย .docx — fileName จริงต่อท้ายแทน) + var key = resource["d:".Length..]; + var tail = key["drafts/".Length..]; + var dot = tail.LastIndexOf('.'); + if (dot >= 0) + tail = tail[..dot]; + target = $"d/{tail}"; } else throw new ArgumentException($"รูปแบบ resource ไม่รองรับ: {resource}", nameof(resource)); - return $"{baseUrl}/api/open/{token}/{expiresUnix}/{target}"; + return $"{baseUrl}/api/open/{token}/{expiresUnix}/{target}/{Uri.EscapeDataString(fileName)}"; } public bool Validate(string resource, string format, string token, string expiresUnix) diff --git a/docs/acceptance-review.md b/docs/acceptance-review.md index b11979f..5ba0eca 100644 --- a/docs/acceptance-review.md +++ b/docs/acceptance-review.md @@ -157,7 +157,7 @@ QA reviewer: **PASS**, 2 rounds, 3 findings fixed (build compatibility, pending | add-in: อ่าน TemplateId / ส่งเฉพาะ startVersioning / append form field | `metadataService.test.ts` (ไฟล์ร่างอ่าน TemplateId), `versionFlow.test.ts` (แนบ/ไม่แนบ templateId), `versioningService.test.ts` (form field) | | เว็บ: เปิด docx URL ที่ถืออยู่เอง (ไฟล์ร่าง) ผ่าน ms-word + fallback + notice | `useVersionOpen.spec.ts` `openDocxUrl` test | -ชุดเต็ม (v9.2): backend **86 ผ่าน**, add-in **66 ผ่าน**, web **60 ผ่าน** + production build ผ่าน +ชุดเต็ม (v9.2.1): backend **89 ผ่าน**, add-in **66 ผ่าน**, web **60 ผ่าน** + production build ผ่าน ### ค้างยืนยันด้วยมือ (ต้องมี Word จริง) @@ -191,3 +191,5 @@ QA reviewer รอบ hotfix "เปิดใน Word ตอน cold-start" (v9. QA reviewer รอบ v9.2 (download-first + open-link): **PASS**, 1 round (2026-09-10) — reviewer ตรวจ security ของ `/api/open` เชิงลึก (HMAC binding/fixed-time compare/path scoping ของ draft catch-all route — ผ่านทั้งหมด) + รัน suites เองได้ 86/58/66 ตรงตาม docs · minors 4 แก้ครบ: (1) คำ spec header ที่ขัดแย้งตารางทดสอบเอง (2) OpenLinks config ตรวจตั้งแต่สตาร์ทด้วย ValidateOnStart (Secret ว่าง/BaseUrl ไม่ใช่ https = fail ดัง) (3) เอา dead code presigned ออก (GetPresignedGetUrl/PresignedExpirySeconds) + comment/copy เก่าทั้ง web/add-in/CLAUDE.md (4) draft success copy นำด้วยการดาวน์โหลดแทน "กำลังเปิดใน Word" QA reviewer รอบ follow-up v9.2 (เลิกยิง ms-word บน Mac default + จดสูตร sideload): **PASS**, 1 round (2026-09-10) — minors 3: แก้ 2 (sync implementation plan กับ spec + ตัวเลข 60, comment เก่าใน create page) · ปฏิเสธ 1 พร้อมเหตุผล: ปุ่มยังชื่อ "เปิดใน Word" บน Mac ทั้งที่ตอนนี้=ดาวน์โหลด — notice หลังกดอธิบายชัดและชื่อปุ่มเดิมมาก่อนการเปลี่ยนนี้ เปลี่ยนชื่อกระทบ UX ที่ทีมคุ้นเคย เก็บเป็นข้อสังเกตไว้ก่อน (จะเปลี่ยนเมื่อมี feedback ผู้ใช้จริง) + +QA reviewer รอบ v9.2.1 (ชื่อไฟล์จริงใน URL open-link): **PASS**, 1 round (2026-09-10) — user แจ้งได้แต่คำว่า "file" เพราะ v9.2 ตั้งท้าย URL เป็น file.docx ตายตัว · minors 2 แก้ครบ: sync spec changelog + เพิ่ม tests ป้องกัน (tampered stamp/templateId และสลับนามสกุล .docx↔.pdf บน version route ต้อง 404 — พบระหว่างเขียนว่า draft route ไม่ผูกชื่อท้ายกับลายเซ็นเพราะ format เป็น docx เสมอ จึงย้ายเคสนามสกุลไป version route ที่ format ผูกลายเซ็นจริง) · reviewer ยืนยัน URL name = disposition name เสมอ (คำนวณจาก source เดียวกัน) และ escaping ชื่อไทยผ่าน end-to-end diff --git a/specs/spec-officejs.md b/specs/spec-officejs.md index ed5aaaf..e0fbe72 100644 --- a/specs/spec-officejs.md +++ b/specs/spec-officejs.md @@ -189,7 +189,7 @@ GET /api/documents/{documentId}/versions/{versionId}/download # proxy str GET /api/documents/{documentId}/versions/{versionId}/download-link # open-link อายุสั้นของ backend (add-in/เว็บใช้อันนี้) ``` - `download` — stream ไฟล์จาก MinIO กลับไป พร้อม content-type ที่ถูกต้อง -- `download-link` (v9.2) — ออก **open-link ของ backend เอง**: URL `https ของ backend` + token HMAC อายุสั้น (config ได้ `OpenLinks__Secret/BaseUrl/ExpirySeconds`, default ~2 นาที, clamp 30 วิ–10 นาที) ฝัง**ใน path** รูป `/api/open/{t}/{e}/v/{did}/{vid}/file.docx` (draft: `/d/{objectKey}`) — ไม่มี query string เพราะ Word for Mac 16.101+ เมิน URL ที่มี query · `/api/open` เป็น `[AllowAnonymous]` โดยใช้ลายเซ็นแทน Bearer (Word/เบราว์เซอร์แนบ header ไม่ได้) แล้ว stream ไฟล์จาก MinIO ผ่าน backend (ไม่ expose MinIO เท่าเดิม) · ยังเป็นกลไกหลักของ add-in เพราะ webview ของ host ทิ้ง blob download (v3) +- `download-link` (v9.2) — ออก **open-link ของ backend เอง**: URL `https ของ backend` + token HMAC อายุสั้น (config ได้ `OpenLinks__Secret/BaseUrl/ExpirySeconds`, default ~2 นาที, clamp 30 วิ–10 นาที) ฝัง**ใน path** รูป `/api/open/{t}/{e}/v/{did}/{vid}/{fileName}` (draft: `/d/{templateId}/{stamp}/{fileName}`) — ไม่มี query string เพราะ Word for Mac 16.101+ เมิน URL ที่มี query · `{fileName}` เป็น**ชื่อจริงตามหลักเดิม** (`{base}_v{N}.docx/pdf` / `{template}_{timestamp}.docx`, URL-encoded ชื่อไทย) เพราะ Word ตั้งชื่อ copy ตาม URL ตอนเปิด และ browser ใช้ URL เป็น fallback ตอนดาวน์โหลด · `/api/open` เป็น `[AllowAnonymous]` โดยใช้ลายเซ็นแทน Bearer (Word/เบราว์เซอร์แนบ header ไม่ได้) แล้ว stream ไฟล์จาก MinIO ผ่าน backend (ไม่ expose MinIO เท่าเดิม) · ยังเป็นกลไกหลักของ add-in เพราะ webview ของ host ทิ้ง blob download (v3) ### FR-8: Auth (Keycloak / OpenID Connect) @@ -484,7 +484,7 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi - Web: ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม ### เพิ่มเติม v9.2 (Word 16.101+ เมิน ms-word ทุกรูปแบบ → download-first + open-link ของ backend) -- Backend: `OpenLinkService` (HMAC อายุสั้น `OpenLinks__*`) + `OpenController` `/api/open/{t}/{e}/v/{did}/{vid}/file.{docx|pdf}` และ `/d/{objectKey}` — ไม่มี query string, `[AllowAnonymous]` ด้วยลายเซ็น, stream จาก MinIO · `download-link` และ `generate-draft` ออก open-link แทน presigned ของ MinIO +- Backend: `OpenLinkService` (HMAC อายุสั้น `OpenLinks__*`) + `OpenController` `/api/open/{t}/{e}/v/{did}/{vid}/{fileName}` และ `/d/{templateId}/{stamp}/{fileName}` — ไม่มี query string, `[AllowAnonymous]` ด้วยลายเซ็น, stream จาก MinIO · `{fileName}` เป็นชื่อจริงตามหลักเดิม (v9.2.1: เดิมใช้ `file.docx` ตายตัวทำให้ Word/browser ตั้งชื่อได้แค่คำว่า file) · `download-link` และ `generate-draft` ออก open-link แทน presigned ของ MinIO - Web: `openDocxUrl` = ดาวน์โหลดทันที (anchor → attachment) + ยิง `ms-word:ofe` ควบคู่ + notice "ไฟล์อยู่ Downloads" — เอา heuristic blur/visibilitychange ออก - การทดสอบบนเครื่องจริง (Mac, Word 16.101.25092124, 2026-09-10): `ms-word:ofe` + http → เมิน; + https (มี query หรือไม่มี) → เมิน (Word เด้งหน้าเลือกไฟล์ ไม่ยิง request ถึง server เลย); + `file://` → เมิน; `open -a Word ` → เปิดได้ (แต่ใช้จากเว็บไม่ได้เพราะ browser sandbox และ backend รันบน server กลางใน production) → เหลือ download-first เป็นทางเดียวที่ deterministic - ทางเลือกอนาคต (ยังไม่ทำ): helper เล็กติดตั้งบนเครื่อง user รับคำสั่งผ่าน localhost แล้วดาวน์โหลดไป tmp + `open -a Word` — ได้ปุ่มเดียวเปิดอัตโนมัติจริง แต่ต้องมี installer/security/update (scope ใหม่) From 4f54af4e24efb840900d37b413a2b97c9496eaa8 Mon Sep 17 00:00:00 2001 From: Suphonchai Phoonsawat Date: Thu, 10 Sep 2026 14:33:12 +0700 Subject: [PATCH 07/10] v9.2.1: re-enable ms-word on all platforms + real filenames in URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User clarified: with the new open-link URLs (https + no query string + ending .docx), pressing the button DID open the file in Word 16.101+ on their Mac — only the OLD URL forms (http, query strings) were ignored. The previous disable-on-Mac was based on terminal tests that hit the old forms / cold-start races, so it removed working behavior. - useVersionOpen: fireWordScheme = openInWord !== 'off' (default '' and 'on' fire on ALL platforms; only explicit 'off' is download-only) - Notice updated: "กำลังเปิดไฟล์ใน Word ให้อัตโนมัติ — หากไม่เปิด ไฟล์อยู่ที่โฟลเดอร์ Downloads แล้ว" (covers cold-start drop) - Docs corrected with v9.2.1 header (old forms were the broken ones; new form confirmed working via real usage) + correction markers on the v9.2 test record + CLAUDE.md rule 13 rewritten - web 60/60 + build; QA reviewer FAIL round 1 (5 doc/comment minors, all fixed) → PASS round 2 Co-Authored-By: Claude Code --- CLAUDE.md | 2 +- docs/acceptance-review.md | 4 +++- docs/plans/officejs-implementation-plan.md | 3 ++- specs/spec-officejs.md | 8 ++++++-- web/app/composables/useVersionOpen.ts | 18 +++++++++--------- web/nuxt.config.ts | 4 ++-- web/test/useVersionOpen.spec.ts | 20 +++++++------------- 7 files changed, 30 insertions(+), 29 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7632eef..0902fac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQ 10. **ทุก Office.js call ต้อง catch** — ห้ามปล่อย unhandled promise rejection 11. **Dev: ทุก service ที่ task pane (หน้า https) เรียกต้องเป็น HTTPS ด้วย cert ของ `office-addin-dev-certs`** — Keycloak = `https://localhost:8443` (Office Dialog API ปฏิเสธ http:// โดยสมบูรณ์), backend = `https://localhost:7232` + `http://localhost:5075` (WKWebView บล็อก mixed content → "Load failed") — รัน `npx office-addin-dev-certs install` ก่อน `docker compose up` 12. **ดาวน์โหลดไฟล์จาก add-in ใช้ลิงก์เปิดอายุสั้นจาก download-link (open-link ของ backend) + `Office.context.ui.openBrowserWindow` เท่านั้น** — webview ของ Word ทิ้ง blob download (`a[download]`) แบบเงียบๆ ไฟล์ไม่ถูกเขียนที่ไหนเลย; และ**ห้าม `cp` ทับ manifest ใน wef folder** (`npm start` ใช้ symlink — ทับแล้วครั้งหน้า EEXIST, ให้แก้ที่ `addin/manifest.xml` ต้นทาง) -13. **`ms-word:ofe` ใช้เป็น best-effort เท่านั้น — ห้ามพึ่งเป็นกลไกหลัก** (v9.2): Word for Mac 16.101+ (ก.ย. 2026) เมิน URL ทุกรูปแบบ (http/https/query/ไม่มี query/file://) โดยเด้งหน้าเลือกไฟล์และไม่ยิง request ถึง server เลย — การเปิดไฟล์จากเว็บต้อง **download-first** (ดาวน์โหลดทันที + ยิง ms-word ควบคู่) · ลิงก์เปิดไฟล์ใช้ **open-link ของ backend** (`/api/open/{t}/{e}/...` HMAC ใน path ไม่มี query string — ฝั่ง MinIO presigned เป็น http ใช้กับ Word ไม่ได้) · prod ต้องตั้ง `OpenLinks__Secret`/`OpenLinks__BaseUrl` (https) +13. **URL สำหรับ `ms-word:ofe` ต้องเป็น https + ไม่มี query string + จบ .docx/.pdf** (v9.2.1): Word for Mac 16.101+ (ก.ย. 2026) เมิน URL แบบเก่า (http เช่น presigned ของ MinIO, หรือมี query string) — แต่ open-link ของ backend แบบ `/api/open/{t}/{e}/.../{fileName}` ใช้ได้จริงแม้ Word 16.101+ (ยืนยันจากการใช้งานจริง) · เว็บยิง ms-word ให้ Word เปิดไฟล์ + ดาวน์โหลดสำรองทันทีทุกครั้ง (`NUXT_PUBLIC_OPEN_IN_WORD=off` ปิดได้) · cold-start ครั้งแรกของ Word URL event อาจหลุด (เด้งหน้าเลือกไฟล์) — ไฟล์สำรองอยู่ Downloads แล้วเสมอ · prod ต้องตั้ง `OpenLinks__Secret`/`OpenLinks__BaseUrl` (https) 14. **`slice.data` จาก `getSliceAsync` มีหลายรูปแบบตาม host** (ArrayBuffer / view / base64 string / `number[]`) — ต้องผ่าน `fileService.sliceDataToBytes()` เสมอ ห้าม assume รูปเดียว 15. **`@nuxtjs/tailwindcss` บน Nuxt 4 ต้องระบุ `cssPath: '~/assets/css/tailwind.css'` ใน nuxt.config.ts เสมอ** — default แบบ relative resolve ต่อ rootDir แล้ว fallback ไปไฟล์ default ของ tailwindcss package **แบบเงียบ** (token/component classes ทั้งหมดหายจาก build โดยไม่มี error) · แก้ `tailwind.config.ts` แล้วต้อง restart dev server · สีทั้งหมดเป็น CSS variables RGB triplet ใน tailwind.css — ห้ามใส่ hex (break `/opacity` utilities เงียบๆ) diff --git a/docs/acceptance-review.md b/docs/acceptance-review.md index 5ba0eca..03c8cf1 100644 --- a/docs/acceptance-review.md +++ b/docs/acceptance-review.md @@ -178,7 +178,7 @@ QA reviewer: **PASS**, 2 rounds, 3 findings fixed (build compatibility, pending | `open-word:` / `launch-word:` scheme | ❌ ไม่ยิง request | | `open -a "Microsoft Word" /tmp/.../v1.docx` | ✅ เปิดได้ (แต่เรียกจากเว็บไม่ได้ — browser sandbox + backend รัน server กลางใน production) | -→ ข้อสรุป: **download-first** คือกลไกเดียวที่ deterministic ทุกเครื่อง (commit ตามด้วย v9.2); `ms-word:ofe` เหลือเป็น best-effort ควบคู่ +→ ข้อสรุปตอนนั้น (v9.2): download-first เป็นกลไกหลัก + `ms-word:ofe` เป็น best-effort ควบคู่ · **แก้ไขความเข้าใจ (v9.2.1):** การใช้งานจริงหลัง deploy v9.2 ยืนยันว่า open-link แบบใหม่ (https + ไม่มี query + จบ .docx ท้าย path) **เปิดใน Word 16.101+ ได้จริงผ่าน browser** (ที่พังคือ URL แบบเก่า http/มี query เท่านั้น — การทดสอบผ่าน terminal `open` ก่อนหน้าให้ผลลบเพราะทดสอบ URL รูปแบบเก่า/ตอน cold-start) default จึงกลับมายิง ms-word ทุกแพลตฟอร์ม + ดาวน์โหลดสำรองทันทีทุกครั้ง QA reviewer: **PASS**, 2 rounds, 8 findings แก้ครบไม่มีที่ถูกปฏิเสธ (2026-09-10) — round 1: blocker `.at(-1)` ใน test ใหม่ตัดกับ tsconfig `lib: es2020` ทำ `addin npm run build` พัง (jest ไม่ type-check จึงรอดมา) + minors 4 (นับจำนวน test ผิดในตารางนี้, stale notices ระหว่างสองปุ่ม submit, AlertBox สำเร็จไม่ใช่ `type="success"`, `isTrackedDocument` ไม่มี production caller → เก็บไว้พร้อมหมายเหตุเหตุผล) · round 2: minors 2 + pre-existing 1 (หัวเอกสาร/สถานะเก่าใน acceptance-review, CLAUDE.md, implementation-plan header). ค้าง manual บน Word จริงตามรายการด้านบน. @@ -193,3 +193,5 @@ QA reviewer รอบ v9.2 (download-first + open-link): **PASS**, 1 round (2026 QA reviewer รอบ follow-up v9.2 (เลิกยิง ms-word บน Mac default + จดสูตร sideload): **PASS**, 1 round (2026-09-10) — minors 3: แก้ 2 (sync implementation plan กับ spec + ตัวเลข 60, comment เก่าใน create page) · ปฏิเสธ 1 พร้อมเหตุผล: ปุ่มยังชื่อ "เปิดใน Word" บน Mac ทั้งที่ตอนนี้=ดาวน์โหลด — notice หลังกดอธิบายชัดและชื่อปุ่มเดิมมาก่อนการเปลี่ยนนี้ เปลี่ยนชื่อกระทบ UX ที่ทีมคุ้นเคย เก็บเป็นข้อสังเกตไว้ก่อน (จะเปลี่ยนเมื่อมี feedback ผู้ใช้จริง) QA reviewer รอบ v9.2.1 (ชื่อไฟล์จริงใน URL open-link): **PASS**, 1 round (2026-09-10) — user แจ้งได้แต่คำว่า "file" เพราะ v9.2 ตั้งท้าย URL เป็น file.docx ตายตัว · minors 2 แก้ครบ: sync spec changelog + เพิ่ม tests ป้องกัน (tampered stamp/templateId และสลับนามสกุล .docx↔.pdf บน version route ต้อง 404 — พบระหว่างเขียนว่า draft route ไม่ผูกชื่อท้ายกับลายเซ็นเพราะ format เป็น docx เสมอ จึงย้ายเคสนามสกุลไป version route ที่ format ผูกลายเซ็นจริง) · reviewer ยืนยัน URL name = disposition name เสมอ (คำนวณจาก source เดียวกัน) และ escaping ชื่อไทยผ่าน end-to-end + +QA reviewer รอบ v9.2.1 re-enable (เปิด ms-word ทุก platform กลับ): **FAIL → แก้ครบ → PASS**, 2 rounds (2026-09-10) — round 1: minors 5 ทั้งหมดเป็น doc/comment ที่ขัดกับเรื่องที่แก้ (comment nuxt.config ยังบอกไม่ยิงบน Mac, spec header/heading v9.2 ยังพูดว่า "เมินทุกรูปแบบ" โดยไม่มีป้ายแก้ไข, implementation plan เขียนประวัติศาสตร์ใหม่ให้ v9.2, comment เก่าใน spec.ts) — โค้ดเองผ่านหมด (toggle logic fail-open, notice ตรงจริงทั้ง 3 ผลลัพธ์, tests ครบ matrix) · round 2: ตรวจซ้ำหลังแก้ — ผ่าน diff --git a/docs/plans/officejs-implementation-plan.md b/docs/plans/officejs-implementation-plan.md index af12e6e..c4ba6be 100644 --- a/docs/plans/officejs-implementation-plan.md +++ b/docs/plans/officejs-implementation-plan.md @@ -646,5 +646,6 @@ npm test # vitest (46 ตัว) - [x] Tests/build (backend 82 / add-in 66 / web 59 + build ทั้ง add-in/web) - [x] QA reviewer PASS (2 rounds, 2026-09-10) - [x] **v9 (feedback จากการใช้จริง): เปิดใน Word ทันที** — เก็บ object ใต้ `drafts/` + lifecycle รายวัน + คืน presigned URL + `useVersionOpen.openDocxUrl` · **แก้ตัดคำไทย** — ค่าเขียนที่ run ข้อความ placeholder จริง (complex script) + SDT เปล่าสืบทอด sdtPr rPr (backend 85 / web 59) + QA reviewer PASS (2 rounds, 2026-09-10) -- [x] **v9.2: Word 16.101+ เมิน `ms-word:ofe` ทุกรูปแบบ (ทดสอบ 6 รอบบนเครื่องจริง)** → download-first (ดาวน์โหลดทันที + ยิง scheme ควบคู่เฉพาะ non-Mac โดย default — `NUXT_PUBLIC_OPEN_IN_WORD` override, เอา heuristic ออก) + `download-link`/draft ออก open-link ของ backend แทน presigned MinIO (`/api/open/{t}/{e}/...` HMAC ใน path ไม่มี query) — backend 86 / web 60 +- [x] **v9.2: Word 16.101+ ทดสอบแล้วเมิน `ms-word:ofe` (ทดสอบ 6 รอบบนเครื่องจริง — ภายหลังพบว่าเมินเฉพาะ URL แบบเก่า)** → open-link ของ backend แทน presigned MinIO (`/api/open/{t}/{e}/...` HMAC ใน path ไม่มี query จบ .docx) + download-first (ดาวน์โหลดทันที, เอา heuristic ออก) +- [x] **v9.2.1: ชื่อไฟล์จริงใน URL open-link** (เดิม file.docx ตายตัวทำให้ Word/browser ตั้งชื่อได้แค่คำว่า file) + ยืนยันจากการใช้งานจริงว่า open-link แบบใหม่เปิดใน Word 16.101+ ได้ → default ยิง ms-word ทุกแพลตฟอร์ม - [ ] ตรวจ Word จริง (manual): เว็บสร้างไฟล์ร่าง → **ไฟล์ตก Downloads ทันที + ตัดคำไทยถูก** → เปิดไฟล์เอง → untracked + hint → Start Versioning → v1 ผูก template + fields sync · รวมในรายการค้างเดิมของ Word for Windows / Word on the web diff --git a/specs/spec-officejs.md b/specs/spec-officejs.md index e0fbe72..9f8f455 100644 --- a/specs/spec-officejs.md +++ b/specs/spec-officejs.md @@ -1,5 +1,7 @@ # Spec: Word Versioning Add-in (Office.js Cross-Platform + .NET Backend) +> ฉบับแก้ไข v9.2.1 (2026-09-10): **แก้ความเข้าใจ v9.2** — การใช้งานจริงหลัง deploy ยืนยันว่า open-link แบบใหม่ (https + ไม่มี query string + จบ .docx ท้าย path) **เปิดใน Word for Mac 16.101+ ได้จริงผ่าน browser** — ที่ถูกเมินคือ URL แบบเก่า (http เช่น presigned ของ MinIO หรือมี query string) เท่านั้น → default กลับมายิง ms-word ทุกแพลตฟอร์ม + ดาวน์โหลดสำรองทันทีทุกครั้ง (`NUXT_PUBLIC_OPEN_IN_WORD=off` ปิดได้) + ชื่อไฟล์จริงตามหลักเดิมใน URL open-link (เดิม v9.2 ใช้ file.docx ตายตัวจน Word/browser ตั้งชื่อได้แค่คำว่า file) + > ฉบับแก้ไข v9.2 (2026-09-10): **Word for Mac 16.101+ (ก.ย. 2026) ทำลาย `ms-word:ofe` บนเครื่องที่ทดสอบ** — ทุกรูปแบบถูกเมินแบบไม่ deterministic (https ไม่มี query เคยสำเร็จ 1 ครั้ง; ยืนยันด้วยการทดสอบ 6 รอบบนเครื่องจริง) → เปลี่ยน UX เปิดไฟล์เป็น **download-first** (ดาวน์โหลดทันที + ยิง ms-word ควบคู่แบบ best-effort เผื่อเครื่องที่ยังใช้ได้) + `download-link` เปลี่ยนจาก presigned ของ MinIO เป็น **open-link ของ backend เอง** (HMAC อายุสั้นฝังใน path ไม่มี query, วิ่งผ่าน https ของ backend, `[AllowAnonymous]` โดยใช้ลายเซ็นแทน Bearer) · เอา heuristic สลับแอปออก (โดนหลอกด้วยหน้าเลือกไฟล์ของ Word จนไม่ดาวน์โหลด) > ฉบับแก้ไข v9 (2026-09-10): ปรับ FR-16 ตามผลใช้งานจริง — ไฟล์ร่างเก็บชั่วคราวใน MinIO (prefix `drafts/` + lifecycle ลบรายวัน) เพื่อคืน presigned URL ให้เว็บ**เปิดใน Word ทันที** (เดิมคืนไฟล์ตรงๆ ผู้ใช้ต้องเปิดเอง) + ค่าที่แทนที่เขียนที่ run ข้อความ placeholder จริง (complex script ไทย) แก้การตัดคำภาษาไทย > ฉบับแก้ไข v8 (2026-09-10): เพิ่ม FR-16 — ไฟล์ร่าง (draft): สร้าง .docx จาก template แบบ stateless (ไม่สร้าง Document/version, stamp property `TemplateId` ตัวเดียว) + ผูก template ตอน Start Versioning ผ่าน form field `templateId` ทางเลือก — ไฟล์ร่างเริ่ม versioning ที่ v1 โดยไม่มี v0 @@ -292,7 +294,7 @@ POST /api/documents/generate { templateId, values: { : } } - เก็บ PDF ใน bucket docx-versions, sibling key `{documentId}/v{N:D4}_{clientVersionId}.pdf`; `PdfObjectKey` nullable - docx upload ล้มใช้ error เดิม; PDF upload ล้มเป็น best-effort → version สำเร็จ `hasPdf:false`; task pane เตือน “บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ” และแนะนำ Save & Version ใหม่ - upload/history response มี `hasPdf`; ปุ่ม PDF แสดงเฉพาะเมื่อมี PDF; add-in เปิด presigned URL ผ่าน `openBrowserWindow` -- เว็บ history ทั้ง desktop/mobile เปิด docx แบบ **download-first** (v9.2): กดปุ่ม → **ดาวน์โหลดไฟล์ทันทีทุกครั้ง** (ลิงก์เปิดของ backend, disposition attachment) + ยิง `ms-word:ofe|u|` ควบคู่แบบ best-effort **เฉพาะ non-Mac โดย default** — บน Mac ไม่ยิงเพราะ Word 16.101+ ไม่เพียงเมิน URL แต่เด้งหน้าเลือกไฟล์ทุกครั้งจนรำคาญ (override ได้ด้วย env `NUXT_PUBLIC_OPEN_IN_WORD=on|off`) + ข้อความบอกว่าไฟล์อยู่ Downloads; PDF เปิดแท็บใหม่ · เลิกใช้ heuristic การสลับแอป (v9.1) เพราะโดนหลอกจนไม่ดาวน์โหลด (ยืนยันด้วยการทดสอบบนเครื่องจริง 2026-09-10) +- เว็บ history ทั้ง desktop/mobile เปิด docx แบบ **download-first** (v9.2): กดปุ่ม → **ดาวน์โหลดไฟล์ทันทีทุกครั้ง** (ลิงก์เปิดของ backend, disposition attachment) + ยิง `ms-word:ofe|u|` ควบคู่ให้ Word เปิดไฟล์เลย **ทุกแพลตฟอร์มโดย default** (v9.2.1: open-link แบบใหม่ https+ไม่มี query+จบ .docx ยืนยันจากการใช้งานจริงว่าเปิดได้แม้ Word for Mac 16.101+ — ที่ถูกเมินคือ URL แบบเก่า http/มี query เท่านั้น) · ปิดได้ด้วย env `NUXT_PUBLIC_OPEN_IN_WORD=off` + ข้อความบอกว่าไฟล์อยู่ Downloads; PDF เปิดแท็บใหม่ · เลิกใช้ heuristic การสลับแอป (v9.1) เพราะโดนหลอกจนไม่ดาวน์โหลด (ยืนยันด้วยการทดสอบบนเครื่องจริง 2026-09-10) - หน้า generate success เปิด v0 ใน Word พร้อมลิงก์ดาวน์โหลดสำรอง; PDF fidelity และ tracked changes ต้องยืนยันบน Word จริง ### FR-16: ไฟล์ร่าง (draft) — สร้าง .docx จาก template โดยไม่สร้าง Document (เพิ่มเติม v8 · ปรับ v9) @@ -483,7 +485,9 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi - Add-in: อ่าน `TemplateId` ใน `readTrackedProperties` + ส่ง `templateId` เฉพาะ `startVersioning` + hint ไฟล์ร่างใน task pane - Web: ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม -### เพิ่มเติม v9.2 (Word 16.101+ เมิน ms-word ทุกรูปแบบ → download-first + open-link ของ backend) +### เพิ่มเติม v9.2 (Word 16.101+ เมิน ms-word แบบ URL เก่า → download-first + open-link ของ backend) + +> หมายเหตุ v9.2.1: บันทึกทดสอบด้านล่างเขียนตอน v9.2 ก่อนพบว่า open-link แบบใหม่ (https+ไม่มี query+จบ .docx) ใช้ได้จริง — ทดสอบผ่าน terminal `open` ให้ผลลบเพราะทดสอบ URL รูปแบบเก่า/ช่วง cold-start ผลสรุปปัจจุบันอยู่ที่หัวข้อ v9.2.1 ด้านบน - Backend: `OpenLinkService` (HMAC อายุสั้น `OpenLinks__*`) + `OpenController` `/api/open/{t}/{e}/v/{did}/{vid}/{fileName}` และ `/d/{templateId}/{stamp}/{fileName}` — ไม่มี query string, `[AllowAnonymous]` ด้วยลายเซ็น, stream จาก MinIO · `{fileName}` เป็นชื่อจริงตามหลักเดิม (v9.2.1: เดิมใช้ `file.docx` ตายตัวทำให้ Word/browser ตั้งชื่อได้แค่คำว่า file) · `download-link` และ `generate-draft` ออก open-link แทน presigned ของ MinIO - Web: `openDocxUrl` = ดาวน์โหลดทันที (anchor → attachment) + ยิง `ms-word:ofe` ควบคู่ + notice "ไฟล์อยู่ Downloads" — เอา heuristic blur/visibilitychange ออก - การทดสอบบนเครื่องจริง (Mac, Word 16.101.25092124, 2026-09-10): `ms-word:ofe` + http → เมิน; + https (มี query หรือไม่มี) → เมิน (Word เด้งหน้าเลือกไฟล์ ไม่ยิง request ถึง server เลย); + `file://` → เมิน; `open -a Word ` → เปิดได้ (แต่ใช้จากเว็บไม่ได้เพราะ browser sandbox และ backend รันบน server กลางใน production) → เหลือ download-first เป็นทางเดียวที่ deterministic diff --git a/web/app/composables/useVersionOpen.ts b/web/app/composables/useVersionOpen.ts index 126cfb0..78a5457 100644 --- a/web/app/composables/useVersionOpen.ts +++ b/web/app/composables/useVersionOpen.ts @@ -1,16 +1,16 @@ import { onBeforeUnmount, ref } from 'vue' import { versionDownloadPath, wordOpenUrl, type VersionFormat } from '../utils/versionDownload' -/** Shared by history and generate success — ลิงก์เปิดอายุสั้นของ backend (open-link) ใช้ทั้งดาวน์โหลดและเปิดแบบ best-effort */ +/** Shared by history and generate success — ลิงก์เปิดอายุสั้นของ backend (open-link) ใช้ทั้งดาวน์โหลดและเปิดใน Word */ export function useVersionOpen() { const { apiJson } = useApi() // อ่านตอน setup (มี nuxt context) — จะยิง ms-word:ofe ควบคู่กับการดาวน์โหลดหรือไม่ - // (v9.2): Word for Mac 16.101+ เมิน URL ทุกรูปแบบและเด้งหน้าเลือกไฟล์ทุกครั้ง — default - // จึงไม่ยิงบน Mac; override ด้วย env NUXT_PUBLIC_OPEN_IN_WORD=on|off เพื่อบังคับทุกแพลตฟอร์ม + // (v9.2.1): open-link แบบใหม่ (https + ไม่มี query string + จบ .docx) เปิดใน Word ได้จริง + // รวมถึง Word for Mac 16.101+ — ยืนยันจากการใช้งานจริง (URL แบบเก่า http/มี query ต่างหากที่ถูกเมิน) + // default จึงยิงทุกแพลตฟอร์ม; ปิดได้ด้วย env NUXT_PUBLIC_OPEN_IN_WORD=off (เช่นเครื่องที่ Word + // เด้งหน้าเลือกไฟล์รำคาญ หรือ cold-start ทำ URL event หลุดบ่อยๆ) const openInWord = String(useRuntimeConfig().public.openInWord ?? '').toLowerCase() - const isMac = typeof navigator !== 'undefined' - && /Mac/i.test(navigator.platform || navigator.userAgent) - const fireWordScheme = openInWord === 'on' ? true : openInWord === 'off' ? false : !isMac + const fireWordScheme = openInWord !== 'off' const openingVersionId = ref(null) const downloadError = ref(null) @@ -39,15 +39,15 @@ export function useVersionOpen() { a.remove() } - /** เปิดไฟล์ docx แบบ download-first (v9.2): ดาวน์โหลดไฟล์ให้ทันทีทุกครั้ง แล้วยิง ms-word:ofe - * ควบคู่แบบ best-effort เฉพาะเมื่อ fireWordScheme (default: ไม่ยิงบน Mac — ดูคอมเมนต์ด้านบน) + /** เปิดไฟล์ docx (v9.2.1): ยิง ms-word:ofe ให้ Word เปิดไฟล์เลย + ดาวน์โหลดสำรองลง Downloads ทันทีทุกครั้ง + * (ถ้า Word เปิดให้อัตโนมัติก็ใช้ได้เลย ถ้าไม่ (เช่น cold-start ครั้งแรก URL event หลุด) ไฟล์ก็อยู่ Downloads แล้ว) * ใช้กับลิงก์ที่ถืออยู่เองได้เลย (ไฟล์ร่าง FR-16: response ของ generate-draft คือ URL แล้ว) */ function openDocxUrl(url: string) { resetOpen() fallbackUrl.value = url saveToDownloads(url) openNotice.value = fireWordScheme - ? 'ดาวน์โหลดไฟล์แล้วที่โฟลเดอร์ Downloads — เปิดจากที่นั่นได้เลย (บางเครื่อง Word จะเปิดให้อัตโนมัติ)' + ? 'กำลังเปิดไฟล์ใน Word ให้อัตโนมัติ — หากไม่เปิด ไฟล์อยู่ที่โฟลเดอร์ Downloads แล้วเปิดจากที่นั่นได้เลย' : 'ดาวน์โหลดไฟล์แล้วที่โฟลเดอร์ Downloads — เปิดจากที่นั่นได้เลย' if (fireWordScheme) window.location.assign(wordOpenUrl(url)) diff --git a/web/nuxt.config.ts b/web/nuxt.config.ts index 4f66d45..2f8e551 100644 --- a/web/nuxt.config.ts +++ b/web/nuxt.config.ts @@ -27,8 +27,8 @@ export default defineNuxtConfig({ // Keycloak dev ใช้ https:8443 (cert ของ office-addin-dev-certs — เชื่อถือได้ในเครื่อง) keycloakAuthority: 'https://localhost:8443/realms/word-versioning', keycloakClientId: 'word-versioning-web', - // ยิง ms-word:ofe ควบคู่กับการดาวน์โหลดหรือไม่ — '' = auto (ไม่ยิงบน Mac เพราะ Word 16.101+ - // เมิน URL ทุกรูปแบบและเด้งหน้าเลือกไฟล์รำคาญ, ยิงบน Windows/อื่นๆ), 'on'/'off' = บังคับทุกที่ (v9.2) + // ยิง ms-word:ofe ควบคู่กับการดาวน์โหลดหรือไม่ — '' / 'on' = ยิงทุกแพลตฟอร์ม (open-link แบบใหม่ https+ไม่มี query+จบ .docx + // ยืนยันแล้วว่าเปิดได้จริงแม้ Word 16.101+ บน Mac — v9.2.1), 'off' = ดาวน์โหลดอย่างเดียว openInWord: '', }, }, diff --git a/web/test/useVersionOpen.spec.ts b/web/test/useVersionOpen.spec.ts index e684425..4828055 100644 --- a/web/test/useVersionOpen.spec.ts +++ b/web/test/useVersionOpen.spec.ts @@ -20,7 +20,7 @@ function mount() { beforeEach(() => { vi.stubGlobal('useApi', () => ({ apiJson })) - // default: auto — happy-dom ไม่ใช่ Mac UA จึงยิง ms-word ควบคู่ (แต่ละ test ที่ต้องการก็ stub ทับได้) + // default: ยิง ms-word คู่กัน (toggle ไม่มี platform logic แล้ว — แต่ละ test stub ทับได้) vi.stubGlobal('useRuntimeConfig', () => ({ public: { openInWord: '' } })) tab = { opener: {}, closed: false, location: { replace }, close } vi.spyOn(window, 'open').mockReturnValue(tab as unknown as Window) @@ -116,7 +116,7 @@ describe('version open lifecycle', () => { expect(saved).toHaveLength(1) expect(saved[0].href).toBe('https://files.example/draft.docx?sig=a%2Fb') expect(state.openNotice.value).toContain('Downloads') - // ยังยิง ms-word ควบคู่แบบ best-effort เผื่อเครื่องที่ scheme ยังใช้ได้ (Windows / Word รุ่นก่อน) + // ยิง ms-word คู่กัน — Word เปิดไฟล์ให้อัตโนมัติ (ถ้า cold-start หลุด ไฟล์สำรองก็อยู่ Downloads แล้ว) expect(assign).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/draft.docx?sig=a%2Fb') }) it('re-click downloads again with the latest URL', () => { @@ -132,28 +132,22 @@ describe('version open lifecycle', () => { expect(saved[1].href).toBe('https://files.example/second.docx') expect(state.fallbackUrl.value).toBe('https://files.example/second.docx') }) - it('OPEN_IN_WORD=off: ไม่ยิง ms-word เลย — ดาวน์โหลดอย่างเดียว (Word 16.101+ บน Mac เด้งหน้าเลือกไฟล์ทุกครั้ง)', () => { + it('OPEN_IN_WORD=off: ไม่ยิง ms-word เลย — ดาวน์โหลดอย่างเดียว', () => { vi.stubGlobal('useRuntimeConfig', () => ({ public: { openInWord: 'off' } })) const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {}) vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) const state = mount() state.openDocxUrl('https://files.example/draft.docx') expect(assign).not.toHaveBeenCalled() - expect(state.openNotice.value).not.toContain('อัตโนมัติ') // ข้อความไม่เกี้ยวเรื่องเปิดอัตโนมัติ + expect(state.openNotice.value).toContain('Downloads') // ข้อความชี้ไปที่ Downloads อย่างเดียว }) - it('auto บน Mac: ไม่ยิง ms-word (default), override on แล้วยิงแม้บน Mac', () => { + it('default (auto) ยิง ms-word ทุกแพลตฟอร์ม รวมถึง Mac — open-link แบบใหม่ (https + ไม่มี query + จบ .docx) เปิดได้จริงบน Word 16.101+', () => { vi.spyOn(window.navigator, 'platform', 'get').mockReturnValue('MacIntel') - let state = mount() // default '' → auto → Mac = ไม่ยิง const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {}) vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) - state.openDocxUrl('https://files.example/draft.docx') - expect(assign).not.toHaveBeenCalled() - - app.unmount() - host.remove() - vi.stubGlobal('useRuntimeConfig', () => ({ public: { openInWord: 'on' } })) - state = mount() + const state = mount() state.openDocxUrl('https://files.example/draft.docx') expect(assign).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/draft.docx') + expect(state.openNotice.value).toContain('อัตโนมัติ') }) }) From 1fbac8927377f1f7507c8c6a2d10a6f01f70c2a2 Mon Sep 17 00:00:00 2001 From: Suphonchai Phoonsawat Date: Thu, 10 Sep 2026 14:41:26 +0700 Subject: [PATCH 08/10] update T14_Template.docx with new content --- T14_Template.docx | Bin 161148 -> 160770 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/T14_Template.docx b/T14_Template.docx index e288c355aee4b71bd5bd8913279a4f61fbb8c671..7da7559efdbeb791255b56df9a6692c14e569471 100644 GIT binary patch delta 24097 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 LuG98Sd%*ty23Pu5 delta 24417 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

A8Q}Y(M7xV3K#o(dA|fvkWHSv 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 Date: Sun, 13 Sep 2026 20:26:01 +0700 Subject: [PATCH 09/10] infra: minio images via quay.io (Docker Hub private) + USERPROFILE fallback for certs mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker Hub ทำ repo ของ MinIO (minio/minio, minio/mc) เป็น private — pull ไม่ได้ ทั้งจาก compose และตรงๆ จึงย้ายไป quay.io mirror ทางการ ส่วน mount cert ของ Keycloak ใส่ fallback ${HOME:-${USERPROFILE}} เพราะ PowerShell ไม่ set HOME เคยทำให้ compose recreate ด้วย mount พาธว่าง แล้ว Keycloak start ไม่ได้ Co-Authored-By: Claude Code --- docker-compose.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index a901142..d894438 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,7 +28,8 @@ services: # container listen 39000/39001 เท่ากับ host ตรงๆ (ไม่ map ข้าม port) — map ข้ามแล้ว # console จะ redirect/-เชื่อม server ผิด port (login error / เด้งไป :9001 ตาย) minio: - image: minio/minio:latest + # quay.io mirror — Docker Hub ทำ minio/minio เป็น private แล้ว (pull access denied) + image: quay.io/minio/minio:latest command: server /data --address ":39000" --console-address ":39001" environment: MINIO_ROOT_USER: ${MINIO_ROOT_USER} @@ -52,7 +53,8 @@ services: # สร้าง bucket docx-versions + docx-templates แบบ private (anonymous = none) # idempotent — รันซ้ำได้ด้วย --ignore-existing minio-init: - image: minio/mc:latest + # quay.io mirror — Docker Hub ทำ minio/mc เป็น private แล้ว (pull access denied) + image: quay.io/minio/mc:latest depends_on: minio: condition: service_healthy @@ -91,7 +93,8 @@ services: # 8080 (http) เปิดไว้เฉพาะใน network ของ compose ให้ service `api` (profile) ใช้ภายใน volumes: - ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro - - ${HOME}/.office-addin-dev-certs:/certs:ro + # HOME unset ใน PowerShell — fallback ไป USERPROFILE (Windows); Mac/Git Bash ใช้ HOME ตามปกติ + - ${HOME:-${USERPROFILE}}/.office-addin-dev-certs:/certs:ro # --- Backend API (ยังไม่เปิดใช้ — รอ Phase 2 สร้าง Dockerfile) ----- # รันฝั่ง host ด้วย `dotnet run` ได้เลย โดยชี้ config ไปที่ localhost:5432 / localhost:39000 From 535a59f09213200cf946c1b10cb39cefcc9420f3 Mon Sep 17 00:00:00 2001 From: Suphonchai Phoonsawat Date: Sun, 13 Sep 2026 20:49:24 +0700 Subject: [PATCH 10/10] fix: update MinIO container to use quay.io mirror and add EF migrations on startup --- .../IntegrationTestFixture.cs | 3 ++- backend/WordVersioning.Api/Program.cs | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/backend/WordVersioning.Api.IntegrationTests/IntegrationTestFixture.cs b/backend/WordVersioning.Api.IntegrationTests/IntegrationTestFixture.cs index 194f051..160479e 100644 --- a/backend/WordVersioning.Api.IntegrationTests/IntegrationTestFixture.cs +++ b/backend/WordVersioning.Api.IntegrationTests/IntegrationTestFixture.cs @@ -46,7 +46,8 @@ public sealed class IntegrationTestFixture : IAsyncLifetime private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:16") // ให้ตรงกับ docker-compose ของ dev .Build(); - private readonly MinioContainer _minio = new MinioBuilder("minio/minio:latest") + // quay.io mirror — Docker Hub ทำ minio/minio เป็น private แล้ว (เดียวกับ docker-compose) + private readonly MinioContainer _minio = new MinioBuilder("quay.io/minio/minio:latest") .WithUsername(MinioUser) .WithPassword(MinioPassword) .Build(); diff --git a/backend/WordVersioning.Api/Program.cs b/backend/WordVersioning.Api/Program.cs index 53d8969..9c3acab 100644 --- a/backend/WordVersioning.Api/Program.cs +++ b/backend/WordVersioning.Api/Program.cs @@ -108,6 +108,14 @@ if (devHttpsEnabled) var app = builder.Build(); +// สร้าง/อัปเดต schema ด้วย EF migrations ทุกครั้งที่สตาร์ท (idempotent — apply เฉพาะ migration ที่ยังค้าง) +// DB ไม่พร้อม = ให้ตายตรงนี้เลย ดีกว่าขึ้นมาแล้วเงียบๆ แล้ว 500 ทุก request +await using (var scope = app.Services.CreateAsyncScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); +} + // FR-16 — ไฟล์ร่างอยู่ใต้ prefix `drafts/` ของ bucket versions: ตั้ง lifecycle ให้หมดอายุเอง (idempotent) // best-effort — MinIO ยังไม่พร้อมตอนสตาร์ทก็แค่ log เตือน ไม่บล็อก (ไฟล์ร่างจะค้างนานขึ้นเท่านั้น) try