eCourt-Doc-Edit/backend/WordVersioning.Api/Services/ObjectStorageService.cs

184 lines
9.5 KiB
C#
Raw Normal View History

2026-08-19 10:54:55 +07:00
using Amazon.Runtime;
using Amazon.S3;
using Amazon.S3.Model;
using Microsoft.Extensions.Options;
namespace WordVersioning.Api.Services;
public interface IObjectStorageService
{
/// <summary>อัปโหลด stream ไป bucket (content ต้อง seek ได้ — จะได้กำหนด ContentLength และ retry ได้)</summary>
Task PutAsync(string bucket, string key, Stream content, string contentType, CancellationToken ct = default);
/// <summary>ดึง object กลับมาเป็น stream (proxy ต่อให้ client) — stream นี้ dispose แล้วจะปิด S3 response ให้ด้วย</summary>
Task<Stream> GetStreamAsync(string bucket, string key, CancellationToken ct = default);
2026-08-19 23:06:57 +07:00
/// <summary>สร้าง presigned GET URL อายุสั้น — ให้เปิดในเบราว์เซอร์ของผู้ใช้โดยตรง
/// (webview ของ Word ทิ้ง blob download แบบเงียบๆ — spec อนุญาต presigned URL อายุสั้นสำหรับดาวน์โหลด)</summary>
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>
2026-09-08 14:01:13 +07:00
string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry, string? contentDisposition = null);
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>
2026-09-10 11:49:16 +07:00
/// <summary>FR-16 — ตั้ง lifecycle rule ให้ object ใต้ prefix หนึ่งหมดอายุเองตามจำนวนวัน
/// (idempotent ตาม rule id และ sync ค่า days ใหม่ถ้า config เปลี่ยนหลัง boot แรก — days &lt; 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);
2026-08-19 10:54:55 +07:00
}
/// <summary>Wrapper เรียก MinIO ผ่าน AWSSDK.S3 (S3-compatible API)</summary>
public class ObjectStorageService : IObjectStorageService
{
private readonly IAmazonS3 _s3;
2026-08-19 23:06:57 +07:00
private readonly bool _useHttps;
2026-08-19 10:54:55 +07:00
private readonly ILogger<ObjectStorageService> _logger;
public ObjectStorageService(IOptions<MinioOptions> options, ILogger<ObjectStorageService> logger)
{
var opt = options.Value;
2026-08-19 23:06:57 +07:00
_useHttps = opt.UseSSL;
2026-08-19 10:54:55 +07:00
var config = new AmazonS3Config
{
ServiceURL = (opt.UseSSL ? "https://" : "http://") + opt.Endpoint,
ForcePathStyle = true, // จำเป็นสำหรับ MinIO (path-style bucket)
};
_s3 = new AmazonS3Client(new BasicAWSCredentials(opt.AccessKey, opt.SecretKey), config);
_logger = logger;
}
public async Task PutAsync(string bucket, string key, Stream content, string contentType, CancellationToken ct = default)
{
2026-08-19 12:15:20 +07:00
// จับความยาวไว้ก่อน — AWSSDK 4.x dispose input stream ให้หลัง upload เสร็จ อ่าน .Length ตอนหลังไม่ได้
long? length = content.CanSeek ? content.Length : null;
2026-08-19 10:54:55 +07:00
var request = new PutObjectRequest
{
BucketName = bucket,
Key = key,
InputStream = content,
ContentType = contentType,
};
2026-08-19 12:15:20 +07:00
if (length.HasValue)
2026-08-19 10:54:55 +07:00
{
// ระบุความยาวชัดๆ ไม่ต้องให้ SDK ไป buffer ทั้ง stream
2026-08-19 12:15:20 +07:00
request.Headers.ContentLength = length.Value;
2026-08-19 10:54:55 +07:00
}
await _s3.PutObjectAsync(request, ct);
2026-08-19 12:15:20 +07:00
_logger.LogInformation("Uploaded object {Bucket}/{Key} ({Length} bytes)", bucket, key, length);
2026-08-19 10:54:55 +07:00
}
public async Task<Stream> GetStreamAsync(string bucket, string key, CancellationToken ct = default)
{
GetObjectResponse response;
try
{
response = await _s3.GetObjectAsync(new GetObjectRequest { BucketName = bucket, Key = key }, ct);
}
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
throw new KeyNotFoundException($"Object {bucket}/{key} not found");
}
_logger.LogInformation("Fetching object {Bucket}/{Key} ({Length} bytes)", bucket, key, response.ContentLength);
return new S3ResponseStream(response);
}
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>
2026-09-08 14:01:13 +07:00
public string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry, string? contentDisposition = null)
2026-08-19 23:06:57 +07:00
{
var request = new GetPreSignedUrlRequest
{
BucketName = bucket,
Key = key,
Verb = HttpVerb.GET,
Expires = DateTime.UtcNow + expiry,
};
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>
2026-09-08 14:01:13 +07:00
if (contentDisposition is not null)
request.ResponseHeaderOverrides.ContentDisposition = contentDisposition;
2026-08-19 23:06:57 +07:00
// GetPreSignedURL เป็น sync (เซ็น SigV4 ฝั่ง client ไม่ยิง network)
var url = _s3.GetPreSignedURL(request);
// AWSSDK v4 บังคับ https ใน presigned URL เสมอ (ไม่สน UseHttp) — สลับ scheme เองตาม config ได้
// เพราะ SigV4 เซ็นค่า host header (host:port) ไม่รวม scheme → signature ยังถูกต้อง
if (!_useHttps && url.StartsWith("https://", StringComparison.Ordinal))
{
url = "http://" + url["https://".Length..];
}
_logger.LogInformation("Issued presigned GET {Bucket}/{Key} expiry={Expiry}s", bucket, key, expiry.TotalSeconds);
return url;
}
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>
2026-09-10 11:49:16 +07:00
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;
}
2026-08-19 10:54:55 +07:00
/// <summary>Stream ที่ครอบ GetObjectResponse — dispose stream = dispose response ทั้งอัน
/// (ไม่งั้น controller ต้องรับ response มาด้วยแล้วจัดการ lifetime เอง ยุ่ง)</summary>
private sealed class S3ResponseStream(GetObjectResponse response) : Stream
{
private readonly Stream _inner = response.ResponseStream;
public override bool CanRead => _inner.CanRead;
public override bool CanSeek => _inner.CanSeek;
public override bool CanWrite => _inner.CanWrite;
public override long Length => _inner.Length;
public override long Position { get => _inner.Position; set => _inner.Position = value; }
public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count);
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) =>
_inner.ReadAsync(buffer, offset, count, ct);
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken ct = default) => _inner.ReadAsync(buffer, ct);
public override long Seek(long offset, SeekOrigin origin) => _inner.Seek(offset, origin);
public override void Flush() => _inner.Flush();
public override void SetLength(long value) => _inner.SetLength(value);
public override void Write(byte[] buffer, int offset, int count) => _inner.Write(buffer, offset, count);
protected override void Dispose(bool disposing)
{
if (disposing) response.Dispose();
base.Dispose(disposing);
}
}
}