Phase 3
This commit is contained in:
parent
9321f5a370
commit
25c2ce53cd
23 changed files with 1746 additions and 68 deletions
12
backend/WordVersioning.Api/Services/AddInMetadata.cs
Normal file
12
backend/WordVersioning.Api/Services/AddInMetadata.cs
Normal 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";
|
||||
}
|
||||
170
backend/WordVersioning.Api/Services/DocxGenerationService.cs
Normal file
170
backend/WordVersioning.Api/Services/DocxGenerationService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
20
backend/WordVersioning.Api/Services/DocxUploadValidation.cs
Normal file
20
backend/WordVersioning.Api/Services/DocxUploadValidation.cs
Normal 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);
|
||||
}
|
||||
64
backend/WordVersioning.Api/Services/FieldMapJson.cs
Normal file
64
backend/WordVersioning.Api/Services/FieldMapJson.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
99
backend/WordVersioning.Api/Services/FieldSyncService.cs
Normal file
99
backend/WordVersioning.Api/Services/FieldSyncService.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue