using Amazon.Runtime; using Amazon.S3; using Amazon.S3.Model; using Microsoft.Extensions.Options; namespace WordVersioning.Api.Services; public interface IObjectStorageService { /// อัปโหลด stream ไป bucket (content ต้อง seek ได้ — จะได้กำหนด ContentLength และ retry ได้) Task PutAsync(string bucket, string key, Stream content, string contentType, CancellationToken ct = default); /// ดึง object กลับมาเป็น stream (proxy ต่อให้ client) — stream นี้ dispose แล้วจะปิด S3 response ให้ด้วย Task GetStreamAsync(string bucket, string key, CancellationToken ct = default); /// FR-16 — ตั้ง lifecycle rule ให้ object ใต้ prefix หนึ่งหมดอายุเองตามจำนวนวัน /// (idempotent ตาม rule id และ sync ค่า days ใหม่ถ้า config เปลี่ยนหลัง boot แรก — days < 1 คือ throw) /// ใช้กับไฟล์ร่างที่เก็บชั่วคราวใน bucket versions ใต้ `drafts/` /// คืน true เมื่อเขียน config ใหม่, false เมื่อ rule ถูกต้องอยู่แล้ว (ใช้ assert ใน test) Task EnsurePrefixExpiryAsync(string bucket, string prefix, int days, string ruleId, CancellationToken ct = default); } /// Wrapper เรียก MinIO ผ่าน AWSSDK.S3 (S3-compatible API) public class ObjectStorageService : IObjectStorageService { private readonly IAmazonS3 _s3; private readonly bool _useHttps; private readonly ILogger _logger; public ObjectStorageService(IOptions options, ILogger logger) { var opt = options.Value; _useHttps = opt.UseSSL; 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) { // จับความยาวไว้ก่อน — AWSSDK 4.x dispose input stream ให้หลัง upload เสร็จ อ่าน .Length ตอนหลังไม่ได้ long? length = content.CanSeek ? content.Length : null; var request = new PutObjectRequest { BucketName = bucket, Key = key, InputStream = content, ContentType = contentType, }; if (length.HasValue) { // ระบุความยาวชัดๆ ไม่ต้องให้ SDK ไป buffer ทั้ง stream request.Headers.ContentLength = length.Value; } await _s3.PutObjectAsync(request, ct); _logger.LogInformation("Uploaded object {Bucket}/{Key} ({Length} bytes)", bucket, key, length); } public async Task 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); } public async Task EnsurePrefixExpiryAsync(string bucket, string prefix, int days, string ruleId, CancellationToken ct = default) { ArgumentOutOfRangeException.ThrowIfLessThan(days, 1); var rules = new List(); 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; } /// Stream ที่ครอบ GetObjectResponse — dispose stream = dispose response ทั้งอัน /// (ไม่งั้น controller ต้องรับ response มาด้วยแล้วจัดการ lifetime เอง ยุ่ง) 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 ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) => _inner.ReadAsync(buffer, offset, count, ct); public override ValueTask ReadAsync(Memory 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); } } }