fix: align backend APIs and upload flow
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
using IM.Admin.Api;
|
||||
using IM.Admin.Data;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace IM.Admin.Services;
|
||||
public static class AdminBootstrap
|
||||
{
|
||||
public static async Task Run(IServiceProvider services, string[] args)
|
||||
{
|
||||
using var scope = services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<AdminDb>();
|
||||
if (args.Contains("--migrate")) { await db.Database.MigrateAsync(); Console.WriteLine("管理数据库迁移完成"); }
|
||||
if (!args.Contains("--init-admin") && !args.Contains("--reset-admin")) return;
|
||||
var name = Environment.GetEnvironmentVariable("IM_ADMIN_ACCOUNT")?.Trim().ToLowerInvariant() ?? throw new InvalidOperationException("IM_ADMIN_ACCOUNT is required");
|
||||
var password = Environment.GetEnvironmentVariable("IM_ADMIN_PASSWORD") ?? throw new InvalidOperationException("IM_ADMIN_PASSWORD is required");
|
||||
AuthEndpoints.CheckPassword(password);
|
||||
var a = await db.Accounts.SingleOrDefaultAsync(x => x.Account == name);
|
||||
if (args.Contains("--init-admin")) {
|
||||
if (await db.Accounts.AnyAsync()) throw new InvalidOperationException("已存在管理员,禁止重复初始化");
|
||||
a = new AdminAccount { Account = name, Name = name, Role = "super", Email = Environment.GetEnvironmentVariable("IM_ADMIN_EMAIL") ?? "" }; db.Accounts.Add(a);
|
||||
}
|
||||
if (a is null) throw new InvalidOperationException("管理员不存在");
|
||||
a.PasswordHash = scope.ServiceProvider.GetRequiredService<IPasswordHasher<AdminAccount>>().HashPassword(a, password);
|
||||
a.Stamp = Guid.NewGuid().ToString("N"); a.FailedAttempts = 0; a.LockedUntil = null;
|
||||
db.Audit.Add(new AuditRecord { ActorId = a.Id, ActorName = "部署初始化命令", Action = "初始化或重置管理员", TargetId = a.Id.ToString(), Reason = "部署命令操作", After = "凭据已更新" });
|
||||
await db.SaveChangesAsync(); Console.WriteLine("管理员凭据已更新;请清除临时密码环境变量。");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using IM.InitCommon.Management;
|
||||
using MySqlConnector;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace IM.Admin.Services;
|
||||
public sealed class HealthSampler(IConfiguration config, IServiceScopeFactory scopes)
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, DateTime> successes = new();
|
||||
public async Task<ServiceHealth[]> Read(CancellationToken ct)
|
||||
{
|
||||
using var scope = scopes.CreateScope(); var client = scope.ServiceProvider.GetRequiredService<InternalClient>();
|
||||
var tasks = new[] { "admin", "user", "contact", "group", "message", "file", "connector" }.Select(name => Check(name, async token => {
|
||||
var response = await client.Send<JsonElement>(name, "/internal/management/health", ct: token);
|
||||
if (response.GetProperty("status").GetString() != "healthy") throw new InvalidOperationException();
|
||||
return response.TryGetProperty("configVersion", out var version) ? version.GetInt64() : (long?)null;
|
||||
}, ct)).ToList();
|
||||
tasks.Add(Check("MySQL", async token => { await using var db = new MySqlConnection(config.GetConnectionString("Admin")); await db.OpenAsync(token); return null; }, ct));
|
||||
tasks.Add(Check("Redis", async token => { var options = ConfigurationOptions.Parse(config.GetConnectionString("Redis") ?? throw new InvalidOperationException()); options.ConnectTimeout = 2000; options.AbortOnConnectFail = true; using var redis = await ConnectionMultiplexer.ConnectAsync(options).WaitAsync(token); await redis.GetDatabase().PingAsync().WaitAsync(token); return null; }, ct));
|
||||
tasks.Add(Check("RabbitMQ(TCP)", async token => { using var tcp = new TcpClient(); await tcp.ConnectAsync(config["Management:RabbitHost"] ?? "rabbitmq", config.GetValue("Management:RabbitPort", 5672), token); return null; }, ct));
|
||||
tasks.Add(Check("Consul", async token => { using var http = new HttpClient(); using var response = await http.GetAsync((config["Management:ConsulUrl"] ?? throw new InvalidOperationException()).TrimEnd('/') + "/v1/status/leader", token); response.EnsureSuccessStatusCode(); return null; }, ct));
|
||||
return await Task.WhenAll(tasks);
|
||||
}
|
||||
async Task<ServiceHealth> Check(string name, Func<CancellationToken, Task<long?>> check, CancellationToken ct)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); timeout.CancelAfter(TimeSpan.FromSeconds(3)); var sw = Stopwatch.StartNew();
|
||||
try { var version = await check(timeout.Token); var now = DateTime.UtcNow; successes[name] = now; return new(name, "healthy", Math.Round(sw.Elapsed.TotalMilliseconds), now, now, null, version); }
|
||||
catch { return new(name, "unavailable", null, DateTime.UtcNow, successes.TryGetValue(name, out var time) ? time : null, "连接或就绪检查失败,请检查服务日志"); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using IM.Admin.Api;
|
||||
using IM.Admin.Data;
|
||||
using IM.InitCommon.Management;
|
||||
using MailKit.Security;
|
||||
using MimeKit;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace IM.Admin.Services;
|
||||
public sealed class InfrastructureService(AdminDb db, SettingsService settings, InternalClient client, IConfiguration config, IHostEnvironment environment)
|
||||
{
|
||||
public async Task Draft(string id, SettingInput input, HttpContext c)
|
||||
{
|
||||
ApiSupport.Reason(input.Reason); Validate(id, input.Value);
|
||||
var row = await db.Settings.SingleOrDefaultAsync(x => x.Id == id);
|
||||
if ((row?.Version ?? 0) != input.Version) throw new ApiError(409, "配置版本已变化,请刷新");
|
||||
if (row is null) { row = new SystemSetting { Id = id }; db.Settings.Add(row); }
|
||||
row.Draft = input.Value.ToJsonString(); row.TestedVersion = null; row.Version++; row.UpdatedAt = DateTime.UtcNow;
|
||||
var prior = settings.Unprotect(row.DraftSecret ?? row.Secret);
|
||||
var secret = string.IsNullOrWhiteSpace(input.Secret) ? prior : id == "storage" ? MergeSecrets(prior, input.Secret) : input.Secret;
|
||||
row.DraftSecret = settings.Protect(secret);
|
||||
db.Audit(c, "保存基础设施草稿", id, "", "草稿已更新;凭据" + (string.IsNullOrEmpty(input.Secret) ? "保持" : "已更换"), input.Reason); await db.SaveChangesAsync();
|
||||
}
|
||||
static string MergeSecrets(string previous, string next)
|
||||
{
|
||||
var old = JsonNode.Parse(string.IsNullOrEmpty(previous) ? "{}" : previous)!.AsObject(); var update = JsonNode.Parse(next)!.AsObject();
|
||||
foreach (var p in update) { var entry = old[p.Key]?.AsObject() ?? new JsonObject(); foreach (var value in p.Value!.AsObject()) if (!string.IsNullOrWhiteSpace(value.Value?.GetValue<string>())) entry[value.Key] = value.Value!.DeepClone(); old[p.Key] = entry.DeepClone(); }
|
||||
return old.ToJsonString();
|
||||
}
|
||||
public async Task Test(string id, InfraTest input, HttpContext c)
|
||||
{
|
||||
var row = await Row(id, input.Version); var value = JsonNode.Parse(row.Draft!)!.AsObject();
|
||||
Validate(id, value);
|
||||
if (id == "smtp") {
|
||||
using var smtp = await Connect(value, settings.Unprotect(row.DraftSecret));
|
||||
if (!System.Net.Mail.MailAddress.TryCreate(input.Recipient, out _)) throw new ApiError(400, "请填写有效测试收件人");
|
||||
await SendMessage(smtp, value, input.Recipient!, "IM SMTP 连接测试", "这是一封由后台管理员主动发起的连接测试邮件。");
|
||||
} else await client.Send<JsonElement>("file", "/internal/management/storage/test", new InfrastructureEnvelope(row.Version, value, settings.Unprotect(row.DraftSecret)));
|
||||
row.TestedVersion = row.Version; db.Audit(c, "测试基础设施连接", id, "", "测试成功", "管理员主动执行连通性测试"); await db.SaveChangesAsync();
|
||||
}
|
||||
public async Task Activate(string id, SettingInput input, HttpContext c)
|
||||
{
|
||||
ApiSupport.Reason(input.Reason); var row = await Row(id, input.Version);
|
||||
if (row.TestedVersion != row.Version) throw new ApiError(409, "请先测试当前草稿的连接");
|
||||
if (id == "storage") await client.Send<JsonElement>("file", "/internal/management/storage/validate", new InfrastructureEnvelope(row.Version, JsonNode.Parse(row.Draft!)!.AsObject(), settings.Unprotect(row.DraftSecret)));
|
||||
var before = row.Value; row.Value = row.Draft!; row.Secret = row.DraftSecret!; row.Draft = null; row.DraftSecret = null; row.Version++; row.UpdatedAt = DateTime.UtcNow;
|
||||
db.Audit(c, "启用基础设施配置", id, before, row.Value, input.Reason); await db.SaveChangesAsync();
|
||||
}
|
||||
async Task<SystemSetting> Row(string id, long version)
|
||||
{
|
||||
if (id is not "smtp" and not "storage") throw new ApiError(404, "分组不存在");
|
||||
var row = await db.Settings.SingleOrDefaultAsync(x => x.Id == id) ?? throw new ApiError(404, "请先保存草稿");
|
||||
if (row.Draft is null || row.Version != version) throw new ApiError(409, "草稿不存在或版本已变化"); return row;
|
||||
}
|
||||
void Validate(string id, JsonObject value)
|
||||
{
|
||||
if (id == "smtp") {
|
||||
var allowed = new[] { "host", "port", "username", "from", "tls", "enabled" };
|
||||
if (value.Any(x => !allowed.Contains(x.Key))) throw new ApiError(400, "不支持的邮件字段");
|
||||
ValidateHost(value["host"]?.GetValue<string>() ?? "", config);
|
||||
if (value["port"]?.GetValue<int>() is not (>= 1 and <= 65535) || value["tls"]?.GetValue<string>() is not ("starttls" or "ssl") || !System.Net.Mail.MailAddress.TryCreate(value["from"]?.GetValue<string>(), out _)) throw new ApiError(400, "邮件端口、TLS 或发件人无效");
|
||||
} else if (id == "storage") {
|
||||
if (value["providers"] is not JsonObject providers || providers.Count == 0 || value["defaultProviderCode"] is null || !providers.ContainsKey(value["defaultProviderCode"]!.GetValue<string>())) throw new ApiError(400, "请配置有效的默认存储提供商");
|
||||
var allowed = new[] { "providerCode", "providerType", "enabled", "bucket", "publicBucket", "region", "endpoint", "publicBaseUrl", "localRootPath", "localUploadApiBaseUrl", "uploadUrlExpiresIn", "downloadUrlExpiresIn", "maxObjectSizeBytes", "minPartSizeBytes", "defaultPartSizeBytes", "maxPartCount" };
|
||||
if (value.Any(x => x.Key != "providers" && x.Key != "defaultProviderCode")) throw new ApiError(400, "未知存储字段");
|
||||
foreach (var (code, node) in providers) {
|
||||
var p = node!.AsObject(); if (p.Any(x => !allowed.Contains(x.Key)) || p["providerCode"]?.GetValue<string>() != code) throw new ApiError(400, "存储字段不正确,凭据需独立提交");
|
||||
var type = p["providerType"]?.GetValue<int>(); if (type is not 1 and not 2 and not 5) throw new ApiError(400, "仅支持本地或 S3 兼容存储");
|
||||
if (type != 1) { if (!Uri.TryCreate(p["endpoint"]?.GetValue<string>(), UriKind.Absolute, out var uri) || uri.Scheme is not ("https" or "http") || !string.IsNullOrEmpty(uri.UserInfo)) throw new ApiError(400, "存储端点无效"); ValidateHost(uri.Host, config); }
|
||||
}
|
||||
} else throw new ApiError(404, "分组不存在");
|
||||
}
|
||||
public static void ValidateHost(string host, IConfiguration config)
|
||||
{
|
||||
var allowed = config.GetSection("Management:AllowedInfrastructureHosts").Get<string[]>() ?? [];
|
||||
if (string.IsNullOrWhiteSpace(host) || !allowed.Contains(host, StringComparer.OrdinalIgnoreCase)) throw new ApiError(400, "该目标不在部署允许列表中");
|
||||
}
|
||||
async Task<MailKit.Net.Smtp.SmtpClient> Connect(JsonObject value, string secret)
|
||||
{
|
||||
Validate("smtp", value); var smtp = new MailKit.Net.Smtp.SmtpClient { Timeout = 10000 };
|
||||
if (environment.IsDevelopment() && config["Management:DevelopmentSmtpCertificateSha256"] is { Length: > 0 } pin)
|
||||
smtp.ServerCertificateValidationCallback = (_, certificate, _, errors) => errors == System.Net.Security.SslPolicyErrors.None || certificate is not null && string.Equals(certificate.GetCertHashString(System.Security.Cryptography.HashAlgorithmName.SHA256), pin, StringComparison.OrdinalIgnoreCase);
|
||||
try { await smtp.ConnectAsync(value["host"]!.GetValue<string>(), value["port"]!.GetValue<int>(), value["tls"]!.GetValue<string>() == "ssl" ? SecureSocketOptions.SslOnConnect : SecureSocketOptions.StartTls);
|
||||
if (!string.IsNullOrWhiteSpace(value["username"]?.GetValue<string>())) await smtp.AuthenticateAsync(value["username"]!.GetValue<string>(), secret);
|
||||
return smtp;
|
||||
} catch { smtp.Dispose(); throw new ApiError(503, "邮件连接失败,请检查允许列表、TLS 和凭据"); }
|
||||
}
|
||||
static async Task SendMessage(MailKit.Net.Smtp.SmtpClient smtp, JsonObject v, string recipient, string title, string text)
|
||||
{ var msg = new MimeMessage(); msg.From.Add(MailboxAddress.Parse(v["from"]!.GetValue<string>())); msg.To.Add(MailboxAddress.Parse(recipient)); msg.Subject = title; msg.Body = new TextPart("plain") { Text = text }; await smtp.SendAsync(msg); await smtp.DisconnectAsync(true); }
|
||||
public async Task<bool> MailEnabled() { var row = await db.Settings.AsNoTracking().SingleOrDefaultAsync(x => x.Id == "smtp"); return row is not null && JsonNode.Parse(row.Value)?["enabled"]?.GetValue<bool>() == true; }
|
||||
public async Task Send(string recipient, string title, string text) { var row = await db.Settings.AsNoTracking().SingleAsync(x => x.Id == "smtp"); var value = JsonNode.Parse(row.Value)!.AsObject(); using var smtp = await Connect(value, settings.Unprotect(row.Secret)); await SendMessage(smtp, value, recipient, title, text); }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Data;
|
||||
using IM.Admin.Data;
|
||||
using IM.InitCommon.Management;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace IM.Admin.Services;
|
||||
public sealed class OperationWorker(IServiceScopeFactory scopes, ILogger<OperationWorker> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(2));
|
||||
while (await timer.WaitForNextTickAsync(ct)) {
|
||||
try { await Process(ct); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch (Exception e) { logger.LogWarning("Operation dispatcher unavailable: {Type}", e.GetType().Name); }
|
||||
}
|
||||
}
|
||||
public async Task Process(CancellationToken ct)
|
||||
{
|
||||
using var scope = scopes.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<AdminDb>();
|
||||
var id = await db.Operations.Where(x => x.Status == "pending" && x.NextAttemptAt <= DateTime.UtcNow).OrderBy(x => x.CreatedAt).Select(x => (Guid?)x.Id).FirstOrDefaultAsync(ct);
|
||||
if (id is null) return;
|
||||
await using var tx = await db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
|
||||
// A row lock serializes dispatchers. Receipt idempotency covers response loss after the domain commit.
|
||||
var op = await db.Operations.FromSqlInterpolated($"SELECT * FROM admin_operations WHERE Id = {id.Value} FOR UPDATE").SingleAsync(ct);
|
||||
if (op.Status != "pending" || op.NextAttemptAt > DateTime.UtcNow) return;
|
||||
try {
|
||||
var receipt = op.Action is "警告" or "驳回" ? new ActionReceipt(op.TargetId.ToString(), "处理中", op.Action == "驳回" ? "已驳回" : "已处理") : await scope.ServiceProvider.GetRequiredService<InternalClient>().Send<ActionReceipt>(op.Type, "/internal/management/action", new InternalAction(op.Id, op.ActorId, op.TargetId, op.Action, op.Reason), ct);
|
||||
if (op.ReportId.HasValue) {
|
||||
var r = await db.Reports.SingleAsync(x => x.Id == op.ReportId, ct);
|
||||
r.Status = op.Action == "驳回" ? "已驳回" : "已处理"; r.Result = op.Action + ":" + op.Reason; r.ClosedAt = DateTime.UtcNow; r.Version++;
|
||||
}
|
||||
op.Status = "completed"; op.CompletedAt = DateTime.UtcNow; op.Error = null;
|
||||
db.Audit.Add(new AuditRecord { ActorId = op.ActorId, ActorName = op.ActorName, Action = op.Action, TargetId = op.TargetId.ToString(), TargetName = receipt.TargetName, Before = receipt.Before, After = receipt.After, Reason = op.Reason, ReportId = op.ReportId, OperationId = op.Id });
|
||||
} catch (Exception e) when (e is not OperationCanceledException || !ct.IsCancellationRequested) {
|
||||
op.Attempts++; op.Error = e is InternalServiceException se ? se.Message : "业务服务暂不可用,可重试原任务";
|
||||
op.Status = op.Attempts >= 3 ? "failed" : "pending"; op.NextAttemptAt = DateTime.UtcNow.AddSeconds(10 * op.Attempts);
|
||||
db.Audit.Add(new AuditRecord { ActorId = op.ActorId, ActorName = op.ActorName, Action = op.Action, TargetId = op.TargetId.ToString(), TargetName = op.TargetId.ToString(), Before = "待确认", After = "未确认", Reason = op.Reason, ReportId = op.ReportId, Result = $"第 {op.Attempts} 次执行失败;任务 {op.Id}" });
|
||||
}
|
||||
await db.SaveChangesAsync(ct); await tx.CommitAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using IM.Admin.Api;
|
||||
using IM.Admin.Data;
|
||||
using IM.InitCommon.Management;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace IM.Admin.Services;
|
||||
public sealed class SettingsService(AdminDb db, IDataProtectionProvider protection, IConfiguration config)
|
||||
{
|
||||
public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||
public static readonly Dictionary<string, string[]> Fields = new() {
|
||||
["platform"] = ["platformName", "description", "supportEmail"], ["account"] = ["registrationEnabled", "passwordMinLength"],
|
||||
["social"] = ["friendLimit", "createdGroupLimit", "groupMemberLimit", "defaultJoinAuthority"],
|
||||
["messaging"] = ["textLimit", "recallMinutes", "uploadMaxBytes", "allowedFileTypes"],
|
||||
["reports"] = ["reportCategories", "reportsPerDay", "reportCooldownMinutes"],
|
||||
["security"] = ["clientAccessMinutes", "clientRefreshDays", "adminSessionMinutes", "adminLockThreshold", "adminLockMinutes"],
|
||||
};
|
||||
byte[] Key() { var key = Convert.FromBase64String(config["Management:CredentialKey"] ?? throw new InvalidOperationException("Management:CredentialKey is required")); if (key.Length != 32) throw new InvalidOperationException("CredentialKey must be 32 bytes"); return key; }
|
||||
public string Protect(string secret) {
|
||||
if (string.IsNullOrEmpty(secret)) return "";
|
||||
var nonce = RandomNumberGenerator.GetBytes(12); var plain = Encoding.UTF8.GetBytes(secret); var cipher = new byte[plain.Length]; var tag = new byte[16];
|
||||
using var aes = new AesGcm(Key(), 16); aes.Encrypt(nonce, plain, cipher, tag, Encoding.UTF8.GetBytes("IM.Admin.Config.v1"));
|
||||
return "v1:" + Convert.ToBase64String(nonce.Concat(tag).Concat(cipher).ToArray());
|
||||
}
|
||||
public string Unprotect(string? secret) {
|
||||
if (string.IsNullOrEmpty(secret)) return "";
|
||||
if (!secret.StartsWith("v1:")) throw new InvalidOperationException("Unsupported credential encryption version");
|
||||
var data = Convert.FromBase64String(secret[3..]); var plain = new byte[data.Length - 28];
|
||||
using var aes = new AesGcm(Key(), 16); aes.Decrypt(data.AsSpan(0,12), data.AsSpan(28), data.AsSpan(12,16), plain, Encoding.UTF8.GetBytes("IM.Admin.Config.v1"));
|
||||
return Encoding.UTF8.GetString(plain);
|
||||
}
|
||||
public static JsonObject Defaults(string id)
|
||||
{
|
||||
if (!Fields.TryGetValue(id, out var fields)) throw new ApiError(404, "配置分组不存在");
|
||||
var all = JsonSerializer.SerializeToNode(new Policy(), Json)!.AsObject();
|
||||
return new JsonObject(fields.Select(k => new KeyValuePair<string, JsonNode?>(k, all[k]?.DeepClone())));
|
||||
}
|
||||
public async Task<Policy> Policy()
|
||||
{
|
||||
var merged = JsonSerializer.SerializeToNode(new Policy(), Json)!.AsObject();
|
||||
var rows = await db.Settings.AsNoTracking().Where(x => x.Id != "storage" && x.Id != "smtp").ToListAsync();
|
||||
foreach (var row in rows) foreach (var p in JsonNode.Parse(row.Value)!.AsObject()) merged[p.Key] = p.Value?.DeepClone();
|
||||
var policy = merged.Deserialize<Policy>(Json)!; policy.Version = rows.Sum(x => x.Version); return policy;
|
||||
}
|
||||
public async Task<object> List()
|
||||
{
|
||||
var rows = await db.Settings.AsNoTracking().ToListAsync();
|
||||
return Fields.Keys.Concat(["storage", "smtp"]).Select(id => {
|
||||
var row = rows.SingleOrDefault(x => x.Id == id);
|
||||
return new { id, version = row?.Version ?? 0, value = row is null ? (Fields.ContainsKey(id) ? Defaults(id) : new JsonObject()) : JsonNode.Parse(row.Value), draft = row?.Draft is null ? null : JsonNode.Parse(row.Draft), secretSet = !string.IsNullOrEmpty(row?.Secret), draftSecretSet = !string.IsNullOrEmpty(row?.DraftSecret), tested = row?.TestedVersion == row?.Version && row is not null, updatedAt = row?.UpdatedAt };
|
||||
}).ToArray();
|
||||
}
|
||||
public async Task Save(string id, SettingInput input, HttpContext c)
|
||||
{
|
||||
ApiSupport.Reason(input.Reason);
|
||||
if (!Fields.ContainsKey(id)) throw new ApiError(400, "基础设施配置需走草稿发布流程");
|
||||
Validate(id, input.Value);
|
||||
var row = await db.Settings.SingleOrDefaultAsync(x => x.Id == id);
|
||||
if ((row?.Version ?? 0) != input.Version) throw new ApiError(409, "配置版本已变化,请刷新后重试");
|
||||
if (row is null) { row = new SystemSetting { Id = id }; db.Settings.Add(row); }
|
||||
var before = row.Value; row.Value = input.Value.ToJsonString(); row.Version++; row.UpdatedAt = DateTime.UtcNow;
|
||||
db.Audit(c, "修改系统配置", id, before, row.Value, input.Reason); await db.SaveChangesAsync();
|
||||
}
|
||||
public static void Validate(string id, JsonObject value)
|
||||
{
|
||||
if (!Fields.TryGetValue(id, out var fields) || value.Count != fields.Length || fields.Any(x => !value.ContainsKey(x))) throw new ApiError(400, "配置字段不完整或包含未知字段");
|
||||
try {
|
||||
var merged = JsonSerializer.SerializeToNode(new Policy(), Json)!.AsObject(); foreach (var p in value) merged[p.Key] = p.Value?.DeepClone();
|
||||
var p1 = merged.Deserialize<Policy>(Json)!;
|
||||
if (string.IsNullOrWhiteSpace(p1.PlatformName) || p1.PlatformName.Length > 60 || p1.Description.Length > 500 || p1.PasswordMinLength is < 6 or > 50 || p1.AdminSessionMinutes is < 5 or > 10080 || p1.AdminLockThreshold is < 1 or > 20 || p1.AdminLockMinutes is < 1 or > 1440 || p1.DefaultJoinAuthority is < 0 or > 2 || p1.ReportsPerDay is < 1 or > 1000 || p1.ReportCooldownMinutes is < 0 or > 1440) throw new Exception();
|
||||
if (new[] { p1.FriendLimit, p1.CreatedGroupLimit, p1.GroupMemberLimit, p1.TextLimit, p1.RecallMinutes, p1.ClientAccessMinutes, p1.ClientRefreshDays }.Any(x => x < 0 || x > 1000000) || p1.UploadMaxBytes < 0 || p1.UploadMaxBytes > 1099511627776) throw new Exception();
|
||||
if (p1.ReportCategories is null || p1.ReportCategories.Length is < 1 or > 30 || p1.ReportCategories.Any(x => string.IsNullOrWhiteSpace(x) || x.Length > 40) || p1.ReportCategories.Distinct().Count() != p1.ReportCategories.Length) throw new Exception();
|
||||
if (p1.AllowedFileTypes is null || p1.AllowedFileTypes.Any(x => !System.Text.RegularExpressions.Regex.IsMatch(x, "^\\.[a-z0-9]{1,15}$"))) throw new Exception();
|
||||
if (p1.SupportEmail.Length > 0 && !System.Net.Mail.MailAddress.TryCreate(p1.SupportEmail, out _)) throw new Exception();
|
||||
} catch { throw new ApiError(400, "配置值无效,请检查类型、范围、邮箱和文件扩展名"); }
|
||||
}
|
||||
}
|
||||
public record SettingInput(long Version, JsonObject Value, string Reason, string? Secret = null);
|
||||
Reference in New Issue
Block a user