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