Compare commits
10 commits
b3c1c667f7
...
535a59f092
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
535a59f092 | ||
|
|
3b449f46e0 | ||
|
|
1fbac89273 | ||
|
|
4f54af4e24 | ||
|
|
f4b2ff9d28 | ||
|
|
83a949cfa0 | ||
|
|
ee4d8573ee | ||
|
|
e0558e3cf4 | ||
|
|
b9b227926d | ||
|
|
70f71f4c5c |
33 changed files with 949 additions and 302 deletions
16
CLAUDE.md
16
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`)
|
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 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`
|
||||||
|
|
||||||
## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ)
|
## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ)
|
||||||
|
|
||||||
|
|
@ -37,9 +37,10 @@ Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQ
|
||||||
9. **Config (DB/MinIO/Keycloak) แยกเป็น env/appsettings เท่านั้น** — ห้าม hardcode; `userId` เอาจาก JWT claims เท่านั้น ห้ามรับจาก client
|
9. **Config (DB/MinIO/Keycloak) แยกเป็น env/appsettings เท่านั้น** — ห้าม hardcode; `userId` เอาจาก JWT claims เท่านั้น ห้ามรับจาก client
|
||||||
10. **ทุก Office.js call ต้อง catch** — ห้ามปล่อย unhandled promise rejection
|
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`
|
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` ต้นทาง)
|
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. **`slice.data` จาก `getSliceAsync` มีหลายรูปแบบตาม host** (ArrayBuffer / view / base64 string / `number[]`) — ต้องผ่าน `fileService.sliceDataToBytes()` เสมอ ห้าม assume รูปเดียว
|
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. **`@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 เงียบๆ)
|
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 หลักที่ต้องเข้าใจ
|
## สถาปัตยกรรม — flow หลักที่ต้องเข้าใจ
|
||||||
|
|
||||||
|
|
@ -82,6 +83,9 @@ npx office-addin-dev-certs install # ครั้งแรก
|
||||||
npm run build # webpack build
|
npm run build # webpack build
|
||||||
npm start # dev server — sideload ผ่าน manifest (symlink)
|
npm start # dev server — sideload ผ่าน manifest (symlink)
|
||||||
npm test
|
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 เดี่ยว
|
npx jest src/services/metadataService.test.ts # รัน test เดี่ยว
|
||||||
|
|
||||||
# web portal (http://localhost:5173 — ห้ามใช้ 3000)
|
# web portal (http://localhost:5173 — ห้ามใช้ 3000)
|
||||||
|
|
@ -97,6 +101,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|<url>` พร้อมดาวน์โหลดสำรอง; PDF เปิดแท็บใหม่. Add-in ใช้ `openBrowserWindow` เท่านั้น. ข้อมูลเก่าไม่ backfill หรือเปลี่ยนเลข.
|
`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|<url>` พร้อมดาวน์โหลดสำรอง; 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) แล้วคืน **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`.
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,6 @@
|
||||||
|
|
||||||
- **Version PDF**: สำเนา PDF ที่ Word ผลิตขณะเปิดเอกสาร; ขาดได้แบบ best-effort
|
- **Version PDF**: สำเนา PDF ที่ Word ผลิตขณะเปิดเอกสาร; ขาดได้แบบ best-effort
|
||||||
- **ฉบับตั้งต้น (v0)**: version ที่เว็บสร้างจากฟอร์ม ยังไม่เคยผ่าน Word
|
- **ฉบับตั้งต้น (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 หลังฉบับตั้งต้น
|
- **เลข version**: จำนวนรอบการบันทึก version ผ่าน Word หลังฉบับตั้งต้น
|
||||||
- **ต้นฉบับ (docx)**: ตัว version จริง; PDF เป็น derived artifact
|
- **ต้นฉบับ (docx)**: ตัว version จริง; PDF เป็น derived artifact
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -5,8 +5,8 @@ using WordVersioning.Api.Services;
|
||||||
|
|
||||||
namespace WordVersioning.Api.IntegrationTests;
|
namespace WordVersioning.Api.IntegrationTests;
|
||||||
|
|
||||||
/// <summary>FR-16 — ไฟล์ร่าง (draft): generate docx จาก template แบบ stateless
|
/// <summary>FR-16 — ไฟล์ร่าง (draft): generate docx จาก template โดยไม่เกิด Document/version ใน DB
|
||||||
/// (ค่าครบ + stamp เฉพาะ TemplateId + ไม่เกิด Document/version ใดๆ ในระบบ)</summary>
|
/// (v9: เก็บไฟล์ชั่วคราวใต้ drafts/ + คืน presigned URL ให้เว็บเปิดใน Word + ค่าที่แทนที่ได้ formatting ไทยถูกต้อง)</summary>
|
||||||
[Collection(IntegrationTestCollection.Name)]
|
[Collection(IntegrationTestCollection.Name)]
|
||||||
public sealed class DraftTests(IntegrationTestFixture fixture)
|
public sealed class DraftTests(IntegrationTestFixture fixture)
|
||||||
{
|
{
|
||||||
|
|
@ -19,13 +19,16 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
|
||||||
using var response = await client.PostAsJsonAsync(
|
using var response = await client.PostAsJsonAsync(
|
||||||
$"api/templates/{templateId}/generate-draft",
|
$"api/templates/{templateId}/generate-draft",
|
||||||
new { values = TestFlow.DefaultValues }, IntegrationTestFixture.Json);
|
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)
|
// คืน presigned URL อายุสั้น (เปิดใน Word ผ่าน ms-word:ofe / ดาวน์โหลดสำรอง) — ดึงไฟล์จริงได้ทันที
|
||||||
Assert.Equal(TestDocx.DocxContentType, response.Content.Headers.ContentType?.MediaType);
|
var link = (await response.Content.ReadFromJsonAsync<DraftLinkResult>(IntegrationTestFixture.Json))!;
|
||||||
Assert.NotNull(response.Content.Headers.ContentDisposition?.FileNameStar);
|
Assert.False(string.IsNullOrWhiteSpace(link.Url));
|
||||||
|
Assert.True(link.ExpiresAt > DateTimeOffset.UtcNow.AddSeconds(30));
|
||||||
var fileBytes = await response.Content.ReadAsByteArrayAsync();
|
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();
|
||||||
|
|
||||||
// (1) ค่าทุก field ถูกแทนที่ใน SDT — เหมือน generate ปกติ
|
// (1) ค่าทุก field ถูกแทนที่ใน SDT — เหมือน generate ปกติ
|
||||||
Assert.Equal("สมหญิง รักงาน", TestDocx.ReadSdtText(fileBytes, "employee_name"));
|
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("75000", TestDocx.ReadSdtText(fileBytes, "salary"));
|
||||||
Assert.Equal("2026-08-01", TestDocx.ReadSdtText(fileBytes, "start_date"));
|
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 positionRuns = TestDocx.ReadSdtRuns(fileBytes, "position");
|
||||||
var runWithText = Assert.Single(positionRuns.Where(r => r.Text.Length > 0));
|
var runWithText = Assert.Single(positionRuns.Where(r => r.Text.Length > 0));
|
||||||
Assert.True(runWithText.Bold);
|
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, "SavedByAddIn"));
|
||||||
Assert.Null(TestDocx.ReadCustomProperty(fileBytes, "DocumentTrackingId"));
|
Assert.Null(TestDocx.ReadCustomProperty(fileBytes, "DocumentTrackingId"));
|
||||||
|
|
||||||
// (4) stateless จริง: ไม่เกิด Document/version ใดๆ จาก template นี้
|
// (4) ไม่เกิด Document/version ใดๆ ใน DB จาก template นี้ (ไฟล์เก็บชั่วคราวใน MinIO เท่านั้น ไม่ใช่ record)
|
||||||
await using var db = fixture.CreateDb();
|
await using var db = fixture.CreateDb();
|
||||||
Assert.False(await db.Documents.AsNoTracking().AnyAsync(d => d.TemplateId == templateId));
|
Assert.False(await db.Documents.AsNoTracking().AnyAsync(d => d.TemplateId == templateId));
|
||||||
Assert.False(await db.DocumentVersions.AsNoTracking()
|
Assert.False(await db.DocumentVersions.AsNoTracking()
|
||||||
|
|
@ -52,6 +55,64 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
|
||||||
.AnyAsync(v => db.Documents.Any(d => d.Id == v.DocumentId && d.TemplateId == templateId)));
|
.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<string, string> { ["detail1"] = detail, ["remark1"] = "หมายเหตุผู้พิพากษา" } },
|
||||||
|
IntegrationTestFixture.Json);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var link = (await response.Content.ReadFromJsonAsync<DraftLinkResult>(IntegrationTestFixture.Json))!;
|
||||||
|
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();
|
||||||
|
|
||||||
|
// ค่ายาวอยู่ใน 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>สร้าง template จาก BuildThaiTemplateDocx พร้อมตั้ง field defs (detail1 required, remark1 ทางเลือก)</summary>
|
||||||
|
private async Task<Guid> 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<CreateTemplateResult>(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]
|
[Fact]
|
||||||
public async Task Draft_MissingRequiredField_Returns400()
|
public async Task Draft_MissingRequiredField_Returns400()
|
||||||
{
|
{
|
||||||
|
|
@ -123,7 +184,138 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
|
||||||
using var response = await client.PostAsJsonAsync(
|
using var response = await client.PostAsJsonAsync(
|
||||||
$"api/templates/{templateId}/generate-draft", new { values }, IntegrationTestFixture.Json);
|
$"api/templates/{templateId}/generate-draft", new { values }, IntegrationTestFixture.Json);
|
||||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
var fileBytes = await response.Content.ReadAsByteArrayAsync();
|
var link = (await response.Content.ReadFromJsonAsync<DraftLinkResult>(IntegrationTestFixture.Json))!;
|
||||||
|
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));
|
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<string, string>(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<DraftLinkResult>(IntegrationTestFixture.Json))!;
|
||||||
|
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
|
||||||
|
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_OpenLink_BadToken_Returns404_AndNoBearerNeeded()
|
||||||
|
{
|
||||||
|
// /api/open ใช้ลายเซ็นใน path แทน Bearer ([AllowAnonymous] โดยเจตนา — Word/เบราว์เซอร์แนบ header ไม่ได้)
|
||||||
|
// รูป 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(
|
||||||
|
$"api/templates/{templateId}/generate-draft",
|
||||||
|
new { values = TestFlow.DefaultValues }, IntegrationTestFixture.Json);
|
||||||
|
var link = (await draft.Content.ReadFromJsonAsync<DraftLinkResult>(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
|
||||||
|
|
||||||
|
// ชื่อไฟล์ท้าย 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";
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<DraftLinkResult>(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<UploadVersionResult>(IntegrationTestFixture.Json))!;
|
||||||
|
var link = await client.GetFromJsonAsync<DownloadLinkResult>(
|
||||||
|
$"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()
|
||||||
|
{
|
||||||
|
// 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<ArgumentOutOfRangeException>(() =>
|
||||||
|
storage.EnsurePrefixExpiryAsync(bucket, "test-drafts-probe/", 0, "test-expire-probe"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ public sealed class GenerateTests(IntegrationTestFixture fixture)
|
||||||
Assert.Equal("75000", TestDocx.ReadSdtText(fileBytes, "salary"));
|
Assert.Equal("75000", TestDocx.ReadSdtText(fileBytes, "salary"));
|
||||||
Assert.Equal("2026-08-01", TestDocx.ReadSdtText(fileBytes, "start_date"));
|
Assert.Equal("2026-08-01", TestDocx.ReadSdtText(fileBytes, "start_date"));
|
||||||
|
|
||||||
// (2) multi-run SDT: ค่าใหม่เขียนที่ Text แรก (ใน Run แรก — สืบทอด rPr bold ของ template)
|
// (2) multi-run SDT: ค่าใหม่เขียนที่ run ข้อความยาวสุด (v9 — ที่นี่คือ run bold ซึ่งชนะด้วย tie แบบเจตนา,
|
||||||
// และ Text ของ run หลังถูกลบ (เหลือ run โล่งที่มีแต่ rPr — มองไม่เห็น ไม่มีผลกับเนื้อหา)
|
// ดู comment ใน TestDocx) และ Text ของ run หลังถูกลบ (เหลือ run โล่งที่มีแต่ rPr — มองไม่เห็น ไม่มีผลกับเนื้อหา)
|
||||||
var positionRuns = TestDocx.ReadSdtRuns(fileBytes, "position");
|
var positionRuns = TestDocx.ReadSdtRuns(fileBytes, "position");
|
||||||
var runWithText = Assert.Single(positionRuns.Where(r => r.Text.Length > 0));
|
var runWithText = Assert.Single(positionRuns.Where(r => r.Text.Length > 0));
|
||||||
Assert.Equal("วิศวกรซอฟต์แวร์", runWithText.Text);
|
Assert.Equal("วิศวกรซอฟต์แวร์", runWithText.Text);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Testcontainers.Minio;
|
using Testcontainers.Minio;
|
||||||
using Testcontainers.PostgreSql;
|
using Testcontainers.PostgreSql;
|
||||||
using WordVersioning.Api.Data;
|
using WordVersioning.Api.Data;
|
||||||
|
|
@ -45,7 +46,8 @@ public sealed class IntegrationTestFixture : IAsyncLifetime
|
||||||
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:16") // ให้ตรงกับ docker-compose ของ dev
|
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:16") // ให้ตรงกับ docker-compose ของ dev
|
||||||
.Build();
|
.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)
|
.WithUsername(MinioUser)
|
||||||
.WithPassword(MinioPassword)
|
.WithPassword(MinioPassword)
|
||||||
.Build();
|
.Build();
|
||||||
|
|
@ -112,6 +114,9 @@ public sealed class IntegrationTestFixture : IAsyncLifetime
|
||||||
["Authentication:ValidAudiences:0"] = "word-addin-officejs",
|
["Authentication:ValidAudiences:0"] = "word-addin-officejs",
|
||||||
["Authentication:ValidAudiences:1"] = "word-versioning-web",
|
["Authentication:ValidAudiences:1"] = "word-versioning-web",
|
||||||
["Cors:AllowedOrigins:0"] = "https://localhost:3000",
|
["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",
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -152,6 +157,14 @@ public sealed class IntegrationTestFixture : IAsyncLifetime
|
||||||
.UseNpgsql(_postgres.GetConnectionString())
|
.UseNpgsql(_postgres.GetConnectionString())
|
||||||
.Options);
|
.Options);
|
||||||
|
|
||||||
|
/// <summary>บริการ storage จริงจาก host ของ API — ใช้ทดสอบกับ MinIO container ตรงๆ (เช่น lifecycle rule)</summary>
|
||||||
|
public WordVersioning.Api.Services.IObjectStorageService CreateStorage() =>
|
||||||
|
_factory.Services.GetRequiredService<WordVersioning.Api.Services.IObjectStorageService>();
|
||||||
|
|
||||||
|
/// <summary>config MinIO ที่ host ใช้จริง (ชื่อ bucket ฯลฯ)</summary>
|
||||||
|
public WordVersioning.Api.Services.MinioOptions MinioConfig =>
|
||||||
|
_factory.Services.GetRequiredService<Microsoft.Extensions.Options.IOptions<WordVersioning.Api.Services.MinioOptions>>().Value;
|
||||||
|
|
||||||
/// <summary>user token จริงผ่าน password grant (client word-versioning-web ของ realm test เปิดให้ตอน test เท่านั้น)
|
/// <summary>user token จริงผ่าน password grant (client word-versioning-web ของ realm test เปิดให้ตอน test เท่านั้น)
|
||||||
/// — token มี aud = word-versioning-web จาก audience mapper ของ realm ตามที่ backend validate
|
/// — token มี aud = word-versioning-web จาก audience mapper ของ realm ตามที่ backend validate
|
||||||
/// (เลี่ยง PKCE dance ของ production flow — ตัว validate ฝั่ง backend คือตัวเดิมทดสอบเต็มรูปแบบ)</summary>
|
/// (เลี่ยง PKCE dance ของ production flow — ตัว validate ฝั่ง backend คือตัวเดิมทดสอบเต็มรูปแบบ)</summary>
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,9 @@ public static class TestDocx
|
||||||
new RunProperties(new Bold()),
|
new RunProperties(new Bold()),
|
||||||
new Text("[ชื่อพนักงาน]")))),
|
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 Paragraph(SdtRun("position",
|
||||||
new Run(
|
new Run(
|
||||||
new RunProperties(new Bold()),
|
new RunProperties(new Bold()),
|
||||||
|
|
@ -83,6 +85,49 @@ public static class TestDocx
|
||||||
return buffer.ToArray();
|
return buffer.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>template แบบที่พบใน template ไทยจริง (เช่น T14): placeholder แตกเป็น [ / ข้อความไทย / ]
|
||||||
|
/// หลาย run โดย run ข้อความไทยถือ complex script flags (w:hint="cs" + w:cs) ต่างจาก run ปีกกา
|
||||||
|
/// — และมี SDT เปล่าที่มี rPr อยู่ใน sdtPr เท่านั้น (formatting ที่ผู้แต่งกำหนดให้เนื้อหา)</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>อ่านข้อความรวมของ SDT ตาม tag (คืน "" เมื่อไม่มี tag นั้นในไฟล์) — ตรวจผลลัพธ์ generate</summary>
|
/// <summary>อ่านข้อความรวมของ SDT ตาม tag (คืน "" เมื่อไม่มี tag นั้นในไฟล์) — ตรวจผลลัพธ์ generate</summary>
|
||||||
public static string ReadSdtText(byte[] docx, string tag)
|
public static string ReadSdtText(byte[] docx, string tag)
|
||||||
{
|
{
|
||||||
|
|
@ -109,7 +154,7 @@ public static class TestDocx
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>อ่านข้อความ+formatting ของแต่ละ run ใน SDT (ตาม tag) — ใช้ตรวจว่า generate
|
/// <summary>อ่านข้อความ+formatting ของแต่ละ run ใน SDT (ตาม tag) — ใช้ตรวจว่า generate
|
||||||
/// รวม multi-run เป็น run เดียวแล้วยังรักษา run properties (bold/italic) ของ run แรกไว้</summary>
|
/// รวม multi-run เหลือ run ที่ถือค่าแล้วยังรักษา run properties (bold/italic) ของ run นั้นไว้ (v9: run ข้อความยาวสุด)</summary>
|
||||||
public static List<(string Text, bool Bold, bool Italic)> ReadSdtRuns(byte[] docx, string tag)
|
public static List<(string Text, bool Bold, bool Italic)> ReadSdtRuns(byte[] docx, string tag)
|
||||||
{
|
{
|
||||||
using var doc = WordprocessingDocument.Open(new MemoryStream(docx), false);
|
using var doc = WordprocessingDocument.Open(new MemoryStream(docx), false);
|
||||||
|
|
@ -140,6 +185,36 @@ public static class TestDocx
|
||||||
?.VTLPWSTR?.Text;
|
?.VTLPWSTR?.Text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>ข้อมูล run หนึ่งตัวใน SDT (ตาม tag): ข้อความรวม + hint=cs ของ rFonts + flag complex script (w:cs)
|
||||||
|
/// + ฟอนต์ ascii — ใช้ตรวจว่าค่าที่แทนที่ได้ formatting ของ run ข้อความไทยจริง (FR-16 v9)</summary>
|
||||||
|
public record SdtRunInfo(string Text, bool CsHint, bool ComplexScript, string? AsciiFont);
|
||||||
|
|
||||||
|
public static List<SdtRunInfo> 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<Tag>())
|
||||||
|
{
|
||||||
|
if (sdtTag.Val?.Value != tag) continue;
|
||||||
|
var sdt = sdtTag.Parent?.Parent;
|
||||||
|
var content = sdt?.ChildElements.FirstOrDefault(c => c.LocalName == "sdtContent");
|
||||||
|
return content?.Descendants<Run>()
|
||||||
|
.Select(run => new SdtRunInfo(
|
||||||
|
string.Concat(run.Descendants<Text>().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(
|
private static SdtRun SdtRun(string tag, params Run[] runs) => new(
|
||||||
new SdtProperties(new Tag { Val = tag }),
|
new SdtProperties(new Tag { Val = tag }),
|
||||||
new SdtContentRun(runs));
|
new SdtContentRun(runs));
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ public record UploadVersionResult(Guid VersionId, int VersionNumber, bool HasPdf
|
||||||
|
|
||||||
public record DownloadLinkResult(string Url, DateTimeOffset ExpiresAt);
|
public record DownloadLinkResult(string Url, DateTimeOffset ExpiresAt);
|
||||||
|
|
||||||
|
/// <summary>response ของ POST generate-draft (FR-16) — presigned URL อายุสั้นของไฟล์ร่าง</summary>
|
||||||
|
public record DraftLinkResult(string Url, DateTimeOffset ExpiresAt);
|
||||||
|
|
||||||
public record GenerateResult(Guid DocumentId, Guid VersionId, int VersionNumber, string DownloadUrl);
|
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);
|
public record VersionItem(Guid VersionId, int VersionNumber, DateTimeOffset CreatedAt, string CreatedByUserId, string ContentHash, JsonElement? Fields, string? Message, bool HasPdf);
|
||||||
|
|
|
||||||
|
|
@ -33,14 +33,17 @@ public sealed class VersionPdfTests(IntegrationTestFixture fixture)
|
||||||
Assert.Equal("application/pdf", download.Content.Headers.ContentType!.MediaType);
|
Assert.Equal("application/pdf", download.Content.Headers.ContentType!.MediaType);
|
||||||
Assert.Equal("คำร้อง.pdf", download.Content.Headers.ContentDisposition!.FileNameStar);
|
Assert.Equal("คำร้อง.pdf", download.Content.Headers.ContentDisposition!.FileNameStar);
|
||||||
var link = await client.GetFromJsonAsync<DownloadLinkResult>($"api/documents/{id}/versions/{result.VersionId}/download-link?format=pdf");
|
var link = await client.GetFromJsonAsync<DownloadLinkResult>($"api/documents/{id}/versions/{result.VersionId}/download-link?format=pdf");
|
||||||
using var browser = new HttpClient();
|
// ชื่อไฟล์จริง ({base}_v{N}.pdf) อยู่ท้าย URL ด้วย (Word/browser ตั้งชื่อตาม URL ไม่ใช่แค่ disposition)
|
||||||
using var signedDownload = await browser.GetAsync(link!.Url);
|
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);
|
Assert.Equal(HttpStatusCode.OK, signedDownload.StatusCode);
|
||||||
Assert.Equal(Pdf, await signedDownload.Content.ReadAsByteArrayAsync());
|
Assert.Equal(Pdf, await signedDownload.Content.ReadAsByteArrayAsync());
|
||||||
Assert.Equal("inline", signedDownload.Content.Headers.ContentDisposition!.DispositionType);
|
Assert.Equal("inline", signedDownload.Content.Headers.ContentDisposition!.DispositionType);
|
||||||
Assert.Equal("คำร้อง_v1.pdf", signedDownload.Content.Headers.ContentDisposition.FileNameStar);
|
Assert.Equal("คำร้อง_v1.pdf", signedDownload.Content.Headers.ContentDisposition.FileNameStar);
|
||||||
var docxLink = await client.GetFromJsonAsync<DownloadLinkResult>($"api/documents/{id}/versions/{result.VersionId}/download-link");
|
var docxLink = await client.GetFromJsonAsync<DownloadLinkResult>($"api/documents/{id}/versions/{result.VersionId}/download-link");
|
||||||
using var signedDocx = await browser.GetAsync(docxLink!.Url);
|
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(HttpStatusCode.OK, signedDocx.StatusCode);
|
||||||
Assert.Equal("attachment", signedDocx.Content.Headers.ContentDisposition!.DispositionType);
|
Assert.Equal("attachment", signedDocx.Content.Headers.ContentDisposition!.DispositionType);
|
||||||
Assert.Equal("คำร้อง_v1.docx", signedDocx.Content.Headers.ContentDisposition.FileNameStar);
|
Assert.Equal("คำร้อง_v1.docx", signedDocx.Content.Headers.ContentDisposition.FileNameStar);
|
||||||
|
|
@ -114,7 +117,7 @@ public sealed class VersionPdfTests(IntegrationTestFixture fixture)
|
||||||
var user = new TestUser();
|
var user = new TestUser();
|
||||||
var controller = new WordVersioning.Api.Controllers.DocumentsController(db, storage,
|
var controller = new WordVersioning.Api.Controllers.DocumentsController(db, storage,
|
||||||
new WordVersioning.Api.Services.HashService(), null!,
|
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.Options.Options.Create(new WordVersioning.Api.Services.MinioOptions()),
|
||||||
Microsoft.Extensions.Logging.Abstractions.NullLogger<WordVersioning.Api.Controllers.DocumentsController>.Instance);
|
Microsoft.Extensions.Logging.Abstractions.NullLogger<WordVersioning.Api.Controllers.DocumentsController>.Instance);
|
||||||
var id = Guid.NewGuid();
|
var id = Guid.NewGuid();
|
||||||
|
|
@ -165,7 +168,7 @@ public sealed class VersionPdfTests(IntegrationTestFixture fixture)
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
public Task<Stream> GetStreamAsync(string bucket, string key, CancellationToken ct = default) => throw new NotSupportedException();
|
public Task<Stream> 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<bool> EnsurePrefixExpiryAsync(string bucket, string prefix, int days, string ruleId, CancellationToken ct = default) => Task.FromResult(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ public class DocumentsController(
|
||||||
IHashService hasher,
|
IHashService hasher,
|
||||||
IDocxGenerationService docxGeneration,
|
IDocxGenerationService docxGeneration,
|
||||||
IFieldSyncService fieldSync,
|
IFieldSyncService fieldSync,
|
||||||
|
IOpenLinkService openLinks,
|
||||||
ICurrentUser currentUser,
|
ICurrentUser currentUser,
|
||||||
IOptions<MinioOptions> minioOptions,
|
IOptions<MinioOptions> minioOptions,
|
||||||
ILogger<DocumentsController> logger) : ControllerBase
|
ILogger<DocumentsController> logger) : ControllerBase
|
||||||
|
|
@ -307,6 +308,13 @@ public class DocumentsController(
|
||||||
template.Id, template.ObjectKey);
|
template.Id, template.ObjectKey);
|
||||||
return StatusCode(StatusCodes.Status502BadGateway, "ไฟล์ template เสียหาย (เปิดด้วย OpenXML ไม่ได้)");
|
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;
|
var now = DateTimeOffset.UtcNow;
|
||||||
using (var hashStream = new MemoryStream(fileBytes))
|
using (var hashStream = new MemoryStream(fileBytes))
|
||||||
|
|
@ -507,9 +515,9 @@ public class DocumentsController(
|
||||||
isPdf ? Path.ChangeExtension(downloadName, ".pdf") : downloadName);
|
isPdf ? Path.ChangeExtension(downloadName, ".pdf") : downloadName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>FR-7/FR-15 — presigned URL with filename; format=pdf selects inline PDF (404 when absent).
|
/// <summary>FR-7/FR-15 — ลิงก์เปิดไฟล์อายุสั้น (HMAC) ที่วิ่งผ่าน https ของ backend; format=pdf = inline PDF (404 เมื่อไม่มี).
|
||||||
/// ออก URL ให้ add-in เปิดในเบราว์เซอร์ของผู้ใช้โดยตรง — webview ของ Word (โดยเฉพาะ WKWebView บน Mac)
|
/// ออก URL ให้ add-in/เว็บเปิดในเบราว์เซอร์หรือ ms-word:ofe ของผู้ใช้ — webview ของ Word ทิ้ง blob download
|
||||||
/// ทิ้ง blob download แบบเงียบๆ จึงต้องให้เบราว์เซอร์จริงจัดการการดาวน์โหลดแทน</summary>
|
/// เงียบๆ และ Word รุ่นใหม่เมิน URL ที่เป็น http จึงไม่ใช้ presigned ของ MinIO ตรงๆ อีก (v9.2)</summary>
|
||||||
[HttpGet("{documentId:guid}/versions/{versionId:guid}/download-link")]
|
[HttpGet("{documentId:guid}/versions/{versionId:guid}/download-link")]
|
||||||
[ProducesResponseType(typeof(VersionDownloadLinkResponse), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(VersionDownloadLinkResponse), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
|
@ -529,18 +537,18 @@ public class DocumentsController(
|
||||||
var objectKey = isPdf ? row.PdfObjectKey : row.ObjectKey;
|
var objectKey = isPdf ? row.PdfObjectKey : row.ObjectKey;
|
||||||
if (objectKey is null)
|
if (objectKey is null)
|
||||||
return NotFound("version นี้ไม่มีสำเนา PDF");
|
return NotFound("version นี้ไม่มีสำเนา PDF");
|
||||||
|
|
||||||
|
// ชื่อไฟล์ตามหลักเดิม — อยู่ท้าย URL (Word/browser ตั้งชื่อตาม) และใน Content-Disposition ตอน stream
|
||||||
var baseName = row.FileName.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)
|
var baseName = row.FileName.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)
|
||||||
? row.FileName[..^5] : row.FileName;
|
? row.FileName[..^5] : row.FileName;
|
||||||
var fileName = $"{baseName}_v{row.VersionNumber}.{(isPdf ? "pdf" : "docx")}";
|
var fileName = $"{baseName}_v{row.VersionNumber}.{(isPdf ? "pdf" : "docx")}";
|
||||||
var disposition = $"{(isPdf ? "inline" : "attachment")}; filename*=UTF-8''{Uri.EscapeDataString(fileName)}";
|
var url = openLinks.CreateLink($"v:{documentId}:{versionId}", isPdf ? "pdf" : "docx", fileName, out var expiresAt);
|
||||||
var expirySeconds = Math.Clamp(minioOptions.Value.PresignedExpirySeconds, 30, 600);
|
|
||||||
var url = storage.GetPresignedGetUrl(minioOptions.Value.VersionsBucket, objectKey, TimeSpan.FromSeconds(expirySeconds), disposition);
|
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Version download link issued documentId={DocumentId} versionId={VersionId} userId={UserId} expirySeconds={ExpirySeconds}",
|
"Version download link issued documentId={DocumentId} versionId={VersionId} userId={UserId} expiresAt={ExpiresAt}",
|
||||||
documentId, versionId, currentUser.UserId, expirySeconds);
|
documentId, versionId, currentUser.UserId, expiresAt);
|
||||||
|
|
||||||
return Ok(new VersionDownloadLinkResponse(url, DateTimeOffset.UtcNow + TimeSpan.FromSeconds(expirySeconds)));
|
return Ok(new VersionDownloadLinkResponse(url, expiresAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>ถอนการเปลี่ยน DocumentFieldValue ที่ stage ไว้กลับเป็นค่าก่อน request
|
/// <summary>ถอนการเปลี่ยน DocumentFieldValue ที่ stage ไว้กลับเป็นค่าก่อน request
|
||||||
|
|
|
||||||
104
backend/WordVersioning.Api/Controllers/OpenController.cs
Normal file
104
backend/WordVersioning.Api/Controllers/OpenController.cs
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>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 ทั้งหมด</summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/open")]
|
||||||
|
public class OpenController(
|
||||||
|
AppDbContext db,
|
||||||
|
IObjectStorageService storage,
|
||||||
|
IOpenLinkService openLinks,
|
||||||
|
IOptions<MinioOptions> minioOptions,
|
||||||
|
ILogger<OpenController> logger) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>เปิดไฟล์ของ version: /api/open/{t}/{e}/v/{documentId}/{versionId}/{fileName}
|
||||||
|
/// (fileName = ชื่อจริงตามหลักเดิม เช่น คำร้อง_v1.docx — ใช้ตรวจ format + ให้ Word/browser ตั้งชื่อตาม)</summary>
|
||||||
|
[HttpGet("{t}/{e}/v/{documentId:guid}/{versionId:guid}/{fileName}")]
|
||||||
|
[AllowAnonymous] // การรับรองคือลายเซ็นใน URL (วิ่งเข้า Word/เบราว์เซอร์ที่แนบ header ไม่ได้)
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>เปิดไฟล์ร่าง (FR-16): /api/open/{t}/{e}/d/{templateId}/{stamp}/{fileName}
|
||||||
|
/// (stamp = yyyyMMddHHmmss_{guid} ของ object key; fileName = ชื่อจริงจากชื่อ template + timestamp)</summary>
|
||||||
|
[HttpGet("{t}/{e}/d/{templateId:guid}/{stamp}/{fileName}")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> OpenDraft(
|
||||||
|
string t, string e, Guid templateId, string stamp, string fileName, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var objectKey = $"drafts/{templateId}/{stamp}.docx";
|
||||||
|
if (!openLinks.Validate($"d:{objectKey}", "docx", t, e))
|
||||||
|
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 = 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, displayName, isPdf: false, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IActionResult Expired() =>
|
||||||
|
NotFound("ลิงก์หมดอายุหรือไม่ถูกต้อง — กรุณากดเปิดไฟล์ใหม่อีกครั้ง");
|
||||||
|
|
||||||
|
private async Task<IActionResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ public class TemplatesController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
IObjectStorageService storage,
|
IObjectStorageService storage,
|
||||||
IDocxGenerationService docxGeneration,
|
IDocxGenerationService docxGeneration,
|
||||||
|
IOpenLinkService openLinks,
|
||||||
ICurrentUser currentUser,
|
ICurrentUser currentUser,
|
||||||
IOptions<MinioOptions> minioOptions,
|
IOptions<MinioOptions> minioOptions,
|
||||||
ILogger<TemplatesController> logger) : ControllerBase
|
ILogger<TemplatesController> logger) : ControllerBase
|
||||||
|
|
@ -305,11 +306,12 @@ public class TemplatesController(
|
||||||
return Ok(new ReplaceTemplateFileResponse(template.Id, FieldDtos(template), addedTags, removedTags));
|
return Ok(new ReplaceTemplateFileResponse(template.Id, FieldDtos(template), addedTags, removedTags));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>FR-16 — ไฟล์ร่าง (draft): generate docx จาก template แบบ stateless — ไม่สร้าง
|
/// <summary>FR-16 — ไฟล์ร่าง (draft): generate docx จาก template โดยไม่สร้าง
|
||||||
/// Document/DocumentVersion/DocumentFieldValue และไม่เขียน MinIO; คืนไฟล์ทันที
|
/// Document/DocumentVersion/DocumentFieldValue ใน DB — เก็บไฟล์ชั่วคราวใน bucket versions ใต้ prefix
|
||||||
/// (ผู้ใช้เปิดใน Word แล้วกด Start Versioning เพื่อเริ่ม track เป็น v1 พร้อมผูกกลับมาที่ template นี้)</summary>
|
/// `drafts/` (หมดอายุรายวันตาม lifecycle) แล้วคืน presigned URL อายุสั้นให้เว็บเปิดใน Word
|
||||||
|
/// ผ่าน ms-word:ofe พร้อมลิงก์ดาวน์โหลดสำรอง (v9 — ตอนแรกคืนไฟล์ตรงๆ แต่ผู้ใช้ต้องเปิด Word เอง)</summary>
|
||||||
[HttpPost("{id:guid}/generate-draft")]
|
[HttpPost("{id:guid}/generate-draft")]
|
||||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(GenerateDraftResponse), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
[ProducesResponseType(StatusCodes.Status502BadGateway)]
|
[ProducesResponseType(StatusCodes.Status502BadGateway)]
|
||||||
|
|
@ -365,12 +367,39 @@ public class TemplatesController(
|
||||||
template.Id, template.ObjectKey);
|
template.Id, template.ObjectKey);
|
||||||
return StatusCode(StatusCodes.Status502BadGateway, "ไฟล์ template เสียหาย (เปิดด้วย OpenXML ไม่ได้)");
|
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 หรือค่าที่กรอกมีปัญหา)");
|
||||||
|
}
|
||||||
|
|
||||||
logger.LogInformation("Draft generated templateId={TemplateId} userId={UserId} bytes={Size}",
|
// เก็บชั่วคราวใต้ drafts/ — lifecycle ลบให้เอง (Program.cs ตั้ง rule ตอน startup)
|
||||||
template.Id, currentUser.UserId, fileBytes.Length);
|
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,
|
// ลิงก์เปิดอายุสั้นผ่าน https ของ backend (HMAC) — ชื่อไฟล์จริง ({template}_{timestamp}.docx) อยู่ท้าย URL
|
||||||
DocxGenerationService.BuildGeneratedFileName(template.Name, DateTimeOffset.UtcNow));
|
// (Word รุ่นใหม่เมิน URL ที่เป็น http เงียบๆ จึงไม่ใช้ presigned ของ MinIO ตรงๆ — v9.2)
|
||||||
|
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);
|
||||||
|
|
||||||
|
return Ok(new GenerateDraftResponse(url, expiresAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>deactivate (soft-delete) — document เก่าที่อ้างอยู่ยังใช้ได้ต่อ แต่สร้างเอกสารใหม่ไม่ได้</summary>
|
/// <summary>deactivate (soft-delete) — document เก่าที่อ้างอยู่ยังใช้ได้ต่อ แต่สร้างเอกสารใหม่ไม่ได้</summary>
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,10 @@ public class GenerateDraftRequest
|
||||||
public JsonElement Values { get; set; }
|
public JsonElement Values { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>response ของ POST /api/templates/{id}/generate-draft (FR-16) — presigned URL อายุสั้น
|
||||||
|
/// ให้เว็บเปิดใน Word ผ่าน ms-word:ofe พร้อมลิงก์ดาวน์โหลดสำรอง (object ใต้ drafts/ หมดอายุรายวันตาม lifecycle)</summary>
|
||||||
|
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 TemplateFieldDto(Guid Id, string Key, string Label, string Type, string Editor, bool IsRequired, int SortOrder);
|
||||||
|
|
||||||
public record TemplateListItem(
|
public record TemplateListItem(
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ using System.Security.Cryptography;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.OpenApi.Models;
|
using Microsoft.OpenApi.Models;
|
||||||
using WordVersioning.Api.Data;
|
using WordVersioning.Api.Data;
|
||||||
using WordVersioning.Api.Services;
|
using WordVersioning.Api.Services;
|
||||||
|
|
@ -39,10 +41,17 @@ builder.Services.AddDbContext<AppDbContext>(options =>
|
||||||
|
|
||||||
// MinIO / hash / docx generation / field sync / current user
|
// MinIO / hash / docx generation / field sync / current user
|
||||||
builder.Services.Configure<MinioOptions>(builder.Configuration.GetSection(MinioOptions.SectionName));
|
builder.Services.Configure<MinioOptions>(builder.Configuration.GetSection(MinioOptions.SectionName));
|
||||||
|
// OpenLinks ต้องตั้งครบตั้งแต่สตาร์ท — Secret ว่าง = ลายเซ็นปลอมได้, BaseUrl ไม่ใช่ https = Word เมิน (fail ดังกว่าเงียบ)
|
||||||
|
builder.Services.AddOptions<OpenLinkOptions>()
|
||||||
|
.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<IObjectStorageService, ObjectStorageService>();
|
builder.Services.AddSingleton<IObjectStorageService, ObjectStorageService>();
|
||||||
builder.Services.AddSingleton<IHashService, HashService>();
|
builder.Services.AddSingleton<IHashService, HashService>();
|
||||||
builder.Services.AddSingleton<IDocxGenerationService, DocxGenerationService>();
|
builder.Services.AddSingleton<IDocxGenerationService, DocxGenerationService>();
|
||||||
builder.Services.AddScoped<IFieldSyncService, FieldSyncService>();
|
builder.Services.AddScoped<IFieldSyncService, FieldSyncService>();
|
||||||
|
builder.Services.AddScoped<IOpenLinkService, OpenLinkService>();
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
builder.Services.AddScoped<ICurrentUser, CurrentUser>();
|
builder.Services.AddScoped<ICurrentUser, CurrentUser>();
|
||||||
|
|
||||||
|
|
@ -65,14 +74,11 @@ builder.Services
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
|
|
||||||
// CORS — add-in task pane (https://localhost:3000) + web portal (http://localhost:5173) เท่านั้น
|
// 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";
|
const string corsPolicy = "frontends";
|
||||||
builder.Services.AddCors(options => options.AddPolicy(corsPolicy, policy => policy
|
builder.Services.AddCors(options => options.AddPolicy(corsPolicy, policy => policy
|
||||||
.WithOrigins(builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [])
|
.WithOrigins(builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [])
|
||||||
.AllowAnyHeader()
|
.AllowAnyHeader()
|
||||||
.AllowAnyMethod()
|
.AllowAnyMethod()));
|
||||||
.WithExposedHeaders("Content-Disposition")));
|
|
||||||
|
|
||||||
// --- dev HTTPS -------------------------------------------------------------
|
// --- dev HTTPS -------------------------------------------------------------
|
||||||
// Word webview (WKWebView บน Mac) บล็อก fetch จากหน้า https ไป http แบบเงียบๆ ("Load failed")
|
// Word webview (WKWebView บน Mac) บล็อก fetch จากหน้า https ไป http แบบเงียบๆ ("Load failed")
|
||||||
|
|
@ -102,6 +108,29 @@ if (devHttpsEnabled)
|
||||||
|
|
||||||
var app = builder.Build();
|
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<AppDbContext>();
|
||||||
|
await db.Database.MigrateAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<IObjectStorageService>();
|
||||||
|
var minioConfig = scope.ServiceProvider.GetRequiredService<IOptions<MinioOptions>>().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())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.UseSwagger();
|
app.UseSwagger();
|
||||||
|
|
|
||||||
|
|
@ -108,8 +108,52 @@ public class DocxGenerationService : IDocxGenerationService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>แทนที่ข้อความใน SDT ที่ <b>run level</b> — เก็บ Run เดิมไว้ตัวแรก (formatting ตาม template)
|
/// <summary>ตัดอักขระที่ XML 1.0 ห้าม (เช่น vertical tab 0x0B จากการ paste ข้อความ) ออกจากค่าก่อนเขียนลงไฟล์ —
|
||||||
/// แล้วลบ Text ที่เหลือ ไม่ทับทั้ง paragraph (spec ส่วน 9 เพิ่มเติม v2)</summary>
|
/// XmlWriter โยน ArgumentException ตอน save ทั้ง package ถ้าปล่อยไว้ (XML รับได้เฉพาะ #x9/#xA/#xD/
|
||||||
|
/// [#x20-#xD7FF]/[#xE000-#xFFFD]/คู่ surrogate ครบ) — ค่าที่ตัดไปเป็นอักขระที่มองไม่เห็นอยู่แล้ว</summary>
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>แทนที่ข้อความใน SDT ที่ <b>run level</b> — เขียนค่าที่ 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 นี้ ค่ายาวๆ จะจัดหน้าผิดทั้งบรรทัด)</summary>
|
||||||
private static void ReplaceSdtContent(Tag tag, string value)
|
private static void ReplaceSdtContent(Tag tag, string value)
|
||||||
{
|
{
|
||||||
var sdtPr = tag.Parent; // w:sdtPr
|
var sdtPr = tag.Parent; // w:sdtPr
|
||||||
|
|
@ -122,7 +166,11 @@ public class DocxGenerationService : IDocxGenerationService
|
||||||
if (texts.Count == 0)
|
if (texts.Count == 0)
|
||||||
{
|
{
|
||||||
// SDT ไม่มีข้อความเลย (เช่น ที่ว่างมา) — สร้าง Run ใหม่ใน paragraph แรก (block) หรือใน content ตรงๆ (run)
|
// SDT ไม่มีข้อความเลย (เช่น ที่ว่างมา) — สร้าง Run ใหม่ใน paragraph แรก (block) หรือใน content ตรงๆ (run)
|
||||||
var run = new Run(new Text(value) { Space = SpaceProcessingModeValues.Preserve });
|
// โดยสืบทอด rPr จาก sdtPr ถ้ามี (คือ formatting ที่ผู้แต่ง template กำหนดให้เนื้อหาของ control นี้ —
|
||||||
|
// เช่นเดียวกับที่ Word ใช้ตอนผู้ใช้พิมพ์ลง control ไม่ใช่ปล่อยเป็น default font)
|
||||||
|
var run = new Run(new Text(SanitizeForXml(value)) { Space = SpaceProcessingModeValues.Preserve });
|
||||||
|
if (sdtPr?.Elements<RunProperties>().FirstOrDefault() is { } sdtRunProperties)
|
||||||
|
run.RunProperties = (RunProperties)sdtRunProperties.CloneNode(true);
|
||||||
if (content is SdtContentBlock)
|
if (content is SdtContentBlock)
|
||||||
{
|
{
|
||||||
var paragraph = content.ChildElements.OfType<Paragraph>().FirstOrDefault();
|
var paragraph = content.ChildElements.OfType<Paragraph>().FirstOrDefault();
|
||||||
|
|
@ -140,12 +188,16 @@ public class DocxGenerationService : IDocxGenerationService
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// เขียนที่ Text แรก (อยู่ใน Run แรก — สืบทอด rPr ของ run นั้น) แล้วเก็บ space ไว้กรณีค่ามีช่องว่างหัว/ท้าย
|
// เลือก Run ผู้บริจาค formatting = run ที่มีข้อความยาวสุด (tie → run แรก) — หลีกเลี่ยง run ปีกกา/สัญลักษณ์
|
||||||
texts[0].Text = value;
|
// ที่ formatting มักไม่ครบ (เช่น ไม่มี complex script flags)
|
||||||
texts[0].Space = SpaceProcessingModeValues.Preserve;
|
var donorText = texts.OrderByDescending(t => t.Text?.Length ?? 0).First();
|
||||||
|
|
||||||
// Text ที่เหลือ (จากการ split run เดิม เช่น rsid/formatting ปนกัน) ลบทิ้ง — field = ค่าเดียว
|
// เขียนที่ Text ของ donor แล้วเก็บ space ไว้กรณีค่ามีช่องว่างหัว/ท้าย
|
||||||
foreach (var text in texts.Skip(1))
|
donorText.Text = SanitizeForXml(value);
|
||||||
|
donorText.Space = SpaceProcessingModeValues.Preserve;
|
||||||
|
|
||||||
|
// Text อื่นทั้งหมด (รวม donor ถ้ามีหลาย Text ใน run เดียวกัน ไม่เกิด) ลบทิ้ง — field = ค่าเดียว
|
||||||
|
foreach (var text in texts.Where(t => !ReferenceEquals(t, donorText)))
|
||||||
{
|
{
|
||||||
var run = text.Parent;
|
var run = text.Parent;
|
||||||
text.Remove();
|
text.Remove();
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ public class MinioOptions
|
||||||
|
|
||||||
public string TemplatesBucket { get; set; } = "docx-templates";
|
public string TemplatesBucket { get; set; } = "docx-templates";
|
||||||
|
|
||||||
/// <summary>อายุของ presigned download URL ที่ออกให้เบราว์เซอร์ (วินาที) — สั้นๆ ตาม spec</summary>
|
/// <summary>FR-16 — จำนวนวันที่ไฟล์ร่าง (object ใต้ prefix `drafts/` ของ VersionsBucket) ถูกลบโดย
|
||||||
public int PresignedExpirySeconds { get; set; } = 120;
|
/// lifecycle rule (ผู้ใช้เปิดผ่าน presigned URL ในไม่กี่นาทีแรก หลังจากนั้นเป็นขยะ)</summary>
|
||||||
|
public int DraftRetentionDays { get; set; } = 1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,11 @@ public interface IObjectStorageService
|
||||||
/// <summary>ดึง object กลับมาเป็น stream (proxy ต่อให้ client) — stream นี้ dispose แล้วจะปิด S3 response ให้ด้วย</summary>
|
/// <summary>ดึง object กลับมาเป็น stream (proxy ต่อให้ client) — stream นี้ dispose แล้วจะปิด S3 response ให้ด้วย</summary>
|
||||||
Task<Stream> GetStreamAsync(string bucket, string key, CancellationToken ct = default);
|
Task<Stream> GetStreamAsync(string bucket, string key, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>สร้าง presigned GET URL อายุสั้น — ให้เปิดในเบราว์เซอร์ของผู้ใช้โดยตรง
|
/// <summary>FR-16 — ตั้ง lifecycle rule ให้ object ใต้ prefix หนึ่งหมดอายุเองตามจำนวนวัน
|
||||||
/// (webview ของ Word ทิ้ง blob download แบบเงียบๆ — spec อนุญาต presigned URL อายุสั้นสำหรับดาวน์โหลด)</summary>
|
/// (idempotent ตาม rule id และ sync ค่า days ใหม่ถ้า config เปลี่ยนหลัง boot แรก — days < 1 คือ throw)
|
||||||
string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry, string? contentDisposition = null);
|
/// ใช้กับไฟล์ร่างที่เก็บชั่วคราวใน bucket versions ใต้ `drafts/`
|
||||||
|
/// คืน true เมื่อเขียน config ใหม่, false เมื่อ rule ถูกต้องอยู่แล้ว (ใช้ assert ใน test)</summary>
|
||||||
|
Task<bool> EnsurePrefixExpiryAsync(string bucket, string prefix, int days, string ruleId, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Wrapper เรียก MinIO ผ่าน AWSSDK.S3 (S3-compatible API)</summary>
|
/// <summary>Wrapper เรียก MinIO ผ่าน AWSSDK.S3 (S3-compatible API)</summary>
|
||||||
|
|
@ -76,27 +78,52 @@ public class ObjectStorageService : IObjectStorageService
|
||||||
return new S3ResponseStream(response);
|
return new S3ResponseStream(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry, string? contentDisposition = null)
|
public async Task<bool> EnsurePrefixExpiryAsync(string bucket, string prefix, int days, string ruleId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var request = new GetPreSignedUrlRequest
|
ArgumentOutOfRangeException.ThrowIfLessThan(days, 1);
|
||||||
|
|
||||||
|
var rules = new List<LifecycleRule>();
|
||||||
|
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,
|
BucketName = bucket,
|
||||||
Key = key,
|
Configuration = new LifecycleConfiguration { Rules = rules },
|
||||||
Verb = HttpVerb.GET,
|
}, ct);
|
||||||
Expires = DateTime.UtcNow + expiry,
|
_logger.LogInformation("Lifecycle rule {RuleId} ตั้งแล้ว: {Bucket}/{Prefix} expire={Days}d", ruleId, bucket, prefix, days);
|
||||||
};
|
return true;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Stream ที่ครอบ GetObjectResponse — dispose stream = dispose response ทั้งอัน
|
/// <summary>Stream ที่ครอบ GetObjectResponse — dispose stream = dispose response ทั้งอัน
|
||||||
|
|
|
||||||
18
backend/WordVersioning.Api/Services/OpenLinkOptions.cs
Normal file
18
backend/WordVersioning.Api/Services/OpenLinkOptions.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
namespace WordVersioning.Api.Services;
|
||||||
|
|
||||||
|
/// <summary>config ของ "open link" — ลิงก์เปิดไฟล์อายุสั้นที่ออกโดย backend เอง
|
||||||
|
/// bind จาก section "OpenLinks" ใน appsettings/env (rule: ห้าม hardcode)</summary>
|
||||||
|
public class OpenLinkOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "OpenLinks";
|
||||||
|
|
||||||
|
/// <summary>secret ใช้เซ็น HMAC ของ token — deploy จริงต้องตั้งค่าเป็นค่าลับจริง (env: OpenLinks__Secret)</summary>
|
||||||
|
public string Secret { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>base URL ที่ใช้ประกอบเป็นลิงก์ (ต้องเป็น https — Word รุ่นใหม่เมิน URL ที่เป็น http เงียบๆ)
|
||||||
|
/// เช่น dev = https://localhost:7232 · prod = https://api.example.com</summary>
|
||||||
|
public string BaseUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>อายุของลิงก์ (วินาที) — สั้นๆ ตามนโยบายเดียวกับ presigned เดิม</summary>
|
||||||
|
public int ExpirySeconds { get; set; } = 120;
|
||||||
|
}
|
||||||
84
backend/WordVersioning.Api/Services/OpenLinkService.cs
Normal file
84
backend/WordVersioning.Api/Services/OpenLinkService.cs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace WordVersioning.Api.Services;
|
||||||
|
|
||||||
|
public interface IOpenLinkService
|
||||||
|
{
|
||||||
|
/// <summary>ออก URL เปิดไฟล์อายุสั้นที่วิ่งผ่าน backend (ตาม BaseUrl ที่ตั้ง ซึ่งต้องเป็น https) —
|
||||||
|
/// Word for Mac รุ่นใหม่ (16.101+, ก.ย. 2026) ยอมรับเฉพาะ URL ที่เป็น https และ "ไม่มี query string"
|
||||||
|
/// (มี query = เมินเงียบๆ ไม่ยิง request เลย) จึงฝังทุกอย่างลง path และปิดท้ายด้วยชื่อไฟล์ .docx/.pdf</summary>
|
||||||
|
/// <param name="resource">รหัสระบุไฟล์: "v:{documentId}:{versionId}" หรือ "d:drafts/{templateId}/{stamp}.docx" (ไฟล์ร่าง)</param>
|
||||||
|
/// <param name="format">docx | pdf — อยู่ในชื่อไฟล์ท้าย path และผูกกับลายเซ็น</param>
|
||||||
|
/// <param name="fileName">ชื่อไฟล์ที่ใช้ปิดท้าย URL (ตามหลักเดิม เช่น คำร้อง_v1.docx) —
|
||||||
|
/// Word ตั้งชื่อ copy ตาม URL ตอนเปิด และ browser ใช้เป็น fallback ตอนดาวน์โหลด จึงต้องเป็นชื่อจริง ไม่ใช่ "file"</param>
|
||||||
|
/// <param name="expiresAt">เวลาหมดอายุ (UTC) ของลิงก์ที่ได้</param>
|
||||||
|
string CreateLink(string resource, string format, string fileName, out DateTimeOffset expiresAt);
|
||||||
|
|
||||||
|
/// <summary>validate token ของลิงก์ — true = ผ่าน (ยังไม่หมดอายุ และ HMAC ตรง resource+format)</summary>
|
||||||
|
bool Validate(string resource, string format, string token, string expiresUnix);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ลิงก์เปิดไฟล์แบบเซ็น HMAC อายุสั้น (แนวคิดเดียวกับ presigned URL ของ S3 แต่ออกเอง
|
||||||
|
/// เพื่อคุม scheme/host ให้เป็น https ของ backend) — payload คือ "resource|format|expiry(unix)"
|
||||||
|
/// เซ็นด้วย HMACSHA256 ของ OpenLinks:Secret แบบ base64url
|
||||||
|
///
|
||||||
|
/// รูป 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})</summary>
|
||||||
|
public class OpenLinkService : IOpenLinkService
|
||||||
|
{
|
||||||
|
private readonly OpenLinkOptions _options;
|
||||||
|
|
||||||
|
public OpenLinkService(IOptions<OpenLinkOptions> options)
|
||||||
|
{
|
||||||
|
_options = options.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
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('/');
|
||||||
|
string target;
|
||||||
|
if (resource.StartsWith("v:", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var parts = resource.Split(':');
|
||||||
|
target = $"v/{parts[1]}/{parts[2]}";
|
||||||
|
}
|
||||||
|
else if (resource.StartsWith("d:drafts/", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
// 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}/{Uri.EscapeDataString(fileName)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,5 +22,12 @@
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"HttpPort": 5075,
|
"HttpPort": 5075,
|
||||||
"HttpsPort": 7232
|
"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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,5 +27,12 @@
|
||||||
|
|
||||||
"Cors": {
|
"Cors": {
|
||||||
"AllowedOrigins": [ "https://localhost:3000", "http://localhost:5173" ]
|
"AllowedOrigins": [ "https://localhost:3000", "http://localhost:5173" ]
|
||||||
|
},
|
||||||
|
|
||||||
|
"OpenLinks": {
|
||||||
|
"//": "ลิงก์เปิดไฟล์อายุสั้น (HMAC) — prod ต้องตั้ง OpenLinks__Secret และ OpenLinks__BaseUrl (https) ผ่าน env",
|
||||||
|
"Secret": "",
|
||||||
|
"BaseUrl": "",
|
||||||
|
"ExpirySeconds": 120
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,8 @@ services:
|
||||||
# container listen 39000/39001 เท่ากับ host ตรงๆ (ไม่ map ข้าม port) — map ข้ามแล้ว
|
# container listen 39000/39001 เท่ากับ host ตรงๆ (ไม่ map ข้าม port) — map ข้ามแล้ว
|
||||||
# console จะ redirect/-เชื่อม server ผิด port (login error / เด้งไป :9001 ตาย)
|
# console จะ redirect/-เชื่อม server ผิด port (login error / เด้งไป :9001 ตาย)
|
||||||
minio:
|
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"
|
command: server /data --address ":39000" --console-address ":39001"
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||||
|
|
@ -52,7 +53,8 @@ services:
|
||||||
# สร้าง bucket docx-versions + docx-templates แบบ private (anonymous = none)
|
# สร้าง bucket docx-versions + docx-templates แบบ private (anonymous = none)
|
||||||
# idempotent — รันซ้ำได้ด้วย --ignore-existing
|
# idempotent — รันซ้ำได้ด้วย --ignore-existing
|
||||||
minio-init:
|
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:
|
depends_on:
|
||||||
minio:
|
minio:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
@ -91,7 +93,8 @@ services:
|
||||||
# 8080 (http) เปิดไว้เฉพาะใน network ของ compose ให้ service `api` (profile) ใช้ภายใน
|
# 8080 (http) เปิดไว้เฉพาะใน network ของ compose ให้ service `api` (profile) ใช้ภายใน
|
||||||
volumes:
|
volumes:
|
||||||
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
|
- ./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) -----
|
# --- Backend API (ยังไม่เปิดใช้ — รอ Phase 2 สร้าง Dockerfile) -----
|
||||||
# รันฝั่ง host ด้วย `dotnet run` ได้เลย โดยชี้ config ไปที่ localhost:5432 / localhost:39000
|
# รันฝั่ง host ด้วย `dotnet run` ได้เลย โดยชี้ config ไปที่ localhost:5432 / localhost:39000
|
||||||
|
|
|
||||||
|
|
@ -136,14 +136,17 @@
|
||||||
|
|
||||||
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 ตามรายการด้านบน.
|
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 จากไฟล์จริง) |
|
| 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) |
|
| 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` |
|
| Start Versioning ของไฟล์ร่าง: upload + templateId → **v1** + Document ผูก template + field sync ครบ + snapshot แบบมีนิยาม | `TemplateLinkingTests.Upload_WithTemplateId_OnNewDocument_CreatesLinkedDocV1WithFieldSync` |
|
||||||
| upload ถัดไปไม่ส่ง templateId → v2, TemplateId คงเดิม | test เดียวกัน (upload รอบสอง) |
|
| upload ถัดไปไม่ส่ง templateId → v2, TemplateId คงเดิม | test เดียวกัน (upload รอบสอง) |
|
||||||
|
|
@ -152,15 +155,43 @@ QA reviewer: **PASS**, 2 rounds, 3 findings fixed (build compatibility, pending
|
||||||
| document เดิม (standalone) ส่ง templateId มา → เฉย | `Upload_ExistingDocument_IgnoresTemplateId` |
|
| document เดิม (standalone) ส่ง templateId มา → เฉย | `Upload_ExistingDocument_IgnoresTemplateId` |
|
||||||
| endpoint ใหม่ต้อง [Authorize] | `AuthTests` เพิ่ม `POST api/templates/{0}/generate-draft` |
|
| 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) |
|
| 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.2.1): backend **89 ผ่าน**, add-in **66 ผ่าน**, web **60 ผ่าน** + production build ผ่าน
|
||||||
|
|
||||||
### ค้างยืนยันด้วยมือ (ต้องมี Word จริง)
|
### ค้างยืนยันด้วยมือ (ต้องมี 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
|
||||||
|
- [ ] 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
|
- [ ] Word for Windows / Word on the web — ตามข้อค้างเดิมตั้งแต่ AC-1
|
||||||
|
|
||||||
รายการเต็ม: [แผน FR-16](plans/fr16-draft-generation-plan.md).
|
รายการเต็ม: [แผน FR-16](plans/fr16-draft-generation-plan.md).
|
||||||
|
|
||||||
|
### การทดสอบ `ms-word:ofe` บนเครื่องจริง (Mac, Word 16.101.25092124 — 2026-09-10)
|
||||||
|
|
||||||
|
| ทดสอบ | ผล |
|
||||||
|
|---|---|
|
||||||
|
| `ms-word:ofe|u|<http://localhost MinIO presigned>` | ❌ Word เด้งหน้าเลือกไฟล์ (ไม่ยิง request) |
|
||||||
|
| `ms-word:ofe|u|<https สั้น ไม่มี query>` | ❌ เหมือนกัน (เคยเปิดได้ 1 ครั้ง — ไม่ deterministic) |
|
||||||
|
| `ms-word:ofe|u|<https มี query สั้น/ยาว>` | ❌ 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) |
|
||||||
|
|
||||||
|
→ ข้อสรุปตอนนั้น (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 จริงตามรายการด้านบน.
|
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)
|
||||||
|
|
||||||
|
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 ผู้ใช้จริง)
|
||||||
|
|
||||||
|
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: ตรวจซ้ำหลังแก้ — ผ่าน
|
||||||
|
|
|
||||||
|
|
@ -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
|
## 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
|
## 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 ในปัจจุบัน)
|
- stamp SavedByAddIn + DocumentTrackingId ตั้งแต่ตอนสร้างไฟล์ร่าง: add-in จะมองเป็น tracked document แต่ backend ไม่มี record → history ติด 404 ถาวร (ไม่มี 404-handling ในปัจจุบัน)
|
||||||
- ปุ่ม "เปิดใน Word" สำหรับไฟล์ร่างแบบ flow v0 (`ms-word:ofe|u|<url>`): ไม่มี URL เพราะไม่มี object ใน storage — ต้องเสีย statelessness ไปเก็บไฟล์ชั่วคราวพร้อม TTL
|
|
||||||
|
|
||||||
## Consequences
|
## 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 อายุสั้นอยู่แล้ว การเปิดซ้ำหลังผ่านไปนานต้องสร้างไฟล์ร่างใหม่)
|
||||||
|
|
|
||||||
|
|
@ -640,9 +640,12 @@ 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, ศัพท์ "ไฟล์ร่าง")
|
รายละเอียดเต็ม: [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] 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] 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] Tests/build (backend 82 / add-in 66 / web 59 + build ทั้ง add-in/web)
|
||||||
- [x] QA reviewer PASS (2 rounds, 2026-09-10)
|
- [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 85 / web 59) + QA reviewer PASS (2 rounds, 2026-09-10)
|
||||||
|
- [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
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
# Spec: Word Versioning Add-in (Office.js Cross-Platform + .NET Backend)
|
# 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
|
> ฉบับแก้ไข 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 พร้อมดาวน์โหลดสำรอง
|
> ฉบับแก้ไข 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
|
> ฉบับแก้ไข v6 (2026-08-22): เพิ่ม FR-14 — editor ต่อ field (รูปแบบช่องกรอกในฟอร์มสร้างเอกสาร): `text` เลือก `textbox`/`textarea` ได้, `number`/`date` ใช้ control ตาม type — presentation metadata เท่านั้น ค่า field ยังเป็น string ทั้ง flow
|
||||||
|
|
@ -181,13 +185,13 @@ GET /api/documents/{documentId}/versions
|
||||||
- คืน list ของ version เรียงจากใหม่ไปเก่า พร้อม `hasPdf` ระบุว่ามีสำเนา PDF
|
- คืน list ของ version เรียงจากใหม่ไปเก่า พร้อม `hasPdf` ระบุว่ามีสำเนา PDF
|
||||||
|
|
||||||
### FR-7: ดาวน์โหลด version ที่ระบุ
|
### 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 # 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` — 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}/{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)
|
### FR-8: Auth (Keycloak / OpenID Connect)
|
||||||
|
|
||||||
|
|
@ -290,16 +294,17 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
|
||||||
- เก็บ PDF ใน bucket docx-versions, sibling key `{documentId}/v{N:D4}_{clientVersionId}.pdf`; `PdfObjectKey` nullable
|
- เก็บ 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 ใหม่
|
- 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`
|
- upload/history response มี `hasPdf`; ปุ่ม PDF แสดงเฉพาะเมื่อมี PDF; add-in เปิด presigned URL ผ่าน `openBrowserWindow`
|
||||||
- เว็บ history ทั้ง desktop/mobile เปิด docx ผ่าน `ms-word:ofe|u|<presigned-url>` (ไม่ encode URL ทั้งก้อนซ้ำตาม Microsoft Office URI Schemes); PDF แท็บใหม่; มีลิงก์ดาวน์โหลดสำรองและ heuristic blur/visibilitychange ~1.5s ซึ่งไม่ใช่หลักฐานว่า Word เปิดสำเร็จ
|
- เว็บ history ทั้ง desktop/mobile เปิด docx แบบ **download-first** (v9.2): กดปุ่ม → **ดาวน์โหลดไฟล์ทันทีทุกครั้ง** (ลิงก์เปิดของ backend, disposition attachment) + ยิง `ms-word:ofe|u|<url>` ควบคู่ให้ 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 จริง
|
- หน้า generate success เปิด v0 ใน Word พร้อมลิงก์ดาวน์โหลดสำรอง; PDF fidelity และ tracked changes ต้องยืนยันบน Word จริง
|
||||||
|
|
||||||
### FR-16: ไฟล์ร่าง (draft) — สร้าง .docx จาก template โดยไม่สร้าง Document (เพิ่มเติม v8)
|
### FR-16: ไฟล์ร่าง (draft) — สร้าง .docx จาก template โดยไม่สร้าง Document (เพิ่มเติม v8 · ปรับ v9)
|
||||||
- **`POST /api/templates/{templateId}/generate-draft`** body `{ values }` — **stateless**: ไม่สร้าง `Document`/`DocumentVersion`/`DocumentFieldValue` และไม่เขียน MinIO; คืนไฟล์ .docx ทันที (200 + attachment, ชื่อ `ชื่อtemplate_yyyyMMdd-HHmmss.docx` — ไม่ใช่ 201 เพราะไม่มีอะไรถูกสร้าง)
|
- **`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)
|
- 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")
|
- 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)
|
- **ผูก 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
|
- 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: ปุ่ม "สร้างไฟล์ร่าง" ที่สองบนฟอร์มสร้างเอกสารเดิม → **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)
|
## 7. Data Model (Backend)
|
||||||
|
|
||||||
|
|
@ -429,7 +434,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 จริง
|
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 ควรทำ
|
## 11. งานที่ Claude Code ควรทำ
|
||||||
|
|
||||||
|
|
@ -476,6 +481,18 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi
|
||||||
- Web: เปิด Word/PDF + fallback + history v0/v1+
|
- Web: เปิด Word/PDF + fallback + history v0/v1+
|
||||||
|
|
||||||
### เพิ่มเติม v8 (FR-16)
|
### เพิ่มเติม 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
|
- Add-in: อ่าน `TemplateId` ใน `readTrackedProperties` + ส่ง `templateId` เฉพาะ `startVersioning` + hint ไฟล์ร่างใน task pane
|
||||||
- Web: ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม + `downloadFile` รับ `init` (POST + JSON → blob)
|
- Web: ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม
|
||||||
|
|
||||||
|
### เพิ่มเติม 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 <local>` → เปิดได้ (แต่ใช้จากเว็บไม่ได้เพราะ 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 ที่เลิกใช้)
|
||||||
|
|
|
||||||
|
|
@ -69,41 +69,5 @@ export function useApi() {
|
||||||
return (await apiJson<T>(path, { method, body: form })) as T
|
return (await apiJson<T>(path, { method, body: form })) as T
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
return { request, apiJson, apiUpload }
|
||||||
* ดาวน์โหลดไฟล์ผ่าน 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<void> {
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,21 @@
|
||||||
import { onBeforeUnmount, ref } from 'vue'
|
import { onBeforeUnmount, ref } from 'vue'
|
||||||
import { versionDownloadPath, wordOpenUrl, type VersionFormat } from '../utils/versionDownload'
|
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) ใช้ทั้งดาวน์โหลดและเปิดใน Word */
|
||||||
export function useVersionOpen() {
|
export function useVersionOpen() {
|
||||||
const { apiJson } = useApi()
|
const { apiJson } = useApi()
|
||||||
|
// อ่านตอน setup (มี nuxt context) — จะยิง ms-word:ofe ควบคู่กับการดาวน์โหลดหรือไม่
|
||||||
|
// (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 fireWordScheme = openInWord !== 'off'
|
||||||
|
|
||||||
const openingVersionId = ref<string | null>(null)
|
const openingVersionId = ref<string | null>(null)
|
||||||
const downloadError = ref<string | null>(null)
|
const downloadError = ref<string | null>(null)
|
||||||
const fallbackUrl = ref<string | null>(null)
|
const fallbackUrl = ref<string | null>(null)
|
||||||
const openNotice = ref<string | null>(null)
|
const openNotice = ref<string | null>(null)
|
||||||
let cleanup = () => {}
|
|
||||||
let generation = 0
|
let generation = 0
|
||||||
let pendingTab: Window | null = null
|
let pendingTab: Window | null = null
|
||||||
|
|
||||||
|
|
@ -16,13 +23,36 @@ export function useVersionOpen() {
|
||||||
generation++
|
generation++
|
||||||
pendingTab?.close()
|
pendingTab?.close()
|
||||||
pendingTab = null
|
pendingTab = null
|
||||||
cleanup()
|
|
||||||
openingVersionId.value = null
|
openingVersionId.value = null
|
||||||
downloadError.value = null
|
downloadError.value = null
|
||||||
fallbackUrl.value = null
|
fallbackUrl.value = null
|
||||||
openNotice.value = null
|
openNotice.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ให้เบราว์เซอร์เซฟ URL ลง Downloads (ลิงก์เปิดของ backend ส่ง disposition: attachment อยู่แล้ว —
|
||||||
|
* หน้าเว็บอยู่ที่เดิม ไม่ navigate ไปไหน) */
|
||||||
|
function saveToDownloads(url: string) {
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
document.body.append(a)
|
||||||
|
a.click()
|
||||||
|
a.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** เปิดไฟล์ 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
|
||||||
|
? 'กำลังเปิดไฟล์ใน Word ให้อัตโนมัติ — หากไม่เปิด ไฟล์อยู่ที่โฟลเดอร์ Downloads แล้วเปิดจากที่นั่นได้เลย'
|
||||||
|
: 'ดาวน์โหลดไฟล์แล้วที่โฟลเดอร์ Downloads — เปิดจากที่นั่นได้เลย'
|
||||||
|
if (fireWordScheme)
|
||||||
|
window.location.assign(wordOpenUrl(url))
|
||||||
|
}
|
||||||
|
|
||||||
async function openVersion(documentId: string, versionId: string, format: VersionFormat = 'docx') {
|
async function openVersion(documentId: string, versionId: string, format: VersionFormat = 'docx') {
|
||||||
if (openingVersionId.value) return
|
if (openingVersionId.value) return
|
||||||
resetOpen()
|
resetOpen()
|
||||||
|
|
@ -45,21 +75,7 @@ export function useVersionOpen() {
|
||||||
}
|
}
|
||||||
else openNotice.value = 'เบราว์เซอร์บล็อกแท็บใหม่ กรุณากดลิงก์เปิดไฟล์ด้านล่าง'
|
else openNotice.value = 'เบราว์เซอร์บล็อกแท็บใหม่ กรุณากดลิงก์เปิดไฟล์ด้านล่าง'
|
||||||
} else {
|
} else {
|
||||||
let switched = false
|
openDocxUrl(link.url)
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
tab?.close()
|
tab?.close()
|
||||||
|
|
@ -71,5 +87,5 @@ export function useVersionOpen() {
|
||||||
}
|
}
|
||||||
|
|
||||||
onBeforeUnmount(resetOpen)
|
onBeforeUnmount(resetOpen)
|
||||||
return { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, resetOpen }
|
return { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, openDocxUrl, resetOpen }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
import type { DocumentDetail, GenerateDocumentResponse, TemplateDetail } from '~/types/api'
|
import type { DocumentDetail, GenerateDocumentResponse, TemplateDetail } from '~/types/api'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { apiJson, downloadFile } = useApi()
|
const { apiJson } = useApi()
|
||||||
const { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, resetOpen } = useVersionOpen()
|
const { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, openDocxUrl, resetOpen } = useVersionOpen()
|
||||||
|
|
||||||
const templateId = computed(() => String(route.params.templateId))
|
const templateId = computed(() => String(route.params.templateId))
|
||||||
const detail = ref<TemplateDetail | null>(null)
|
const detail = ref<TemplateDetail | null>(null)
|
||||||
|
|
@ -82,7 +82,7 @@ async function load() {
|
||||||
/** validate ฝั่ง client (required/type) ก่อนยิง backend — backend ตรวจซ้ำอีกชั้นเสมอ */
|
/** validate ฝั่ง client (required/type) ก่อนยิง backend — backend ตรวจซ้ำอีกชั้นเสมอ */
|
||||||
async function submit() {
|
async function submit() {
|
||||||
submitError.value = null
|
submitError.value = null
|
||||||
downloadError.value = null
|
resetOpen() // เคลียร์ state ของการเปิดไฟล์ครั้งก่อน (รวม fallbackUrl/openNotice ของไฟล์ร่างที่อาจค้าง)
|
||||||
draftSuccess.value = null
|
draftSuccess.value = null
|
||||||
draftError.value = null
|
draftError.value = null
|
||||||
issues.value = Object.fromEntries(
|
issues.value = Object.fromEntries(
|
||||||
|
|
@ -105,7 +105,8 @@ async function submit() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** FR-16 — สร้างไฟล์ร่าง (draft): validate ชุดเดียวกันแล้ว POST ค่าไปขอไฟล์กลับมาดาวน์โหลดทันที
|
/** FR-16 — สร้างไฟล์ร่าง (draft): validate ชุดเดียวกันแล้ว POST ค่าไปขอ open-link (อายุสั้น)
|
||||||
|
* แล้ว download-first เหมือน flow เปิด v0 (ดาวน์โหลดทันที + ยิง ms-word ควบคู่)
|
||||||
* backend ไม่สร้าง Document/version — เอกสารจะเกิดตอนกด Start Versioning ใน Word (เป็น v1 พร้อมผูก template นี้) */
|
* backend ไม่สร้าง Document/version — เอกสารจะเกิดตอนกด Start Versioning ใน Word (เป็น v1 พร้อมผูก template นี้) */
|
||||||
async function submitDraft() {
|
async function submitDraft() {
|
||||||
draftSuccess.value = null
|
draftSuccess.value = null
|
||||||
|
|
@ -118,13 +119,17 @@ async function submitDraft() {
|
||||||
|
|
||||||
draftSubmitting.value = true
|
draftSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
await downloadFile(`/api/templates/${templateId.value}/generate-draft`, `${detail.value?.name ?? 'เอกสาร'}.docx`, {
|
const link = (await apiJson<{ url: string; expiresAt: string }>(
|
||||||
|
`/api/templates/${templateId.value}/generate-draft`,
|
||||||
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ values: values.value }),
|
body: JSON.stringify({ values: values.value }),
|
||||||
})
|
},
|
||||||
|
))!
|
||||||
|
openDocxUrl(link.url)
|
||||||
draftSuccess.value =
|
draftSuccess.value =
|
||||||
'ดาวน์โหลดไฟล์ร่างแล้ว — เปิดไฟล์ใน Word แล้วกด Start Versioning This Document เพื่อเริ่ม track (เอกสารจะถูกผูกกับ template นี้และบันทึกเป็น version 1)'
|
'ดาวน์โหลดไฟล์ร่างให้แล้ว (โฟลเดอร์ Downloads) — เปิดไฟล์ใน Word แล้วกด Start Versioning This Document ใน task pane ของ add-in เพื่อเริ่ม track (เอกสารจะถูกผูกกับ template นี้และบันทึกเป็น version 1)'
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
draftError.value = err instanceof Error ? err.message : 'สร้างไฟล์ร่างไม่สำเร็จ'
|
draftError.value = err instanceof Error ? err.message : 'สร้างไฟล์ร่างไม่สำเร็จ'
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -301,8 +306,13 @@ onMounted(load)
|
||||||
|
|
||||||
<AlertBox v-if="submitError" type="error">{{ submitError }}</AlertBox>
|
<AlertBox v-if="submitError" type="error">{{ submitError }}</AlertBox>
|
||||||
|
|
||||||
<!-- FR-16 — ไฟล์ร่าง: ดาวน์โหลดสำเร็จแล้วฟอร์มอยู่ต่อ ผู้ใช้เปิดไฟล์ใน Word แล้วค่อย Start Versioning -->
|
<!-- FR-16 — ไฟล์ร่าง: เปิดใน Word แล้วฟอร์มอยู่ต่อ พร้อมลิงก์สำรอง (อายุสั้นตาม presigned URL) -->
|
||||||
<AlertBox v-if="draftSuccess" type="success">{{ draftSuccess }}</AlertBox>
|
<AlertBox v-if="draftSuccess" type="success">{{ draftSuccess }}</AlertBox>
|
||||||
|
<AlertBox v-if="fallbackUrl && (draftSuccess || openNotice)">
|
||||||
|
<p v-if="openNotice">{{ openNotice }}</p>
|
||||||
|
<a :href="fallbackUrl" target="_blank" rel="noopener noreferrer" class="link">ดาวน์โหลดไฟล์ร่างสำรอง (.docx)</a>
|
||||||
|
<p class="text-xs text-muted">ลิงก์มีอายุสั้น หากหมดอายุให้กดสร้างไฟล์ร่างอีกครั้ง</p>
|
||||||
|
</AlertBox>
|
||||||
<AlertBox v-if="draftError" type="error">{{ draftError }}</AlertBox>
|
<AlertBox v-if="draftError" type="error">{{ draftError }}</AlertBox>
|
||||||
|
|
||||||
<div class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
|
<div class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
|
||||||
|
|
@ -323,7 +333,7 @@ onMounted(load)
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-muted">
|
<p class="text-xs text-muted">
|
||||||
ไฟล์ร่าง = ได้ .docx ทันทีโดยยังไม่เกิดเอกสารในระบบ — เริ่ม versioning ตอนเปิดใน Word (version 1)
|
ไฟล์ร่าง = เปิดใน Word ทันทีโดยยังไม่เกิดเอกสารในระบบ — เริ่ม versioning ตอนกด Start Versioning ใน Word (version 1)
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,6 @@ export function versionDownloadPath(documentId: string, versionId: string, forma
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Office URI Schemes: preserve the signed URL, including its existing percent escapes. */
|
/** Office URI Schemes: preserve the signed URL, including its existing percent escapes. */
|
||||||
export function wordOpenUrl(presignedUrl: string): string {
|
export function wordOpenUrl(fileUrl: string): string {
|
||||||
return `ms-word:ofe|u|${presignedUrl}`
|
return `ms-word:ofe|u|${fileUrl}`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ export default defineNuxtConfig({
|
||||||
// Keycloak dev ใช้ https:8443 (cert ของ office-addin-dev-certs — เชื่อถือได้ในเครื่อง)
|
// Keycloak dev ใช้ https:8443 (cert ของ office-addin-dev-certs — เชื่อถือได้ในเครื่อง)
|
||||||
keycloakAuthority: 'https://localhost:8443/realms/word-versioning',
|
keycloakAuthority: 'https://localhost:8443/realms/word-versioning',
|
||||||
keycloakClientId: 'word-versioning-web',
|
keycloakClientId: 'word-versioning-web',
|
||||||
|
// ยิง ms-word:ofe ควบคู่กับการดาวน์โหลดหรือไม่ — '' / 'on' = ยิงทุกแพลตฟอร์ม (open-link แบบใหม่ https+ไม่มี query+จบ .docx
|
||||||
|
// ยืนยันแล้วว่าเปิดได้จริงแม้ Word 16.101+ บน Mac — v9.2.1), 'off' = ดาวน์โหลดอย่างเดียว
|
||||||
|
openInWord: '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<string, string>
|
|
||||||
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() // ไม่มีการดาวน์โหลดเกิดขึ้น
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -19,8 +19,9 @@ function mount() {
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers()
|
|
||||||
vi.stubGlobal('useApi', () => ({ apiJson }))
|
vi.stubGlobal('useApi', () => ({ apiJson }))
|
||||||
|
// default: ยิง ms-word คู่กัน (toggle ไม่มี platform logic แล้ว — แต่ละ test stub ทับได้)
|
||||||
|
vi.stubGlobal('useRuntimeConfig', () => ({ public: { openInWord: '' } }))
|
||||||
tab = { opener: {}, closed: false, location: { replace }, close }
|
tab = { opener: {}, closed: false, location: { replace }, close }
|
||||||
vi.spyOn(window, 'open').mockReturnValue(tab as unknown as Window)
|
vi.spyOn(window, 'open').mockReturnValue(tab as unknown as Window)
|
||||||
})
|
})
|
||||||
|
|
@ -30,7 +31,6 @@ afterEach(() => {
|
||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
vi.useRealTimers()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('version open lifecycle', () => {
|
describe('version open lifecycle', () => {
|
||||||
|
|
@ -84,17 +84,70 @@ describe('version open lifecycle', () => {
|
||||||
expect(state.downloadError.value).toBe('popup error')
|
expect(state.downloadError.value).toBe('popup error')
|
||||||
expect(state.openingVersionId.value).toBeNull()
|
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 assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {})
|
||||||
const remove = vi.spyOn(window, 'removeEventListener')
|
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) // กัน download-first click จริง
|
||||||
apiJson.mockResolvedValue({ url: 'https://files.example/v.docx?signature=a%2Fb' })
|
apiJson.mockResolvedValue({ url: 'https://files.example/v.docx?signature=a%2Fb' })
|
||||||
const state = mount()
|
const state = mount()
|
||||||
await state.openVersion('d', 'v')
|
await state.openVersion('d', 'v')
|
||||||
expect(assign).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/v.docx?signature=a%2Fb')
|
expect(assign).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/v.docx?signature=a%2Fb')
|
||||||
vi.advanceTimersByTime(1500)
|
|
||||||
expect(state.openNotice.value).toBeTruthy()
|
expect(state.openNotice.value).toBeTruthy()
|
||||||
expect(state.fallbackUrl.value).toBeTruthy()
|
expect(state.fallbackUrl.value).toBeTruthy()
|
||||||
app.unmount()
|
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(() => {})
|
||||||
|
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')
|
||||||
|
expect(state.openNotice.value).toBeTruthy()
|
||||||
|
})
|
||||||
|
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) {
|
||||||
|
saved.push(this)
|
||||||
|
})
|
||||||
|
const state = mount()
|
||||||
|
state.openDocxUrl('https://files.example/draft.docx?sig=a%2Fb')
|
||||||
|
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 คู่กัน — 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', () => {
|
||||||
|
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')
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
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).toContain('Downloads') // ข้อความชี้ไปที่ Downloads อย่างเดียว
|
||||||
|
})
|
||||||
|
it('default (auto) ยิง ms-word ทุกแพลตฟอร์ม รวมถึง Mac — open-link แบบใหม่ (https + ไม่มี query + จบ .docx) เปิดได้จริงบน Word 16.101+', () => {
|
||||||
|
vi.spyOn(window.navigator, 'platform', 'get').mockReturnValue('MacIntel')
|
||||||
|
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).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/draft.docx')
|
||||||
|
expect(state.openNotice.value).toContain('อัตโนมัติ')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue