This commit is contained in:
Suphonchai Phoonsawat 2026-08-19 12:15:20 +07:00
parent 9321f5a370
commit 25c2ce53cd
23 changed files with 1746 additions and 68 deletions

View file

@ -0,0 +1,12 @@
namespace WordVersioning.Api.Services;
/// <summary>ค่าคงที่ metadata ที่ฝังในไฟล์ .docx (spec ส่วน 8) — ต้องเป็นค่าเดียวกับที่ add-in ใช้</summary>
public static class AddInMetadata
{
/// <summary>GUID คงที่ของ add-in — ฝั่ง add-in ใช้ชื่อ ADDIN_SIGNATURE_GUID ค่าเดียวกันนี้ (Phase 4)</summary>
public const string AddInSignatureGuid = "9B7A3C51-4E6F-4D2A-8B5C-1E0F2A3B4C5D";
public const string SavedByAddInPropertyName = "SavedByAddIn";
public const string DocumentTrackingIdPropertyName = "DocumentTrackingId";
}

View file

@ -0,0 +1,170 @@
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.CustomProperties;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.VariantTypes;
using DocumentFormat.OpenXml.Wordprocessing;
namespace WordVersioning.Api.Services;
public interface IDocxGenerationService
{
/// <summary>parse content controls (SDT tags) จากไฟล์ template — เรียงตามที่ปรากฏในไฟล์ ไม่ซ้ำ
/// (FR-9: detect fields อัตโนมัติตอน upload)</summary>
IReadOnlyList<string> DetectFieldTags(Stream docxStream);
/// <summary>FR-10 — สร้างเอกสารจาก template: แทนที่ข้อความใน SDT แต่ละ field
/// <b>ที่ run level</b> (รักษา run properties/formatting ของ template) แล้ว stamp
/// Custom Properties (SavedByAddIn + DocumentTrackingId) — คืนเป็น bytes ของไฟล์ใหม่</summary>
byte[] Generate(Stream templateStream, IReadOnlyDictionary<string, string> values, Guid documentTrackingId);
}
/// <summary>จัดการ docx ด้วย OpenXML SDK — ใช้ทั้งตอน detect fields และตอน generate (FR-9, FR-10)</summary>
public class DocxGenerationService : IDocxGenerationService
{
/// <summary>fmtid มาตรฐานของ user-defined custom properties (ตาม MS-ODRAW/Office convention)</summary>
private const string CustomPropertyFormatId = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}";
public IReadOnlyList<string> DetectFieldTags(Stream docxStream)
{
using var doc = WordprocessingDocument.Open(docxStream, false);
var tags = new List<string>();
foreach (var root in AllContentRoots(doc))
{
foreach (var tag in root.Descendants<Tag>())
{
var key = tag.Val?.Value?.Trim();
if (!string.IsNullOrEmpty(key) && !tags.Contains(key))
tags.Add(key);
}
}
return tags;
}
public byte[] Generate(Stream templateStream, IReadOnlyDictionary<string, string> values, Guid documentTrackingId)
{
// ก๊อปเข้า memory ก่อน — MinIO stream ไม่ seek ได้ และ package จะเขียนกลับลง stream นี้ตอน dispose
var buffer = new MemoryStream();
templateStream.CopyTo(buffer);
buffer.Position = 0;
using (var doc = WordprocessingDocument.Open(buffer, true))
{
foreach (var root in AllContentRoots(doc))
{
foreach (var tag in root.Descendants<Tag>().ToList())
{
var key = tag.Val?.Value?.Trim();
if (string.IsNullOrEmpty(key) || !values.TryGetValue(key, out var value))
continue;
ReplaceSdtContent(tag, value);
}
}
SetCustomProperty(doc, AddInMetadata.SavedByAddInPropertyName, AddInMetadata.AddInSignatureGuid);
SetCustomProperty(doc, AddInMetadata.DocumentTrackingIdPropertyName, documentTrackingId.ToString());
} // dispose เขียน package กลับลง buffer
buffer.Position = 0;
return buffer.ToArray();
}
/// <summary>แทนที่ข้อความใน SDT ที่ <b>run level</b> — เก็บ Run เดิมไว้ตัวแรก (formatting ตาม template)
/// แล้วลบ Text ที่เหลือ ไม่ทับทั้ง paragraph (spec ส่วน 9 เพิ่มเติม v2)</summary>
private static void ReplaceSdtContent(Tag tag, string value)
{
var sdtPr = tag.Parent; // w:sdtPr
var sdt = sdtPr?.Parent; // w:sdt (block หรือ run context — SDK แยก class แต่ localName เดียวกัน)
var content = sdt?.ChildElements.FirstOrDefault(c => c.LocalName == "sdtContent");
if (content is null)
return;
var texts = content.Descendants<Text>().ToList();
if (texts.Count == 0)
{
// SDT ไม่มีข้อความเลย (เช่น ที่ว่างมา) — สร้าง Run ใหม่ใน paragraph แรก (block) หรือใน content ตรงๆ (run)
var run = new Run(new Text(value) { Space = SpaceProcessingModeValues.Preserve });
if (content is SdtContentBlock)
{
var paragraph = content.ChildElements.OfType<Paragraph>().FirstOrDefault();
if (paragraph is null)
{
paragraph = new Paragraph();
content.Append(paragraph);
}
paragraph.AppendChild(run);
}
else
{
content.AppendChild(run);
}
return;
}
// เขียนที่ Text แรก (อยู่ใน Run แรก — สืบทอด rPr ของ run นั้น) แล้วเก็บ space ไว้กรณีค่ามีช่องว่างหัว/ท้าย
texts[0].Text = value;
texts[0].Space = SpaceProcessingModeValues.Preserve;
// Text ที่เหลือ (จากการ split run เดิม เช่น rsid/formatting ปนกัน) ลบทิ้ง — field = ค่าเดียว
foreach (var text in texts.Skip(1))
{
var run = text.Parent;
text.Remove();
// Run ที่กลายเป็นโล่ง (ไม่มี Break/Tab/อื่นๆ) ลบทิ้งไม่ให้เหลือรอย formatting หลอนๆ
if (run is { } && run.ChildElements.Count == 0)
run.Remove();
}
}
/// <summary>แก้ custom property (มีอยู่แล้ว = แทนที่, ยังไม่มี = เพิ่ม) ผ่าน CustomFilePropertiesPart</summary>
private static void SetCustomProperty(WordprocessingDocument doc, string name, string value)
{
var part = doc.CustomFilePropertiesPart;
if (part is null)
{
part = doc.AddCustomFilePropertiesPart();
part.Properties = new Properties();
}
var props = part.Properties
?? throw new InvalidOperationException("CustomFilePropertiesPart ไม่มี root Properties");
foreach (var existing in props.Elements<CustomDocumentProperty>()
.Where(p => p.Name?.Value == name).ToList())
{
existing.Remove();
}
// pid ต้องเริ่มที่ 2 และไม่ซ้ำ — ต่อจากตัวสูงสุดที่มีอยู่
var nextPid = props.Elements<CustomDocumentProperty>()
.Select(p => p.PropertyId?.Value ?? 1)
.DefaultIfEmpty(1)
.Max() + 1;
props.Append(new CustomDocumentProperty
{
Name = name,
FormatId = CustomPropertyFormatId,
PropertyId = nextPid,
VTLPWSTR = new VTLPWSTR(value),
});
}
/// <summary>content controls อยู่ได้ทั้ง body, header, footer — เก็บครบทุกที่</summary>
private static IEnumerable<OpenXmlPartRootElement> AllContentRoots(WordprocessingDocument doc)
{
var main = doc.MainDocumentPart
?? throw new InvalidOperationException("ไฟล์ docx ไม่มี MainDocumentPart");
if (main.Document is { } document)
yield return document;
foreach (var header in main.HeaderParts)
{
if (header.Header is { } root)
yield return root;
}
foreach (var footer in main.FooterParts)
{
if (footer.Footer is { } root)
yield return root;
}
}
}

View file

@ -0,0 +1,20 @@
namespace WordVersioning.Api.Services;
/// <summary>ตรวจไฟล์ .docx ที่รับเข้ามาทาง multipart — ใช้ร่วมทั้ง upload version และ template</summary>
public static class DocxUploadValidation
{
public const string DocxContentType =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
public static bool IsValidDocx(IFormFile file) =>
file.FileName.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)
&& IsAcceptableContentType(file.ContentType);
// spec: reject content-type ที่ไม่ใช่ docx — ยอมรับ octet-stream/zip ด้วยเพราะ client บางตัว
// (เช่น curl -F) ไม่ set ชนิดไฟล์ให้ ส่วนชื่อไฟล์ต้องลง .docx เสมอ (เช็คข้างบน)
public static bool IsAcceptableContentType(string contentType) =>
string.IsNullOrWhiteSpace(contentType)
|| contentType.Equals(DocxContentType, StringComparison.OrdinalIgnoreCase)
|| contentType.Equals("application/octet-stream", StringComparison.OrdinalIgnoreCase)
|| contentType.Equals("application/zip", StringComparison.OrdinalIgnoreCase);
}

View file

@ -0,0 +1,64 @@
using System.Text.Json;
namespace WordVersioning.Api.Services;
/// <summary>แปลง/ตรวจ map ของ fields (JSON object รูป { tag: value }) ที่ client ส่งมา —
/// ใช้ทั้ง form field `fields` ของ upload (FR-11) และ body `values` ของ generate (FR-10)</summary>
public static class FieldMapJson
{
/// <summary>parse จาก string — คืน false พร้อมข้อความ error เมื่อ JSON ไม่ถูกต้อง</summary>
public static bool TryParse(string? json, out Dictionary<string, string?> map, out string? error)
{
map = [];
error = null;
if (string.IsNullOrWhiteSpace(json))
return true;
try
{
using var doc = JsonDocument.Parse(json);
return TryNormalize(doc.RootElement, out map, out error);
}
catch (JsonException)
{
error = "`fields` ไม่ใช่ JSON ที่ถูกต้อง";
return false;
}
}
/// <summary>normalize จาก JsonElement — ค่า scalar (string/number/bool/null) เท่านั้น
/// null กลายเป็น null ใน map (จะถูกมองเป็นค่าว่าง ไม่ใช่ field หาย)</summary>
public static bool TryNormalize(JsonElement element, out Dictionary<string, string?> map, out string? error)
{
map = [];
error = null;
if (element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
return true;
if (element.ValueKind != JsonValueKind.Object)
{
error = "ต้องเป็น JSON object รูป { tag: value }";
return false;
}
foreach (var prop in element.EnumerateObject())
{
string? value = prop.Value.ValueKind switch
{
JsonValueKind.String => prop.Value.GetString(),
JsonValueKind.Number => prop.Value.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Null => null,
_ => null,
};
if (prop.Value.ValueKind is not (JsonValueKind.String or JsonValueKind.Number
or JsonValueKind.True or JsonValueKind.False or JsonValueKind.Null))
{
error = $"ค่าของ `{prop.Name}` ต้องเป็น scalar (string/number/bool/null)";
return false;
}
map[prop.Name] = value;
}
return true;
}
}

View file

@ -0,0 +1,99 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using WordVersioning.Api.Data;
using WordVersioning.Api.Models;
namespace WordVersioning.Api.Services;
/// <summary>entry หนึ่ง field ใน `DocumentVersion.FieldsSnapshotJson` (เอกสารที่ผูก template)</summary>
public record FieldSnapshotEntry(string Value, bool Missing);
public record FieldSyncResult(string? SnapshotJson);
public interface IFieldSyncService
{
/// <summary>FR-11 — เทียบ fields ที่ส่งมากับ TemplateField ของ template ที่ document ผูกอยู่:
/// stage การ upsert `DocumentFieldValue` (latest value) ลง change tracker (**caller เป็นคน SaveChanges**)
/// แล้วคืน snapshot JSON ของ version นี้ไปเก็บใน `DocumentVersion.FieldsSnapshotJson`
///
/// policy ตาม spec: field ขาด (ผู้ใช้ลบ content control) → เก็บค่าเดิม + mark missing **ไม่ fail**;
/// field เกินนิยาม → ignore; `UpdatedByUserId` จาก JWT เสมอ</summary>
Task<FieldSyncResult> StageFieldSyncAsync(
Document document,
Guid sourceVersionId,
IReadOnlyDictionary<string, string?> sentFields,
DateTimeOffset now,
CancellationToken ct);
}
public class FieldSyncService(AppDbContext db, ICurrentUser currentUser) : IFieldSyncService
{
// camelCase ให้ตรงกับ JSON ที่ API คืนทั้งระบบ
private static readonly JsonSerializerOptions SnapshotJsonOptions = new(JsonSerializerDefaults.Web);
public async Task<FieldSyncResult> StageFieldSyncAsync(
Document document,
Guid sourceVersionId,
IReadOnlyDictionary<string, string?> sentFields,
DateTimeOffset now,
CancellationToken ct)
{
if (document.TemplateId is null)
{
// เอกสารไม่ผูก template (track เองจาก add-in) — ไม่มีนิยามให้เทียบ
// เก็บ map ดิบตามที่ส่งมาใน snapshot ไว้ดู history ได้ (Phase 2 behavior)
return sentFields.Count == 0
? new FieldSyncResult(null)
: new FieldSyncResult(JsonSerializer.Serialize(sentFields, SnapshotJsonOptions));
}
var fieldDefs = await db.TemplateFields.AsNoTracking()
.Where(f => f.TemplateId == document.TemplateId)
.OrderBy(f => f.SortOrder)
.ToListAsync(ct);
if (fieldDefs.Count == 0)
return new FieldSyncResult(null);
// tracked — update ทีหลังให้เข้า SaveChanges เดียวกับ version insert
var existingValues = await db.DocumentFieldValues
.Where(v => v.DocumentId == document.Id)
.ToDictionaryAsync(v => v.FieldKey, ct);
var snapshot = new Dictionary<string, FieldSnapshotEntry>(fieldDefs.Count);
foreach (var def in fieldDefs)
{
if (sentFields.TryGetValue(def.Key, out var sentValue))
{
sentValue ??= string.Empty;
if (existingValues.TryGetValue(def.Key, out var row))
{
row.Value = sentValue;
row.SourceVersionId = sourceVersionId;
row.UpdatedByUserId = currentUser.UserId;
row.UpdatedAt = now;
}
else
{
db.DocumentFieldValues.Add(new DocumentFieldValue
{
DocumentId = document.Id,
FieldKey = def.Key,
Value = sentValue,
SourceVersionId = sourceVersionId,
UpdatedByUserId = currentUser.UserId,
UpdatedAt = now,
});
}
snapshot[def.Key] = new FieldSnapshotEntry(sentValue, Missing: false);
}
else
{
// field หายจากเอกสาร (ผู้ใช้ลบ content control) — เก็บค่าเดิม + mark missing ไม่ fail (AC-14)
var oldValue = existingValues.TryGetValue(def.Key, out var row) ? row.Value : string.Empty;
snapshot[def.Key] = new FieldSnapshotEntry(oldValue, Missing: true);
}
}
return new FieldSyncResult(JsonSerializer.Serialize(snapshot, SnapshotJsonOptions));
}
}

View file

@ -34,6 +34,9 @@ public class ObjectStorageService : IObjectStorageService
public async Task PutAsync(string bucket, string key, Stream content, string contentType, CancellationToken ct = default)
{
// จับความยาวไว้ก่อน — AWSSDK 4.x dispose input stream ให้หลัง upload เสร็จ อ่าน .Length ตอนหลังไม่ได้
long? length = content.CanSeek ? content.Length : null;
var request = new PutObjectRequest
{
BucketName = bucket,
@ -41,14 +44,14 @@ public class ObjectStorageService : IObjectStorageService
InputStream = content,
ContentType = contentType,
};
if (content.CanSeek)
if (length.HasValue)
{
// ระบุความยาวชัดๆ ไม่ต้องให้ SDK ไป buffer ทั้ง stream
request.Headers.ContentLength = content.Length;
request.Headers.ContentLength = length.Value;
}
await _s3.PutObjectAsync(request, ct);
_logger.LogInformation("Uploaded object {Bucket}/{Key} ({Length} bytes)", bucket, key, content.Length);
_logger.LogInformation("Uploaded object {Bucket}/{Key} ({Length} bytes)", bucket, key, length);
}
public async Task<Stream> GetStreamAsync(string bucket, string key, CancellationToken ct = default)