Phase 11: version PDF (FR-15)
- เลข version ใหม่: เว็บ-generate = v0 ฉบับตั้งต้น (ไม่มี PDF), add-in save (Start Versioning + Save & Version) = v1+ มี PDF เสมอ - PDF ผลิตจาก Word จริงผ่าน task pane เท่านั้น (getFileAsync Pdf + runtime guard, manifest ไม่เพิ่ม requirement); capture/upload เป็น best-effort — docx ผ่านแม้ PDF ล้ม + MessageBar เตือนตาม hasPdf - Backend: DocumentVersion.PdfObjectKey (nullable, migration AddVersionPdf) + sibling .pdf ใน bucket docx-versions · multipart part pdf (ผิด type → 400) · /download + /download-link รับ ?format=pdf (ไม่มี → 404) · presigned ฝังชื่อไฟล์ไทย + _vN (RFC 5987) - Web: เปิดไฟล์แทน blob-download — PDF แท็บใหม่, docx ผ่าน ms-word:ofe|u| พร้อม heuristic fallback ลิงก์สำรอง (useVersionOpen, versionDownload) · ปุ่ม "เปิดใน Word" ที่หน้า success ของฟอร์มสร้างเอกสาร - Spec v7 + CONTEXT.md + ADR 0001 + แผน docs/plans/version-pdf.md - Automated QA: backend 68 / add-in 63 / web 55 ผ่าน · manual acceptance ผ่านบน Word for Mac (2026-09-08) รวม fidelity + track changes — ค้างเฉพาะ Windows / Word on the web Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
618328ffa7
commit
39d610a781
39 changed files with 1387 additions and 212 deletions
|
|
@ -37,6 +37,7 @@ public class DocumentsController(
|
|||
private const int MaxVersionMessageLength = 2048; // FR-13 — ตรงกับ HasMaxLength ใน AppDbContext
|
||||
|
||||
/// <summary>FR-5 — อัปโหลด version ใหม่ (auto-create Document ถ้ายังไม่มี)
|
||||
/// + FR-15: optional PDF; PDF storage failure still returns 201 with HasPdf=false
|
||||
/// + FR-11: แนบ `fields` (map tag→text) → sync latest values + snapshot ต่อ version</summary>
|
||||
[HttpPost("{documentId:guid}/versions")]
|
||||
[RequestSizeLimit(MaxUploadBytes)]
|
||||
|
|
@ -65,6 +66,21 @@ public class DocumentsController(
|
|||
if (message is { Length: > MaxVersionMessageLength })
|
||||
return BadRequest($"`message` ยาวเกิน {MaxVersionMessageLength} อักขระ");
|
||||
|
||||
if (request.Pdf is not null)
|
||||
{
|
||||
if (request.Pdf.Length > MaxUploadBytes)
|
||||
return StatusCode(StatusCodes.Status413PayloadTooLarge, "ไฟล์ PDF ใหญ่เกิน 100MB");
|
||||
if (request.Pdf.Length == 0 || !PdfUploadValidation.IsValidPdf(request.Pdf))
|
||||
return BadRequest("ต้องแนบไฟล์ .pdf ที่ไม่ว่าง (application/pdf)");
|
||||
}
|
||||
|
||||
await using var pdfStream = new MemoryStream();
|
||||
if (request.Pdf is not null)
|
||||
{
|
||||
await request.Pdf.CopyToAsync(pdfStream, ct);
|
||||
pdfStream.Position = 0;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var fileName = request.File.FileName;
|
||||
|
||||
|
|
@ -133,6 +149,7 @@ public class DocumentsController(
|
|||
DocumentId = documentId,
|
||||
VersionNumber = nextNumber,
|
||||
ObjectKey = $"{documentId}/v{nextNumber:D4}_{request.ClientVersionId}.docx",
|
||||
PdfObjectKey = request.Pdf is null ? null : $"{documentId}/v{nextNumber:D4}_{request.ClientVersionId}.pdf",
|
||||
ContentHash = contentHash,
|
||||
CreatedByUserId = currentUser.UserId, // จาก claim `sub` เท่านั้น
|
||||
CreatedAt = now,
|
||||
|
|
@ -187,15 +204,31 @@ public class DocumentsController(
|
|||
return StatusCode(StatusCodes.Status502BadGateway, "อัปโหลดไฟล์ขึ้น storage ไม่สำเร็จ");
|
||||
}
|
||||
|
||||
// FR-15: PDF is derived; its storage failure must not undo the saved docx.
|
||||
if (version.PdfObjectKey is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await storage.PutAsync(minioOptions.Value.VersionsBucket, version.PdfObjectKey,
|
||||
pdfStream, PdfUploadValidation.PdfContentType, ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is AmazonServiceException or IOException or HttpRequestException or OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "PDF upload failed documentId={DocumentId} versionId={VersionId}", documentId, version.Id);
|
||||
version.PdfObjectKey = null;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Version uploaded documentId={DocumentId} versionId={VersionId} versionNumber={VersionNumber} userId={UserId} bytes={Size} hash={Hash}",
|
||||
documentId, version.Id, version.VersionNumber, currentUser.UserId, request.File.Length, contentHash);
|
||||
|
||||
return CreatedAtAction(nameof(GetVersions), new { documentId },
|
||||
new UploadVersionResponse(version.Id, version.VersionNumber));
|
||||
new UploadVersionResponse(version.Id, version.VersionNumber, version.PdfObjectKey is not null));
|
||||
}
|
||||
|
||||
/// <summary>FR-10 — สร้างเอกสารจาก template: generate docx + stamp metadata + upload version 1
|
||||
/// <summary>FR-10 — สร้างเอกสารจาก template: generate docx + stamp metadata + upload version 0
|
||||
/// + field values ทั้งหมดใน flow เดียว — เปิดใน Word แล้ว add-in ตรวจพบ tracked document ทันที (AC-12)</summary>
|
||||
[HttpPost("generate")]
|
||||
[ProducesResponseType(typeof(GenerateDocumentResponse), StatusCodes.Status201Created)]
|
||||
|
|
@ -304,8 +337,8 @@ public class DocumentsController(
|
|||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DocumentId = documentId,
|
||||
VersionNumber = 1,
|
||||
ObjectKey = $"{documentId}/v0001_{Guid.NewGuid()}.docx", // clientVersionId generate ฝั่ง server (flow นี้ไม่มี client)
|
||||
VersionNumber = 0,
|
||||
ObjectKey = $"{documentId}/v0000_{Guid.NewGuid()}.docx", // clientVersionId generate ฝั่ง server (flow นี้ไม่มี client)
|
||||
ContentHash = contentHash,
|
||||
CreatedByUserId = currentUser.UserId,
|
||||
CreatedAt = now,
|
||||
|
|
@ -313,7 +346,7 @@ public class DocumentsController(
|
|||
};
|
||||
db.DocumentVersions.Add(version);
|
||||
|
||||
// snapshot + field values ของ v1 (ทุก field มีค่า = missing=false ทั้งหมด)
|
||||
// snapshot + field values ของ v0 (ทุก field มีค่า = missing=false ทั้งหมด)
|
||||
var sync = await fieldSync.StageFieldSyncAsync(doc, version.Id, values, now, ct);
|
||||
version.FieldsSnapshotJson = sync.SnapshotJson;
|
||||
|
||||
|
|
@ -434,37 +467,43 @@ public class DocumentsController(
|
|||
v.ContentHash,
|
||||
v.FieldsSnapshotJson,
|
||||
v.Message,
|
||||
v.PdfObjectKey,
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
return Ok(rows.Select(r => new VersionListItem(
|
||||
r.Id, r.VersionNumber, r.CreatedAt, r.CreatedByUserId, r.ContentHash,
|
||||
ParseSnapshot(r.FieldsSnapshotJson), r.Message)).ToList());
|
||||
ParseSnapshot(r.FieldsSnapshotJson), r.Message, r.PdfObjectKey is not null)).ToList());
|
||||
}
|
||||
|
||||
/// <summary>FR-7 — ดาวน์โหลด version ที่ระบุ (proxy stream จาก MinIO — ไม่ expose MinIO ตรงๆ)</summary>
|
||||
/// <summary>FR-7/FR-15 — proxy download; format=pdf selects PDF (404 when absent), default is docx.</summary>
|
||||
[HttpGet("{documentId:guid}/versions/{versionId:guid}/download")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DownloadVersion(Guid documentId, Guid versionId, CancellationToken ct)
|
||||
public async Task<IActionResult> DownloadVersion(Guid documentId, Guid versionId, CancellationToken ct, [FromQuery] string? format = null)
|
||||
{
|
||||
var row = await db.DocumentVersions
|
||||
.AsNoTracking()
|
||||
.Where(v => v.DocumentId == documentId && v.Id == versionId)
|
||||
.Join(db.Documents, v => v.DocumentId, d => d.Id, (v, d) => new { v.ObjectKey, d.FileName })
|
||||
.Join(db.Documents, v => v.DocumentId, d => d.Id, (v, d) => new { v.ObjectKey, v.PdfObjectKey, d.FileName })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (row is null)
|
||||
return NotFound($"ไม่พบ version {versionId} ของ document {documentId}");
|
||||
|
||||
var isPdf = string.Equals(format, "pdf", StringComparison.OrdinalIgnoreCase);
|
||||
var objectKey = isPdf ? row.PdfObjectKey : row.ObjectKey;
|
||||
if (objectKey is null)
|
||||
return NotFound("version นี้ไม่มีสำเนา PDF");
|
||||
|
||||
Stream stream;
|
||||
try
|
||||
{
|
||||
stream = await storage.GetStreamAsync(minioOptions.Value.VersionsBucket, row.ObjectKey, ct);
|
||||
stream = await storage.GetStreamAsync(minioOptions.Value.VersionsBucket, objectKey, ct);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
logger.LogError("Object missing in storage documentId={DocumentId} versionId={VersionId} key={ObjectKey}",
|
||||
documentId, versionId, row.ObjectKey);
|
||||
documentId, versionId, objectKey);
|
||||
return NotFound("ไฟล์ของ version นี้ไม่พบใน storage");
|
||||
}
|
||||
|
||||
|
|
@ -474,28 +513,38 @@ public class DocumentsController(
|
|||
var downloadName = row.FileName.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)
|
||||
? row.FileName
|
||||
: row.FileName + ".docx";
|
||||
return File(stream, DocxContentType, downloadName);
|
||||
return File(stream, isPdf ? PdfUploadValidation.PdfContentType : DocxContentType,
|
||||
isPdf ? Path.ChangeExtension(downloadName, ".pdf") : downloadName);
|
||||
}
|
||||
|
||||
/// <summary>FR-7 (ทางเลือก presigned URL อายุสั้น — spec ส่วน 9 อนุญาต)
|
||||
/// <summary>FR-7/FR-15 — presigned URL with filename; format=pdf selects inline PDF (404 when absent).
|
||||
/// ออก URL ให้ add-in เปิดในเบราว์เซอร์ของผู้ใช้โดยตรง — webview ของ Word (โดยเฉพาะ WKWebView บน Mac)
|
||||
/// ทิ้ง blob download แบบเงียบๆ จึงต้องให้เบราว์เซอร์จริงจัดการการดาวน์โหลดแทน</summary>
|
||||
[HttpGet("{documentId:guid}/versions/{versionId:guid}/download-link")]
|
||||
[ProducesResponseType(typeof(VersionDownloadLinkResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<VersionDownloadLinkResponse>> GetVersionDownloadLink(
|
||||
Guid documentId, Guid versionId, CancellationToken ct)
|
||||
Guid documentId, Guid versionId, CancellationToken ct, [FromQuery] string? format = null)
|
||||
{
|
||||
var objectKey = await db.DocumentVersions
|
||||
var row = await db.DocumentVersions
|
||||
.AsNoTracking()
|
||||
.Where(v => v.DocumentId == documentId && v.Id == versionId)
|
||||
.Select(v => v.ObjectKey)
|
||||
.Join(db.Documents, v => v.DocumentId, d => d.Id,
|
||||
(v, d) => new { v.ObjectKey, v.PdfObjectKey, v.VersionNumber, d.FileName })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (objectKey is null)
|
||||
if (row is null)
|
||||
return NotFound($"ไม่พบ version {versionId} ของ document {documentId}");
|
||||
|
||||
var isPdf = string.Equals(format, "pdf", StringComparison.OrdinalIgnoreCase);
|
||||
var objectKey = isPdf ? row.PdfObjectKey : row.ObjectKey;
|
||||
if (objectKey is null)
|
||||
return NotFound("version นี้ไม่มีสำเนา PDF");
|
||||
var baseName = row.FileName.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)
|
||||
? row.FileName[..^5] : row.FileName;
|
||||
var fileName = $"{baseName}_v{row.VersionNumber}.{(isPdf ? "pdf" : "docx")}";
|
||||
var disposition = $"{(isPdf ? "inline" : "attachment")}; filename*=UTF-8''{Uri.EscapeDataString(fileName)}";
|
||||
var expirySeconds = Math.Clamp(minioOptions.Value.PresignedExpirySeconds, 30, 600);
|
||||
var url = storage.GetPresignedGetUrl(minioOptions.Value.VersionsBucket, objectKey, TimeSpan.FromSeconds(expirySeconds));
|
||||
var url = storage.GetPresignedGetUrl(minioOptions.Value.VersionsBucket, objectKey, TimeSpan.FromSeconds(expirySeconds), disposition);
|
||||
|
||||
logger.LogInformation(
|
||||
"Version download link issued documentId={DocumentId} versionId={VersionId} userId={UserId} expirySeconds={ExpirySeconds}",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue