From b9b227926dfb8756cde80d929f3970f686b45f0d Mon Sep 17 00:00:00 2001 From: Suphonchai Phoonsawat Date: Thu, 10 Sep 2026 12:07:50 +0700 Subject: [PATCH] Hotfix: strip invalid XML control chars from field values (FR-16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ผู้ใช้ paste ข้อความที่มี vertical tab (0x0B) เข้าฟอร์ม — XML 1.0 ห้าม อักขระนี้ ทำให้ XmlWriter โยน ArgumentException ตอน save package และ หลุดเป็น 500 stack เต็มจอ (catch เดิมครอบแค่ OpenXmlPackageException) - DocxGenerationService.SanitizeForXml: ตัดอักขระที่ XML 1.0 ห้าม (0x00–0x1F ยกเว้น tab/LF/CR, lone surrogate, 0xFFFE/0xFFFF — คู่ surrogate ครบ เช่น อีโมจิ คงไว้) ที่จุดเขียนค่าทั้งสองของ ReplaceSdtContent → ครอบทั้ง generate และ generate-draft - Controllers: broad catch (ยกเว้น cancellation) → 502 ข้อความอ่านออก แทน 500 ดิบ สำหรับ exception ที่ไม่ได้ตั้งใจจาก OpenXML - Test: Draft_ValueWithInvalidXmlControlChars_IsStrippedNot500 (draft + generate v0) — reviewer พิสูจน์แล้วว่า fail จริงบนโค้ดก่อนแก้ - backend 85/85; QA reviewer PASS (1 round, minors 3: แก้ 1 ปฏิเสธ 2 พร้อมเหตุผล — บันทึกใน acceptance-review.md) Co-Authored-By: Claude Code --- CLAUDE.md | 2 +- .../DraftTests.cs | 32 ++++++++++++- .../Controllers/DocumentsController.cs | 7 +++ .../Controllers/TemplatesController.cs | 8 ++++ .../Services/DocxGenerationService.cs | 45 ++++++++++++++++++- docs/acceptance-review.md | 5 ++- docs/plans/officejs-implementation-plan.md | 2 +- specs/spec-officejs.md | 2 +- 8 files changed, 96 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2f059b9..d58c576 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`) -**สถานะปัจจุบัน: Phase 0–12 — FR-16 ไฟล์ร่าง: automated QA PASS (backend 84 / add-in 66 / web 56 + build ครบ · v9: เปิดใน Word ทันที + แก้ตัดคำไทย), ค้าง manual บน Word จริง · Phase 0–11 เสร็จครบ (FR-15 ผ่าน QA + manual บน Word for Mac 2026-09-08)** — ค้างยืนยันบน Word for Windows / Word on the web ตามรายการใน `docs/acceptance-review.md` +**สถานะปัจจุบัน: Phase 0–12 — FR-16 ไฟล์ร่าง: automated QA PASS (backend 85 / add-in 66 / web 56 + build ครบ · v9: เปิดใน Word ทันที + แก้ตัดคำไทย), ค้าง manual บน Word จริง · Phase 0–11 เสร็จครบ (FR-15 ผ่าน QA + manual บน Word for Mac 2026-09-08)** — ค้างยืนยันบน Word for Windows / Word on the web ตามรายการใน `docs/acceptance-review.md` ## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ) diff --git a/backend/WordVersioning.Api.IntegrationTests/DraftTests.cs b/backend/WordVersioning.Api.IntegrationTests/DraftTests.cs index 7e11217..d851398 100644 --- a/backend/WordVersioning.Api.IntegrationTests/DraftTests.cs +++ b/backend/WordVersioning.Api.IntegrationTests/DraftTests.cs @@ -89,7 +89,8 @@ public sealed class DraftTests(IntegrationTestFixture fixture) } /// สร้าง template จาก BuildThaiTemplateDocx พร้อมตั้ง field defs (detail1 required, remark1 ทางเลือก) - private async Task CreateThaiTemplateAsync() { + private async Task CreateThaiTemplateAsync() + { using var client = fixture.CreateClient(); using var create = await client.PostAsync( "api/templates", @@ -191,6 +192,35 @@ public sealed class DraftTests(IntegrationTestFixture fixture) Assert.Equal(templateId.ToString(), TestDocx.ReadCustomProperty(fileBytes, AddInMetadata.TemplateIdPropertyName)); } + [Fact] + public async Task Draft_ValueWithInvalidXmlControlChars_IsStrippedNot500() + { + // ผู้ใช้ paste ข้อความที่มีอักขระควบคุม (vertical tab 0x0B / form feed 0x0C) มาในค่า field — + // XML 1.0 ห้ามอักขระพวกนี้ เดิม XmlWriter โยน ArgumentException ตอน save ทั้ง package กลายเป็น 500 + // ต้องตัดออกเงียบๆ แล้วสร้างไฟล์สำเร็จ (อักขระมองไม่เห็นอยู่แล้ว) — ครอบทั้ง draft และ generate v0 + using var client = fixture.CreateClient(); + var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "paste control chars"); + var values = new Dictionary(TestFlow.DefaultValues) + { + ["position"] = "วิศวกร\u000Bซอฟต์\u000Cแวร์", // VT/FF ฝังเป็น escape กันอักขระล่องหนหายระหว่างแก้ไขไฟล์ + }; + + // ไฟล์ร่าง + using var draft = await client.PostAsJsonAsync( + $"api/templates/{templateId}/generate-draft", new { values }, IntegrationTestFixture.Json); + Assert.Equal(HttpStatusCode.OK, draft.StatusCode); + var link = (await draft.Content.ReadFromJsonAsync(IntegrationTestFixture.Json))!; + using var browser = new HttpClient(); + var draftBytes = await (await browser.GetAsync(link.Url)).Content.ReadAsByteArrayAsync(); + Assert.Equal("วิศวกรซอฟต์แวร์", TestDocx.ReadSdtText(draftBytes, "position")); + + // generate v0 ใช้ ReplaceSdtContent ตัวเดียวกัน — ตรวจซ้ำจุดเดียวกันผ่านอีก endpoint + var doc = await TestFlow.GenerateDocumentAsync(client, templateId, values); + using var download = await client.GetAsync(doc.DownloadUrl); + var docBytes = await download.Content.ReadAsByteArrayAsync(); + Assert.Equal("วิศวกรซอฟต์แวร์", TestDocx.ReadSdtText(docBytes, "position")); + } + [Fact] public async Task Draft_LifecycleRule_Idempotent_AndSyncsRetentionDays() { diff --git a/backend/WordVersioning.Api/Controllers/DocumentsController.cs b/backend/WordVersioning.Api/Controllers/DocumentsController.cs index 630ef0a..8b82b8a 100644 --- a/backend/WordVersioning.Api/Controllers/DocumentsController.cs +++ b/backend/WordVersioning.Api/Controllers/DocumentsController.cs @@ -307,6 +307,13 @@ public class DocumentsController( template.Id, template.ObjectKey); return StatusCode(StatusCodes.Status502BadGateway, "ไฟล์ template เสียหาย (เปิดด้วย OpenXML ไม่ได้)"); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // กันหน้า: OpenXML โยนอะไรก็ตามที่เราไม่ได้เจอมาก่อน — ให้เป็น 502 ที่อ่านออกแทน 500 stack เต็มจอ + logger.LogError(ex, "Generate failed templateId={TemplateId} userId={UserId}", + template.Id, currentUser.UserId); + return StatusCode(StatusCodes.Status502BadGateway, "สร้างเอกสารไม่สำเร็จ (ไฟล์ template หรือค่าที่กรอกมีปัญหา)"); + } var now = DateTimeOffset.UtcNow; using (var hashStream = new MemoryStream(fileBytes)) diff --git a/backend/WordVersioning.Api/Controllers/TemplatesController.cs b/backend/WordVersioning.Api/Controllers/TemplatesController.cs index 4204d98..70eabd3 100644 --- a/backend/WordVersioning.Api/Controllers/TemplatesController.cs +++ b/backend/WordVersioning.Api/Controllers/TemplatesController.cs @@ -366,6 +366,14 @@ public class TemplatesController( template.Id, template.ObjectKey); return StatusCode(StatusCodes.Status502BadGateway, "ไฟล์ template เสียหาย (เปิดด้วย OpenXML ไม่ได้)"); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // กันหน้า: OpenXML โยนอะไรก็ตามที่เราไม่ได้เจอมาก่อน (เช่น ArgumentException จากอักขระที่ XML ใช้ไม่ได้ + // ตอน v9 แรกๆ หลุดเป็น 500 stack เต็มจอ) — ให้เป็น 502 ที่อ่านออกแทน + logger.LogError(ex, "Draft generation failed templateId={TemplateId} userId={UserId}", + template.Id, currentUser.UserId); + return StatusCode(StatusCodes.Status502BadGateway, "สร้างไฟล์ร่างไม่สำเร็จ (ไฟล์ template หรือค่าที่กรอกมีปัญหา)"); + } // เก็บชั่วคราวใต้ drafts/ — lifecycle ลบให้เอง (Program.cs ตั้ง rule ตอน startup) var now = DateTimeOffset.UtcNow; diff --git a/backend/WordVersioning.Api/Services/DocxGenerationService.cs b/backend/WordVersioning.Api/Services/DocxGenerationService.cs index 4646f77..18dff73 100644 --- a/backend/WordVersioning.Api/Services/DocxGenerationService.cs +++ b/backend/WordVersioning.Api/Services/DocxGenerationService.cs @@ -108,6 +108,47 @@ public class DocxGenerationService : IDocxGenerationService } } + /// ตัดอักขระที่ XML 1.0 ห้าม (เช่น vertical tab 0x0B จากการ paste ข้อความ) ออกจากค่าก่อนเขียนลงไฟล์ — + /// XmlWriter โยน ArgumentException ตอน save ทั้ง package ถ้าปล่อยไว้ (XML รับได้เฉพาะ #x9/#xA/#xD/ + /// [#x20-#xD7FF]/[#xE000-#xFFFD]/คู่ surrogate ครบ) — ค่าที่ตัดไปเป็นอักขระที่มองไม่เห็นอยู่แล้ว + private static string SanitizeForXml(string value) + { + // ทางเลือกให้เร็ว: ไม่มีอักขระต้องตัดก็คืนของเดิม (กรณีทั่วไป) + var hasInvalid = false; + foreach (var c in value) + { + if (IsInvalidXmlChar(c)) + { + hasInvalid = true; + break; + } + } + if (!hasInvalid) + return value; + + var sb = new System.Text.StringBuilder(value.Length); + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) + { + sb.Append(c).Append(value[i + 1]); // คู่ surrogate ครบ = XML ใช้ได้ (เช่น อีโมจิ) + i++; + } + else if (!IsInvalidXmlChar(c)) + { + sb.Append(c); + } + } + return sb.ToString(); + + // lone surrogate (คู่ไม่ครบ) จัดการเป็นคู่ที่ loop ด้านบน — ที่นี่ surrogate ทุกตัวถือ invalid + static bool IsInvalidXmlChar(char c) => + (c < 0x20 && c is not '\t' and not '\n' and not '\r') // 0x00–0x1F ยกเว้น tab/LF/CR + || char.IsSurrogate(c) + || c == '\uFFFE' || c == '\uFFFF'; + } + /// แทนที่ข้อความใน SDT ที่ run level — เขียนค่าที่ Run ของ "ข้อความ placeholder จริง" /// (run ที่มีข้อความยาวสุด) เพื่อสืบทอด rPr ของมัน แล้วลบ Text ที่เหลือ ไม่ทับทั้ง paragraph /// (spec ส่วน 9 เพิ่มเติม v2 · v9: template ไทยมักแตก placeholder เป็น [ / ข้อความ / ] หลาย run — @@ -127,7 +168,7 @@ public class DocxGenerationService : IDocxGenerationService // SDT ไม่มีข้อความเลย (เช่น ที่ว่างมา) — สร้าง Run ใหม่ใน paragraph แรก (block) หรือใน content ตรงๆ (run) // โดยสืบทอด rPr จาก sdtPr ถ้ามี (คือ formatting ที่ผู้แต่ง template กำหนดให้เนื้อหาของ control นี้ — // เช่นเดียวกับที่ Word ใช้ตอนผู้ใช้พิมพ์ลง control ไม่ใช่ปล่อยเป็น default font) - var run = new Run(new Text(value) { Space = SpaceProcessingModeValues.Preserve }); + var run = new Run(new Text(SanitizeForXml(value)) { Space = SpaceProcessingModeValues.Preserve }); if (sdtPr?.Elements().FirstOrDefault() is { } sdtRunProperties) run.RunProperties = (RunProperties)sdtRunProperties.CloneNode(true); if (content is SdtContentBlock) @@ -152,7 +193,7 @@ public class DocxGenerationService : IDocxGenerationService var donorText = texts.OrderByDescending(t => t.Text?.Length ?? 0).First(); // เขียนที่ Text ของ donor แล้วเก็บ space ไว้กรณีค่ามีช่องว่างหัว/ท้าย - donorText.Text = value; + donorText.Text = SanitizeForXml(value); donorText.Space = SpaceProcessingModeValues.Preserve; // Text อื่นทั้งหมด (รวม donor ถ้ามีหลาย Text ใน run เดียวกัน ไม่เกิด) ลบทิ้ง — field = ค่าเดียว diff --git a/docs/acceptance-review.md b/docs/acceptance-review.md index 418b074..6d7c245 100644 --- a/docs/acceptance-review.md +++ b/docs/acceptance-review.md @@ -145,6 +145,7 @@ QA reviewer: **PASS**, 2 rounds, 3 findings fixed (build compatibility, pending | สร้างไฟล์ร่างแล้วไม่เกิด Document/version/field values ใน DB + ค่าครบ + formatting รักษาไว้ + **presigned URL ดึงไฟล์จริงได้ทันที** (v9) | `DraftTests.Draft_FillsAllFields_PreservesFormatting_StampsOnlyTemplateId_CreatesNothing` | | stamp เฉพาะ `TemplateId` — ไม่มี SavedByAddIn/DocumentTrackingId | test เดียวกัน (อ่าน custom properties จากไฟล์จริง) | | **ค่าที่แทนที่ได้ complex script ของ run ข้อความไทย (w:hint="cs"+w:cs) · SDT เปล่าสืบทอด sdtPr rPr** (v9 — แก้การตัดคำไทย) | `DraftTests.Draft_ThaiPlaceholder_UsesComplexScriptRun_ForWordBreaking` (template จำลองโครงสร้าง T14) | +| **ค่าที่มีอักขระควบคุมที่ XML ห้าม (เช่น 0x0B จากการ paste) ถูกตัดออก สร้างไฟล์สำเร็จ ไม่กลายเป็น 500** (v9 — bug จริงจากการใช้งาน) | `DraftTests.Draft_ValueWithInvalidXmlControlChars_IsStrippedNot500` (ครอบทั้ง draft และ generate v0) | | validate เหมือน FR-10: required ขาด/type ผิด → 400, unknown → 404, deactivate → 400, ค่าเกินนิยาม ignore | `DraftTests` ที่เหลือ (5 tests) | | Start Versioning ของไฟล์ร่าง: upload + templateId → **v1** + Document ผูก template + field sync ครบ + snapshot แบบมีนิยาม | `TemplateLinkingTests.Upload_WithTemplateId_OnNewDocument_CreatesLinkedDocV1WithFieldSync` | | upload ถัดไปไม่ส่ง templateId → v2, TemplateId คงเดิม | test เดียวกัน (upload รอบสอง) | @@ -155,7 +156,7 @@ QA reviewer: **PASS**, 2 rounds, 3 findings fixed (build compatibility, pending | add-in: อ่าน TemplateId / ส่งเฉพาะ startVersioning / append form field | `metadataService.test.ts` (ไฟล์ร่างอ่าน TemplateId), `versionFlow.test.ts` (แนบ/ไม่แนบ templateId), `versioningService.test.ts` (form field) | | เว็บ: เปิด docx URL ที่ถืออยู่เอง (ไฟล์ร่าง) ผ่าน ms-word + fallback + notice | `useVersionOpen.spec.ts` `openDocxUrl` test | -ชุดเต็ม (v9): backend **84 ผ่าน**, add-in **66 ผ่าน**, web **56 ผ่าน** + production build ผ่าน (backend 82 / web 59 ของ v8 + แก้ v9) +ชุดเต็ม (v9): backend **85 ผ่าน**, add-in **66 ผ่าน**, web **56 ผ่าน** + production build ผ่าน (backend 82 / web 59 ของ v8 + แก้ v9) ### ค้างยืนยันด้วยมือ (ต้องมี Word จริง) @@ -167,3 +168,5 @@ QA reviewer: **PASS**, 2 rounds, 3 findings fixed (build compatibility, pending QA reviewer: **PASS**, 2 rounds, 8 findings แก้ครบไม่มีที่ถูกปฏิเสธ (2026-09-10) — round 1: blocker `.at(-1)` ใน test ใหม่ตัดกับ tsconfig `lib: es2020` ทำ `addin npm run build` พัง (jest ไม่ type-check จึงรอดมา) + minors 4 (นับจำนวน test ผิดในตารางนี้, stale notices ระหว่างสองปุ่ม submit, AlertBox สำเร็จไม่ใช่ `type="success"`, `isTrackedDocument` ไม่มี production caller → เก็บไว้พร้อมหมายเหตุเหตุผล) · round 2: minors 2 + pre-existing 1 (หัวเอกสาร/สถานะเก่าใน acceptance-review, CLAUDE.md, implementation-plan header). ค้าง manual บน Word จริงตามรายการด้านบน. QA reviewer รอบ v9 (เปิดใน Word + ตัดคำไทย): **PASS**, 2 rounds, 10 findings แก้ครบไม่มีที่ถูกปฏิเสธ (2026-09-10) — round 1: minors 6 (dead `downloadFile`/`filenameFrom`, glossary "stateless" เก่า, lifecycle config เปลี่ยนแล้วเป็น no-op เงียบๆ + days ไม่ validate, ไม่มี test คุม lifecycle, comment เก่าใน TestDocx, typo ใน spec) · round 2: minors 2 + nits 2 (comment เก่าอีก 2 จุด, "(stateless)" ตกค้าง 2 บรรทัด, lifecycle test เพิ่ม assertion ผ่านค่าคืน `true/false`, ตัว verdict นี้เอง) — และ test ใหม่จับ bug จริงได้: `Configuration.Rules` เป็น null ได้ทำให้ startup ตั้ง rule ล้มแบบเงียบมาตั้งแต่แรก (โดน catch กลืน) แก้ด้วย `?? []`. + +QA reviewer รอบ hotfix อักขระควบคุม 0x0B จากการ paste: **PASS**, 1 round (2026-09-10) — reviewer พิสูจน์ว่า test ใหม่ fail จริงบนโค้ดก่อนแก้ · minors 3: แก้ 1 (เปลี่ยนอักขระ VT/FF ล่องหนใน test เป็น escape sequence \u000B/\u000C กันหายระหว่างแก้ไขไฟล์) · ปฏิเสธ 2 พร้อมเหตุผล: (1) fast-path ตรวจ surrogate คู่ครบเป็น invalid ทำให้ค่ามีอีโมจิเดิน slow path — ผลลัพธ์ยังถูก (loop ประกอบคู่กลับคืน) และค่า field สั้นจึงแพงแค่หยดเดียว ไม่คุ้มจะ duplicate pair-logic (2) broad catch ให้ 502 แทน 500 สำหรับ bug ภายใน — สอดคล้อง convention 502 ของ "ไฟล์ template/ค่ามีปัญหา" ที่มีอยู่ และเหตุการณ์จริงที่ catch นี้มีไว้กันคือ input-triggered แท้ๆ (stack ยัง log ฝั่ง server) diff --git a/docs/plans/officejs-implementation-plan.md b/docs/plans/officejs-implementation-plan.md index 6a1dd74..d66d47f 100644 --- a/docs/plans/officejs-implementation-plan.md +++ b/docs/plans/officejs-implementation-plan.md @@ -645,5 +645,5 @@ npm test # vitest (46 ตัว) - [x] Web: ปุ่ม "สร้างไฟล์ร่าง" - [x] Tests/build (backend 82 / add-in 66 / web 59 + build ทั้ง add-in/web) - [x] QA reviewer PASS (2 rounds, 2026-09-10) -- [x] **v9 (feedback จากการใช้จริง): เปิดใน Word ทันที** — เก็บ object ใต้ `drafts/` + lifecycle รายวัน + คืน presigned URL + `useVersionOpen.openDocxUrl` · **แก้ตัดคำไทย** — ค่าเขียนที่ run ข้อความ placeholder จริง (complex script) + SDT เปล่าสืบทอด sdtPr rPr (backend 84 / web 56) + QA reviewer PASS (2 rounds, 2026-09-10) +- [x] **v9 (feedback จากการใช้จริง): เปิดใน Word ทันที** — เก็บ object ใต้ `drafts/` + lifecycle รายวัน + คืน presigned URL + `useVersionOpen.openDocxUrl` · **แก้ตัดคำไทย** — ค่าเขียนที่ run ข้อความ placeholder จริง (complex script) + SDT เปล่าสืบทอด sdtPr rPr (backend 85 / web 56) + QA reviewer PASS (2 rounds, 2026-09-10) - [ ] ตรวจ Word จริง (manual): เว็บสร้างไฟล์ร่าง → **Word เปิดทันที + ตัดคำไทยถูก** → untracked + hint → Start Versioning → v1 ผูก template + fields sync · รวมในรายการค้างเดิมของ Word for Windows / Word on the web diff --git a/specs/spec-officejs.md b/specs/spec-officejs.md index 805c15d..173aa5b 100644 --- a/specs/spec-officejs.md +++ b/specs/spec-officejs.md @@ -296,7 +296,7 @@ POST /api/documents/generate { templateId, values: { : } } ### FR-16: ไฟล์ร่าง (draft) — สร้าง .docx จาก template โดยไม่สร้าง Document (เพิ่มเติม v8 · ปรับ v9) - **`POST /api/templates/{templateId}/generate-draft`** body `{ values }` — ไม่สร้าง `Document`/`DocumentVersion`/`DocumentFieldValue` ใน DB; generate ไฟล์แล้ว**เก็บชั่วคราว**ใน bucket `docx-versions` ใต้ prefix `drafts/{templateId}/{yyyyMMddHHmmss}_{guid}.docx` ซึ่ง lifecycle rule ลบให้เอง (default 1 วัน — config `Minio__DraftRetentionDays`, backend ตั้ง rule ตอน startup แบบ idempotent/best-effort) แล้วคืน **200 `{ url, expiresAt }`** เป็น presigned GET URL อายุสั้น (attachment + ชื่อไฟล์ไทย RFC 5987) ให้เว็บเปิดใน Word ทันที (v8 เดิมคืนไฟล์เป็น stream ตรงๆ — ผู้ใช้ต้องเปิดจาก Downloads เอง) -- validate เหมือน FR-10 (required + type + format; ส่งเกินนิยาม ignore) · template ไม่พบ → 404, deactivate → 400 ตามนโยบาย FR-10 · ไม่มี `message` (ไม่มี version จึงไม่มี commit message) +- validate เหมือน FR-10 (required + type + format; ส่งเกินนิยาม ignore) · template ไม่พบ → 404, deactivate → 400 ตามนโยบาย FR-10 · ไม่มี `message` (ไม่มี version จึงไม่มี commit message) · **ค่าที่กรอกจะถูกตัดอักขระที่ XML 1.0 ห้ามออกก่อนเขียนลงไฟล์** (0x00–0x1F ยกเว้น tab/LF/CR, lone surrogate, 0xFFFE/0xFFFF — มักติดมากับการ paste เช่น vertical tab 0x0B ซึ่งทำ XmlWriter โยนตอน save) ใช้ร่วมทั้ง generate และ generate-draft · OpenXML โยน exception ที่ไม่ได้ตั้งใจ → 502 พร้อมข้อความอ่านออก ไม่ปล่อย 500 stack - stamp custom property **`TemplateId` = GUID ของ template ตัวเดียว** — ไม่ stamp `SavedByAddIn`/`DocumentTrackingId` ไฟล์ร่างจึงเป็น untracked ในสายตา add-in (เลี่ยงสถานะ "tracked แต่ history 404") - **ค่าที่แทนที่เขียนที่ run ของ "ข้อความ placeholder จริง"** (v9 — ใช้ร่วมทั้ง generate และ generate-draft): template ไทยมักแตก placeholder เป็น `[` / ข้อความ / `]` หลาย run โดย run ข้อความถือ `w:hint="cs"` + `w:cs` (complex script) ที่ Word ใช้ตัดคำ/กระจายภาษาไทย — เขียนค่าที่ run ที่มีข้อความยาวสุด (ไม่ใช่ run แรกที่เป็นปีกกา) · SDT เปล่าให้ run ใหม่สืบทอด `rPr` จาก `sdtPr` (เหมือนพฤติกรรม Word ตอนพิมพ์ลง control) - **ผูก template ตอน Start Versioning:** `POST /api/documents/{documentId}/versions` รับ `templateId` (multipart form field ทางเลือก) — ใช้เฉพาะตอน auto-create `Document` (field sync ทำงานเต็มระบบที่ v1); document มีอยู่แล้ว → เฉย (แต่ validate ทุกครั้งที่ส่งมา) · ไม่ใช่ GUID / ไม่พบใน DB → 400 · template ที่ deactivate → **ผูกได้** (ไฟล์ร่างถูกสร้างตอน template ยัง active — นโยบายเดียวกับ FR-9/FR-12) · upload แรกของ Document ใหม่ = **version 1** (max+1 — ไม่มี v0)