using System.Text.Json; using Amazon.Runtime; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Npgsql; using WordVersioning.Api.Data; using WordVersioning.Api.Models; using WordVersioning.Api.Services; namespace WordVersioning.Api.Controllers; /// FR-5 (upload version), FR-6 (history), FR-7 (download) + รับ fields สำหรับ FR-11 [ApiController] [Authorize] // FR-8: ทุก endpoint ต้องแนบ token [Route("api/documents")] public class DocumentsController( AppDbContext db, IObjectStorageService storage, IHashService hasher, ICurrentUser currentUser, IOptions minioOptions, ILogger logger) : ControllerBase { public const string DocxContentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; private const long MaxUploadBytes = 100L * 1024 * 1024; // 100MB ตาม spec private const int MaxVersionInsertRetries = 5; /// FR-5 — อัปโหลด version ใหม่ (auto-create Document ถ้ายังไม่มี) [HttpPost("{documentId:guid}/versions")] [RequestSizeLimit(MaxUploadBytes)] [RequestFormLimits(MultipartBodyLengthLimit = MaxUploadBytes)] [ProducesResponseType(typeof(UploadVersionResponse), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)] [ProducesResponseType(StatusCodes.Status502BadGateway)] public async Task UploadVersion(Guid documentId, [FromForm] UploadVersionRequest request, CancellationToken ct) { if (request.File is null || request.File.Length == 0) return BadRequest("ต้องแนบไฟล์ `file` (docx)"); if (!Guid.TryParse(request.ClientVersionId, out _)) return BadRequest("`clientVersionId` ต้องเป็น GUID"); if (request.File.Length > MaxUploadBytes) return StatusCode(StatusCodes.Status413PayloadTooLarge, $"ไฟล์ใหญ่เกิน {MaxUploadBytes / (1024 * 1024)}MB"); if (!IsValidDocx(request.File)) return BadRequest("รองรับเฉพาะไฟล์ .docx (Word document)"); // parse fields map (FR-11) — optional, ผิด format คือ 400 string? fieldsSnapshotJson = null; if (!string.IsNullOrWhiteSpace(request.Fields)) { try { using var fieldsDoc = JsonDocument.Parse(request.Fields); if (fieldsDoc.RootElement.ValueKind != JsonValueKind.Object) return BadRequest("`fields` ต้องเป็น JSON object รูป { tag: text }"); foreach (var prop in fieldsDoc.RootElement.EnumerateObject()) { if (!IsScalar(prop.Value.ValueKind)) return BadRequest($"`fields.{prop.Name}` ต้องเป็นค่า scalar (string/number/bool/null)"); } fieldsSnapshotJson = fieldsDoc.RootElement.GetRawText(); } catch (JsonException) { return BadRequest("`fields` ไม่ใช่ JSON ที่ถูกต้อง"); } } var now = DateTimeOffset.UtcNow; var fileName = request.File.FileName; // hash ระหว่าง stream ผ่าน (pass แรก) แล้ว rewind ส่ง MinIO (pass สอง) await using var fileStream = request.File.OpenReadStream(); Stream uploadStream = fileStream; if (!fileStream.CanSeek) { // IFormFile ปกติ seek ได้ — กันเหลือ: buffer ก่อน var buffered = new MemoryStream(); await fileStream.CopyToAsync(buffered, ct); uploadStream = buffered; } var contentHash = await hasher.ComputeSha256HexAsync(uploadStream, ct); uploadStream.Seek(0, SeekOrigin.Begin); // สร้าง Document ถ้ายังไม่มี (FR-5) var doc = await db.Documents.FirstOrDefaultAsync(d => d.Id == documentId, ct); var createdDoc = false; if (doc is null) { doc = new Document { Id = documentId, FileName = fileName, AddInSignature = string.Empty, // ฝั่ง add-in stamp ในไฟล์เอง CreatedAt = now, }; db.Documents.Add(doc); createdDoc = true; } // จอง versionNumber = max+1 — ถ้าชน unique index แปลว่ามี request คู่แข่ง ให้คำนวณใหม่ DocumentVersion version = null!; for (var attempt = 1; ; attempt++) { var nextNumber = await db.DocumentVersions .Where(v => v.DocumentId == documentId) .Select(v => (int?)v.VersionNumber) .MaxAsync(ct) ?? 0; nextNumber++; version = new DocumentVersion { Id = Guid.NewGuid(), DocumentId = documentId, VersionNumber = nextNumber, ObjectKey = $"{documentId}/v{nextNumber:D4}_{request.ClientVersionId}.docx", ContentHash = contentHash, CreatedByUserId = currentUser.UserId, // จาก claim `sub` เท่านั้น CreatedAt = now, FieldsSnapshotJson = fieldsSnapshotJson, }; db.DocumentVersions.Add(version); try { await db.SaveChangesAsync(ct); break; } catch (DbUpdateException ex) when (IsUniqueViolation(ex) && attempt < MaxVersionInsertRetries) { logger.LogWarning( "Version number conflict documentId={DocumentId} attempt={Attempt} userId={UserId} — retry", documentId, attempt, currentUser.UserId); db.Entry(version).State = EntityState.Detached; if (createdDoc) { // document อาจถูกสร้างโดย request คู่แข่งไปแล้ว — ถอดออกจาก tracker แล้วอ่านใหม่ var existing = await db.Documents.AsNoTracking() .FirstOrDefaultAsync(d => d.Id == documentId, ct); if (existing is not null) { db.Entry(doc).State = EntityState.Detached; doc = existing; createdDoc = false; } } } } // upload ลง MinIO หลังจองเลข version สำเร็จ (key มี versionNumber อยู่ข้างใน) try { await storage.PutAsync(minioOptions.Value.VersionsBucket, version.ObjectKey, uploadStream, DocxContentType, ct); } catch (AmazonServiceException ex) { logger.LogError(ex, "MinIO upload failed documentId={DocumentId} versionId={VersionId} userId={UserId}", documentId, version.Id, currentUser.UserId); // ถอน record ที่เพิ่ง insert — ไม่ทิ้ง row ที่ชี้ไฟล์ที่ไม่มีจริง db.DocumentVersions.Remove(version); if (createdDoc) db.Documents.Remove(doc); await db.SaveChangesAsync(CancellationToken.None); return StatusCode(StatusCodes.Status502BadGateway, "อัปโหลดไฟล์ขึ้น storage ไม่สำเร็จ"); } 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)); } /// FR-6 — version history เรียงใหม่ → เก่า (พร้อม snapshot fields ต่อ version) [HttpGet("{documentId:guid}/versions")] [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task>> GetVersions(Guid documentId, CancellationToken ct) { var exists = await db.Documents.AnyAsync(d => d.Id == documentId, ct); if (!exists) return NotFound($"ไม่พบ document {documentId}"); var rows = await db.DocumentVersions .AsNoTracking() .Where(v => v.DocumentId == documentId) .OrderByDescending(v => v.VersionNumber) .Select(v => new { v.Id, v.VersionNumber, v.CreatedAt, v.CreatedByUserId, v.ContentHash, v.FieldsSnapshotJson, }) .ToListAsync(ct); return Ok(rows.Select(r => new VersionListItem( r.Id, r.VersionNumber, r.CreatedAt, r.CreatedByUserId, r.ContentHash, ParseSnapshot(r.FieldsSnapshotJson))).ToList()); } /// FR-7 — ดาวน์โหลด version ที่ระบุ (proxy stream จาก MinIO — ไม่ expose MinIO ตรงๆ) [HttpGet("{documentId:guid}/versions/{versionId:guid}/download")] [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task DownloadVersion(Guid documentId, Guid versionId, CancellationToken ct) { 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 }) .FirstOrDefaultAsync(ct); if (row is null) return NotFound($"ไม่พบ version {versionId} ของ document {documentId}"); Stream stream; try { stream = await storage.GetStreamAsync(minioOptions.Value.VersionsBucket, row.ObjectKey, ct); } catch (KeyNotFoundException) { logger.LogError("Object missing in storage documentId={DocumentId} versionId={VersionId} key={ObjectKey}", documentId, versionId, row.ObjectKey); return NotFound("ไฟล์ของ version นี้ไม่พบใน storage"); } logger.LogInformation("Version download documentId={DocumentId} versionId={VersionId} userId={UserId}", documentId, versionId, currentUser.UserId); var downloadName = row.FileName.EndsWith(".docx", StringComparison.OrdinalIgnoreCase) ? row.FileName : row.FileName + ".docx"; return File(stream, DocxContentType, downloadName); } private 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 เสมอ (เช็คข้างบน) private 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); private static bool IsScalar(JsonValueKind kind) => kind is JsonValueKind.String or JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False or JsonValueKind.Null; private static object? ParseSnapshot(string? json) { if (json is null) return null; using var doc = JsonDocument.Parse(json); return doc.RootElement.Clone(); // clone ก่อน dispose JsonDocument เพื่อให้ serialize ต่อได้ } private static bool IsUniqueViolation(DbUpdateException ex) => ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }; }