feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -0,0 +1,102 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using D3ROVoice.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace D3ROVoice.Api.Services;
public sealed class AdminOperationException : Exception
{
public AdminOperationException(string message) : base(message) { }
}
public interface IAdminOperationService
{
Task<JsonElement> ExecuteAsync(
string actorEmail,
string operation,
string idempotencyKey,
object request,
string targetType,
Func<object, string> targetId,
string memo,
Func<Task<object?>> readBefore,
Func<Task<object>> mutate);
}
public sealed class AdminOperationService : IAdminOperationService
{
private readonly AppDbContext _db;
public AdminOperationService(AppDbContext db)
{
_db = db;
}
public async Task<JsonElement> ExecuteAsync(
string actorEmail,
string operation,
string idempotencyKey,
object request,
string targetType,
Func<object, string> targetId,
string memo,
Func<Task<object?>> readBefore,
Func<Task<object>> mutate)
{
actorEmail = actorEmail.Trim().ToLowerInvariant();
memo = memo.Trim();
if (actorEmail.Length is < 3 or > 150) throw new AdminOperationException("invalid_actor");
if (!Guid.TryParseExact(idempotencyKey, "D", out _)) throw new AdminOperationException("invalid_idempotency_key");
if (memo.Length is < 3 or > 1000) throw new AdminOperationException("invalid_audit_memo");
var requestJson = JsonSerializer.Serialize(request);
var requestHash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes($"{operation}:{requestJson}")));
await using var transaction = await _db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable);
var existing = await _db.AdminOperationRequests.AsNoTracking().SingleOrDefaultAsync(entry =>
entry.ActorEmail == actorEmail && entry.IdempotencyKey == idempotencyKey);
if (existing != null)
{
if (existing.Operation != operation || existing.RequestHash != requestHash)
throw new AdminOperationException("idempotency_key_reused_with_different_request");
using var replay = JsonDocument.Parse(existing.ResponseJson);
return replay.RootElement.Clone();
}
var before = await readBefore();
var result = await mutate();
var responseJson = JsonSerializer.Serialize(result);
var resolvedTargetId = targetId(result);
if (string.IsNullOrWhiteSpace(resolvedTargetId) || resolvedTargetId.Length > 200)
throw new AdminOperationException("invalid_audit_target");
_db.AdminOperationRequests.Add(new AdminOperationRequest
{
ActorEmail = actorEmail,
IdempotencyKey = idempotencyKey,
Operation = operation,
RequestHash = requestHash,
ResponseJson = responseJson,
CreatedAt = DateTime.UtcNow
});
_db.AdminAuditEntries.Add(new AdminAuditEntry
{
ActorEmail = actorEmail,
Action = operation,
TargetType = targetType,
TargetId = resolvedTargetId,
BeforeJson = before == null ? null : JsonSerializer.Serialize(before),
AfterJson = responseJson,
Memo = memo,
IdempotencyKey = idempotencyKey,
CreatedAt = DateTime.UtcNow
});
await _db.SaveChangesAsync();
await transaction.CommitAsync();
using var response = JsonDocument.Parse(responseJson);
return response.RootElement.Clone();
}
}