Phase 12 v9: draft opens in Word + Thai word-breaking fix (FR-16)
แก้ตามผลใช้งานจริง 2 ประเด็น: 1. เปิดใน Word ทันที — generate-draft เปลี่ยนจากคืนไฟล์ตรงๆ เป็นเก็บ ชั่วคราวใต้ drafts/ (lifecycle ลบรายวัน, config Minio__DraftRetentionDays) + คืน presigned URL ให้เว็บเปิดผ่าน ms-word:ofe พร้อมลิงก์สำรอง (useVersionOpen.openDocxUrl แยกแกนเปิด docx URL ใช้ร่วมกับ flow v0) 2. ตัดคำไทย — T14 แตก placeholder เป็น [ / ข้อความ / ] โดย run ข้อความ ถือ w:hint="cs" + w:cs เท่านั้น; เดิมเขียนค่าลง run ปีกกาทำให้ Word จัดหน้าไทยผิด — แก้เป็นเขียนที่ run ข้อความยาวสุด + SDT เปล่าสืบทอด rPr จาก sdtPr (ใช้ร่วมทั้ง generate และ generate-draft) - ถอดของที่เลิกใช้: CORS expose Content-Disposition, downloadFile init, useApi.spec · Spec v9 + ADR 0002 Amendment + acceptance-review AC-19 - Tests: backend 84 / web 56 + build; QA reviewer PASS (2 rounds, 10 findings แก้ครบ — รวม bug จริงที่ test ใหม่จับได้: lifecycle Rules null ทำ startup ตั้ง rule ล้มแบบเงียบ) · ค้าง manual บน Word จริง Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
b3c1c667f7
commit
70f71f4c5c
23 changed files with 408 additions and 241 deletions
|
|
@ -5,8 +5,8 @@ using WordVersioning.Api.Services;
|
|||
|
||||
namespace WordVersioning.Api.IntegrationTests;
|
||||
|
||||
/// <summary>FR-16 — ไฟล์ร่าง (draft): generate docx จาก template แบบ stateless
|
||||
/// (ค่าครบ + stamp เฉพาะ TemplateId + ไม่เกิด Document/version ใดๆ ในระบบ)</summary>
|
||||
/// <summary>FR-16 — ไฟล์ร่าง (draft): generate docx จาก template โดยไม่เกิด Document/version ใน DB
|
||||
/// (v9: เก็บไฟล์ชั่วคราวใต้ drafts/ + คืน presigned URL ให้เว็บเปิดใน Word + ค่าที่แทนที่ได้ formatting ไทยถูกต้อง)</summary>
|
||||
[Collection(IntegrationTestCollection.Name)]
|
||||
public sealed class DraftTests(IntegrationTestFixture fixture)
|
||||
{
|
||||
|
|
@ -19,13 +19,16 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
|
|||
using var response = await client.PostAsJsonAsync(
|
||||
$"api/templates/{templateId}/generate-draft",
|
||||
new { values = TestFlow.DefaultValues }, IntegrationTestFixture.Json);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode); // ไม่ใช่ 201 — ไม่มีอะไรถูกสร้าง
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode); // ไม่ใช่ 201 — ไม่มีอะไรถูกสร้างใน DB
|
||||
|
||||
// คืนไฟล์ docx พร้อมชื่อไฟล์จากชื่อ template (Content-Disposition attachment)
|
||||
Assert.Equal(TestDocx.DocxContentType, response.Content.Headers.ContentType?.MediaType);
|
||||
Assert.NotNull(response.Content.Headers.ContentDisposition?.FileNameStar);
|
||||
|
||||
var fileBytes = await response.Content.ReadAsByteArrayAsync();
|
||||
// คืน presigned URL อายุสั้น (เปิดใน Word ผ่าน ms-word:ofe / ดาวน์โหลดสำรอง) — ดึงไฟล์จริงได้ทันที
|
||||
var link = (await response.Content.ReadFromJsonAsync<DraftLinkResult>(IntegrationTestFixture.Json))!;
|
||||
Assert.False(string.IsNullOrWhiteSpace(link.Url));
|
||||
Assert.True(link.ExpiresAt > DateTimeOffset.UtcNow.AddSeconds(30));
|
||||
using var browser = new HttpClient();
|
||||
using var file = await browser.GetAsync(link.Url);
|
||||
Assert.Equal(HttpStatusCode.OK, file.StatusCode);
|
||||
var fileBytes = await file.Content.ReadAsByteArrayAsync();
|
||||
|
||||
// (1) ค่าทุก field ถูกแทนที่ใน SDT — เหมือน generate ปกติ
|
||||
Assert.Equal("สมหญิง รักงาน", TestDocx.ReadSdtText(fileBytes, "employee_name"));
|
||||
|
|
@ -33,7 +36,7 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
|
|||
Assert.Equal("75000", TestDocx.ReadSdtText(fileBytes, "salary"));
|
||||
Assert.Equal("2026-08-01", TestDocx.ReadSdtText(fileBytes, "start_date"));
|
||||
|
||||
// (2) multi-run SDT ยังรักษา formatting ของ run แรก (bold)
|
||||
// (2) multi-run SDT ยังรักษา formatting ของ run ผู้บริจาคค่า (bold — ชนะด้วย tie แบบเจตนา, ดู comment ใน TestDocx)
|
||||
var positionRuns = TestDocx.ReadSdtRuns(fileBytes, "position");
|
||||
var runWithText = Assert.Single(positionRuns.Where(r => r.Text.Length > 0));
|
||||
Assert.True(runWithText.Bold);
|
||||
|
|
@ -43,7 +46,7 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
|
|||
Assert.Null(TestDocx.ReadCustomProperty(fileBytes, "SavedByAddIn"));
|
||||
Assert.Null(TestDocx.ReadCustomProperty(fileBytes, "DocumentTrackingId"));
|
||||
|
||||
// (4) stateless จริง: ไม่เกิด Document/version ใดๆ จาก template นี้
|
||||
// (4) ไม่เกิด Document/version ใดๆ ใน DB จาก template นี้ (ไฟล์เก็บชั่วคราวใน MinIO เท่านั้น ไม่ใช่ record)
|
||||
await using var db = fixture.CreateDb();
|
||||
Assert.False(await db.Documents.AsNoTracking().AnyAsync(d => d.TemplateId == templateId));
|
||||
Assert.False(await db.DocumentVersions.AsNoTracking()
|
||||
|
|
@ -52,6 +55,63 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
|
|||
.AnyAsync(v => db.Documents.Any(d => d.Id == v.DocumentId && d.TemplateId == templateId)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Draft_ThaiPlaceholder_UsesComplexScriptRun_ForWordBreaking()
|
||||
{
|
||||
// template ไทยจริง (แบบ T14) แตก placeholder เป็น [ / ข้อความ / ] — ค่าต้องลง run ข้อความ
|
||||
// ที่ถือ w:hint="cs" + w:cs ไม่ใช่ run ปีกกา ไม่งั้น Word ตัดคำ/กระจายไทยผิดทั้งบรรทัด (v9)
|
||||
var templateId = await CreateThaiTemplateAsync();
|
||||
using var client = fixture.CreateClient();
|
||||
const string detail = "โจทก์ฟ้องร้องให้เพิกถอนนิติกรรมสัญญาซื้อขายที่ดินโฉนดเลขที่ 12345 เนื่องจากถูกปลอมลายมือชื่อ";
|
||||
|
||||
using var response = await client.PostAsJsonAsync(
|
||||
$"api/templates/{templateId}/generate-draft",
|
||||
new { values = new Dictionary<string, string> { ["detail1"] = detail, ["remark1"] = "หมายเหตุผู้พิพากษา" } },
|
||||
IntegrationTestFixture.Json);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var link = (await response.Content.ReadFromJsonAsync<DraftLinkResult>(IntegrationTestFixture.Json))!;
|
||||
using var browser = new HttpClient();
|
||||
using var file = await browser.GetAsync(link.Url);
|
||||
Assert.Equal(HttpStatusCode.OK, file.StatusCode);
|
||||
var fileBytes = await file.Content.ReadAsByteArrayAsync();
|
||||
|
||||
// ค่ายาวอยู่ใน run เดียวที่ถือ complex script flags ตาม run ข้อความไทยของ template
|
||||
// (run ปีกกาถูกลบ text แต่คง rPr ไว้ — โล่ง มองไม่เห็นใน Word เหมือนพฤติกรรมเดิมของ generate)
|
||||
var run = Assert.Single(TestDocx.ReadSdtRunInfo(fileBytes, "detail1").Where(r => r.Text.Length > 0));
|
||||
Assert.Equal(detail, run.Text);
|
||||
Assert.True(run.CsHint);
|
||||
Assert.True(run.ComplexScript);
|
||||
|
||||
// SDT เปล่า: run ใหม่สืบทอด rPr จาก sdtPr (ฟอนต์ไทยของผู้แต่ง template) ไม่ใช่ default font
|
||||
var emptyRun = Assert.Single(TestDocx.ReadSdtRunInfo(fileBytes, "remark1").Where(r => r.Text.Length > 0));
|
||||
Assert.Equal("หมายเหตุผู้พิพากษา", emptyRun.Text);
|
||||
Assert.Equal("TH SarabunPSK", emptyRun.AsciiFont);
|
||||
}
|
||||
|
||||
/// <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]
|
||||
public async Task Draft_MissingRequiredField_Returns400()
|
||||
{
|
||||
|
|
@ -123,7 +183,28 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
|
|||
using var response = await client.PostAsJsonAsync(
|
||||
$"api/templates/{templateId}/generate-draft", new { values }, IntegrationTestFixture.Json);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var fileBytes = await response.Content.ReadAsByteArrayAsync();
|
||||
var link = (await response.Content.ReadFromJsonAsync<DraftLinkResult>(IntegrationTestFixture.Json))!;
|
||||
using var browser = new HttpClient();
|
||||
using var file = await browser.GetAsync(link.Url);
|
||||
Assert.Equal(HttpStatusCode.OK, file.StatusCode);
|
||||
var fileBytes = await file.Content.ReadAsByteArrayAsync();
|
||||
Assert.Equal(templateId.ToString(), TestDocx.ReadCustomProperty(fileBytes, AddInMetadata.TemplateIdPropertyName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Draft_LifecycleRule_Idempotent_AndSyncsRetentionDays()
|
||||
{
|
||||
// Program.cs ตั้ง rule `expire-drafts` ให้ bucket ตั้งแต่ startup แล้ว — ตรวจ path สร้างใหม่/ซ้ำ/เปลี่ยนค่า
|
||||
// ผ่าน rule id อื่นกับ prefix ทดสอบ (ทั้งหมดยิง MinIO จริง) — ค่าคืน true = เขียน config จริง, false = ถูกต้องอยู่แล้ว
|
||||
var storage = fixture.CreateStorage();
|
||||
var bucket = fixture.MinioConfig.VersionsBucket;
|
||||
|
||||
Assert.True(await storage.EnsurePrefixExpiryAsync(bucket, "test-drafts-probe/", 1, "test-expire-probe")); // สร้าง
|
||||
Assert.False(await storage.EnsurePrefixExpiryAsync(bucket, "test-drafts-probe/", 1, "test-expire-probe")); // idempotent
|
||||
Assert.True(await storage.EnsurePrefixExpiryAsync(bucket, "test-drafts-probe/", 2, "test-expire-probe")); // sync ค่าใหม่
|
||||
Assert.False(await storage.EnsurePrefixExpiryAsync(bucket, "test-drafts-probe/", 2, "test-expire-probe")); // ตั้งแล้วเช่นกัน
|
||||
|
||||
await Assert.ThrowsAsync<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("2026-08-01", TestDocx.ReadSdtText(fileBytes, "start_date"));
|
||||
|
||||
// (2) multi-run SDT: ค่าใหม่เขียนที่ Text แรก (ใน Run แรก — สืบทอด rPr bold ของ template)
|
||||
// และ Text ของ run หลังถูกลบ (เหลือ run โล่งที่มีแต่ rPr — มองไม่เห็น ไม่มีผลกับเนื้อหา)
|
||||
// (2) multi-run SDT: ค่าใหม่เขียนที่ run ข้อความยาวสุด (v9 — ที่นี่คือ run bold ซึ่งชนะด้วย tie แบบเจตนา,
|
||||
// ดู comment ใน TestDocx) และ Text ของ run หลังถูกลบ (เหลือ run โล่งที่มีแต่ rPr — มองไม่เห็น ไม่มีผลกับเนื้อหา)
|
||||
var positionRuns = TestDocx.ReadSdtRuns(fileBytes, "position");
|
||||
var runWithText = Assert.Single(positionRuns.Where(r => r.Text.Length > 0));
|
||||
Assert.Equal("วิศวกรซอฟต์แวร์", runWithText.Text);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Hosting;
|
|||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Testcontainers.Minio;
|
||||
using Testcontainers.PostgreSql;
|
||||
using WordVersioning.Api.Data;
|
||||
|
|
@ -152,6 +153,14 @@ public sealed class IntegrationTestFixture : IAsyncLifetime
|
|||
.UseNpgsql(_postgres.GetConnectionString())
|
||||
.Options);
|
||||
|
||||
/// <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 เท่านั้น)
|
||||
/// — token มี aud = word-versioning-web จาก audience mapper ของ realm ตามที่ backend validate
|
||||
/// (เลี่ยง PKCE dance ของ production flow — ตัว validate ฝั่ง backend คือตัวเดิมทดสอบเต็มรูปแบบ)</summary>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,9 @@ public static class TestDocx
|
|||
new RunProperties(new Bold()),
|
||||
new Text("[ชื่อพนักงาน]")))),
|
||||
|
||||
// SDT multi-run คนละ formatting — generate ต้องเขียนค่าที่ run แรก (รักษา bold) และลบ text ของ run หลัง
|
||||
// SDT multi-run คนละ formatting — generate เขียนค่าที่ run ข้อความยาวสุด (v9) และลบ text ของอีก run
|
||||
// ⚠️ สอง run ยาวเท่ากัน (8/8 อักขระ) โดยเจตนา: tie → run แรก (bold) ชนะเพราะ OrderByDescending
|
||||
// เสถียร — GenerateTests/DraftTests ใช้จุดนี้เป็น regression guard ของพฤติกรรม tie ห้ามแก้ความยาว
|
||||
new Paragraph(SdtRun("position",
|
||||
new Run(
|
||||
new RunProperties(new Bold()),
|
||||
|
|
@ -83,6 +85,49 @@ public static class TestDocx
|
|||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
/// <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>
|
||||
public static string ReadSdtText(byte[] docx, string tag)
|
||||
{
|
||||
|
|
@ -109,7 +154,7 @@ public static class TestDocx
|
|||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
using var doc = WordprocessingDocument.Open(new MemoryStream(docx), false);
|
||||
|
|
@ -140,6 +185,36 @@ public static class TestDocx
|
|||
?.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(
|
||||
new SdtProperties(new Tag { Val = tag }),
|
||||
new SdtContentRun(runs));
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ public record UploadVersionResult(Guid VersionId, int VersionNumber, bool HasPdf
|
|||
|
||||
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 VersionItem(Guid VersionId, int VersionNumber, DateTimeOffset CreatedAt, string CreatedByUserId, string ContentHash, JsonElement? Fields, string? Message, bool HasPdf);
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@ public sealed class VersionPdfTests(IntegrationTestFixture fixture)
|
|||
}
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -305,11 +305,12 @@ public class TemplatesController(
|
|||
return Ok(new ReplaceTemplateFileResponse(template.Id, FieldDtos(template), addedTags, removedTags));
|
||||
}
|
||||
|
||||
/// <summary>FR-16 — ไฟล์ร่าง (draft): generate docx จาก template แบบ stateless — ไม่สร้าง
|
||||
/// Document/DocumentVersion/DocumentFieldValue และไม่เขียน MinIO; คืนไฟล์ทันที
|
||||
/// (ผู้ใช้เปิดใน Word แล้วกด Start Versioning เพื่อเริ่ม track เป็น v1 พร้อมผูกกลับมาที่ template นี้)</summary>
|
||||
/// <summary>FR-16 — ไฟล์ร่าง (draft): generate docx จาก template โดยไม่สร้าง
|
||||
/// Document/DocumentVersion/DocumentFieldValue ใน DB — เก็บไฟล์ชั่วคราวใน bucket versions ใต้ prefix
|
||||
/// `drafts/` (หมดอายุรายวันตาม lifecycle) แล้วคืน presigned URL อายุสั้นให้เว็บเปิดใน Word
|
||||
/// ผ่าน ms-word:ofe พร้อมลิงก์ดาวน์โหลดสำรอง (v9 — ตอนแรกคืนไฟล์ตรงๆ แต่ผู้ใช้ต้องเปิด Word เอง)</summary>
|
||||
[HttpPost("{id:guid}/generate-draft")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(GenerateDraftResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status502BadGateway)]
|
||||
|
|
@ -366,11 +367,32 @@ public class TemplatesController(
|
|||
return StatusCode(StatusCodes.Status502BadGateway, "ไฟล์ template เสียหาย (เปิดด้วย OpenXML ไม่ได้)");
|
||||
}
|
||||
|
||||
logger.LogInformation("Draft generated templateId={TemplateId} userId={UserId} bytes={Size}",
|
||||
template.Id, currentUser.UserId, fileBytes.Length);
|
||||
// เก็บชั่วคราวใต้ drafts/ — lifecycle ลบให้เอง (Program.cs ตั้ง rule ตอน startup)
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var objectKey = $"drafts/{template.Id}/{now:yyyyMMddHHmmss}_{Guid.NewGuid():N}.docx";
|
||||
try
|
||||
{
|
||||
await storage.PutAsync(minioOptions.Value.VersionsBucket, objectKey,
|
||||
new MemoryStream(fileBytes), DocxUploadValidation.DocxContentType, ct);
|
||||
}
|
||||
catch (AmazonServiceException ex)
|
||||
{
|
||||
logger.LogError(ex, "Draft upload failed templateId={TemplateId} userId={UserId}",
|
||||
template.Id, currentUser.UserId);
|
||||
return StatusCode(StatusCodes.Status502BadGateway, "อัปโหลดไฟล์ขึ้น storage ไม่สำเร็จ");
|
||||
}
|
||||
|
||||
return File(fileBytes, DocxUploadValidation.DocxContentType,
|
||||
DocxGenerationService.BuildGeneratedFileName(template.Name, DateTimeOffset.UtcNow));
|
||||
// presigned อายุสั้น + ชื่อไฟล์ไทยตาม RFC 5987 (นโยบายเดียวกับ download-link ของ version)
|
||||
var expirySeconds = Math.Clamp(minioOptions.Value.PresignedExpirySeconds, 30, 600);
|
||||
var fileName = DocxGenerationService.BuildGeneratedFileName(template.Name, now);
|
||||
var url = storage.GetPresignedGetUrl(
|
||||
minioOptions.Value.VersionsBucket, objectKey, TimeSpan.FromSeconds(expirySeconds),
|
||||
$"attachment; filename*=UTF-8''{Uri.EscapeDataString(fileName)}");
|
||||
|
||||
logger.LogInformation("Draft generated templateId={TemplateId} userId={UserId} bytes={Size} key={ObjectKey}",
|
||||
template.Id, currentUser.UserId, fileBytes.Length, objectKey);
|
||||
|
||||
return Ok(new GenerateDraftResponse(url, now.AddSeconds(expirySeconds)));
|
||||
}
|
||||
|
||||
/// <summary>deactivate (soft-delete) — document เก่าที่อ้างอยู่ยังใช้ได้ต่อ แต่สร้างเอกสารใหม่ไม่ได้</summary>
|
||||
|
|
|
|||
|
|
@ -61,6 +61,10 @@ public class GenerateDraftRequest
|
|||
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 TemplateListItem(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ using System.Security.Cryptography;
|
|||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using WordVersioning.Api.Data;
|
||||
using WordVersioning.Api.Services;
|
||||
|
|
@ -65,14 +67,11 @@ builder.Services
|
|||
builder.Services.AddAuthorization();
|
||||
|
||||
// CORS — add-in task pane (https://localhost:3000) + web portal (http://localhost:5173) เท่านั้น
|
||||
// expose Content-Disposition ให้ fetch ฝั่งเว็บอ่านชื่อไฟล์ (ไทย) ของไฟล์ร่างที่ดาวน์โหลดได้ (FR-16)
|
||||
// — ไม่ expose แล้ว header จะเป็น null บน cross-origin และชื่อไฟล์หายเงียบๆ
|
||||
const string corsPolicy = "frontends";
|
||||
builder.Services.AddCors(options => options.AddPolicy(corsPolicy, policy => policy
|
||||
.WithOrigins(builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [])
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
.WithExposedHeaders("Content-Disposition")));
|
||||
.AllowAnyMethod()));
|
||||
|
||||
// --- dev HTTPS -------------------------------------------------------------
|
||||
// Word webview (WKWebView บน Mac) บล็อก fetch จากหน้า https ไป http แบบเงียบๆ ("Load failed")
|
||||
|
|
@ -102,6 +101,21 @@ if (devHttpsEnabled)
|
|||
|
||||
var app = builder.Build();
|
||||
|
||||
// FR-16 — ไฟล์ร่างอยู่ใต้ prefix `drafts/` ของ bucket versions: ตั้ง lifecycle ให้หมดอายุเอง (idempotent)
|
||||
// best-effort — MinIO ยังไม่พร้อมตอนสตาร์ทก็แค่ log เตือน ไม่บล็อก (ไฟล์ร่างจะค้างนานขึ้นเท่านั้น)
|
||||
try
|
||||
{
|
||||
await using var scope = app.Services.CreateAsyncScope();
|
||||
var storageInit = scope.ServiceProvider.GetRequiredService<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())
|
||||
{
|
||||
app.UseSwagger();
|
||||
|
|
|
|||
|
|
@ -108,8 +108,11 @@ public class DocxGenerationService : IDocxGenerationService
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>แทนที่ข้อความใน SDT ที่ <b>run level</b> — เก็บ Run เดิมไว้ตัวแรก (formatting ตาม template)
|
||||
/// แล้วลบ Text ที่เหลือ ไม่ทับทั้ง paragraph (spec ส่วน 9 เพิ่มเติม v2)</summary>
|
||||
/// <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)
|
||||
{
|
||||
var sdtPr = tag.Parent; // w:sdtPr
|
||||
|
|
@ -122,7 +125,11 @@ public class DocxGenerationService : IDocxGenerationService
|
|||
if (texts.Count == 0)
|
||||
{
|
||||
// SDT ไม่มีข้อความเลย (เช่น ที่ว่างมา) — สร้าง Run ใหม่ใน paragraph แรก (block) หรือใน content ตรงๆ (run)
|
||||
// โดยสืบทอด rPr จาก sdtPr ถ้ามี (คือ formatting ที่ผู้แต่ง template กำหนดให้เนื้อหาของ control นี้ —
|
||||
// เช่นเดียวกับที่ Word ใช้ตอนผู้ใช้พิมพ์ลง control ไม่ใช่ปล่อยเป็น default font)
|
||||
var run = new Run(new Text(value) { Space = SpaceProcessingModeValues.Preserve });
|
||||
if (sdtPr?.Elements<RunProperties>().FirstOrDefault() is { } sdtRunProperties)
|
||||
run.RunProperties = (RunProperties)sdtRunProperties.CloneNode(true);
|
||||
if (content is SdtContentBlock)
|
||||
{
|
||||
var paragraph = content.ChildElements.OfType<Paragraph>().FirstOrDefault();
|
||||
|
|
@ -140,12 +147,16 @@ public class DocxGenerationService : IDocxGenerationService
|
|||
return;
|
||||
}
|
||||
|
||||
// เขียนที่ Text แรก (อยู่ใน Run แรก — สืบทอด rPr ของ run นั้น) แล้วเก็บ space ไว้กรณีค่ามีช่องว่างหัว/ท้าย
|
||||
texts[0].Text = value;
|
||||
texts[0].Space = SpaceProcessingModeValues.Preserve;
|
||||
// เลือก Run ผู้บริจาค formatting = run ที่มีข้อความยาวสุด (tie → run แรก) — หลีกเลี่ยง run ปีกกา/สัญลักษณ์
|
||||
// ที่ formatting มักไม่ครบ (เช่น ไม่มี complex script flags)
|
||||
var donorText = texts.OrderByDescending(t => t.Text?.Length ?? 0).First();
|
||||
|
||||
// Text ที่เหลือ (จากการ split run เดิม เช่น rsid/formatting ปนกัน) ลบทิ้ง — field = ค่าเดียว
|
||||
foreach (var text in texts.Skip(1))
|
||||
// เขียนที่ Text ของ donor แล้วเก็บ space ไว้กรณีค่ามีช่องว่างหัว/ท้าย
|
||||
donorText.Text = value;
|
||||
donorText.Space = SpaceProcessingModeValues.Preserve;
|
||||
|
||||
// Text อื่นทั้งหมด (รวม donor ถ้ามีหลาย Text ใน run เดียวกัน ไม่เกิด) ลบทิ้ง — field = ค่าเดียว
|
||||
foreach (var text in texts.Where(t => !ReferenceEquals(t, donorText)))
|
||||
{
|
||||
var run = text.Parent;
|
||||
text.Remove();
|
||||
|
|
|
|||
|
|
@ -21,4 +21,8 @@ public class MinioOptions
|
|||
|
||||
/// <summary>อายุของ presigned download URL ที่ออกให้เบราว์เซอร์ (วินาที) — สั้นๆ ตาม spec</summary>
|
||||
public int PresignedExpirySeconds { get; set; } = 120;
|
||||
|
||||
/// <summary>FR-16 — จำนวนวันที่ไฟล์ร่าง (object ใต้ prefix `drafts/` ของ VersionsBucket) ถูกลบโดย
|
||||
/// lifecycle rule (ผู้ใช้เปิดผ่าน presigned URL ในไม่กี่นาทีแรก หลังจากนั้นเป็นขยะ)</summary>
|
||||
public int DraftRetentionDays { get; set; } = 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ public interface IObjectStorageService
|
|||
/// <summary>สร้าง presigned GET URL อายุสั้น — ให้เปิดในเบราว์เซอร์ของผู้ใช้โดยตรง
|
||||
/// (webview ของ Word ทิ้ง blob download แบบเงียบๆ — spec อนุญาต presigned URL อายุสั้นสำหรับดาวน์โหลด)</summary>
|
||||
string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry, string? contentDisposition = null);
|
||||
|
||||
/// <summary>FR-16 — ตั้ง lifecycle rule ให้ object ใต้ prefix หนึ่งหมดอายุเองตามจำนวนวัน
|
||||
/// (idempotent ตาม rule id และ sync ค่า days ใหม่ถ้า config เปลี่ยนหลัง boot แรก — days < 1 คือ throw)
|
||||
/// ใช้กับไฟล์ร่างที่เก็บชั่วคราวใน bucket versions ใต้ `drafts/`
|
||||
/// คืน true เมื่อเขียน config ใหม่, false เมื่อ rule ถูกต้องอยู่แล้ว (ใช้ assert ใน test)</summary>
|
||||
Task<bool> EnsurePrefixExpiryAsync(string bucket, string prefix, int days, string ruleId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Wrapper เรียก MinIO ผ่าน AWSSDK.S3 (S3-compatible API)</summary>
|
||||
|
|
@ -99,6 +105,54 @@ public class ObjectStorageService : IObjectStorageService
|
|||
return url;
|
||||
}
|
||||
|
||||
public async Task<bool> EnsurePrefixExpiryAsync(string bucket, string prefix, int days, string ruleId, CancellationToken ct = default)
|
||||
{
|
||||
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,
|
||||
Configuration = new LifecycleConfiguration { Rules = rules },
|
||||
}, ct);
|
||||
_logger.LogInformation("Lifecycle rule {RuleId} ตั้งแล้ว: {Bucket}/{Prefix} expire={Days}d", ruleId, bucket, prefix, days);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Stream ที่ครอบ GetObjectResponse — dispose stream = dispose response ทั้งอัน
|
||||
/// (ไม่งั้น controller ต้องรับ response มาด้วยแล้วจัดการ lifetime เอง ยุ่ง)</summary>
|
||||
private sealed class S3ResponseStream(GetObjectResponse response) : Stream
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue