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:
Suphonchai Phoonsawat 2026-09-10 11:49:16 +07:00
parent b3c1c667f7
commit 70f71f4c5c
23 changed files with 408 additions and 241 deletions

View file

@ -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>

View file

@ -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(

View file

@ -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();

View file

@ -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();

View file

@ -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;
}

View file

@ -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 &lt; 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