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