fix: align backend APIs and upload flow
This commit is contained in:
@@ -25,6 +25,7 @@ namespace IM.InitCommon
|
||||
//similar to serviceCollection.AddDbContextPool<ECDictDbContext>(opt=>new DbContextOptionsBuilder(dbCtxOpt));
|
||||
var methodGenericAddDbContext = methodAddDbContext.MakeGenericMethod(dbCtxType);
|
||||
methodGenericAddDbContext.Invoke(null, new object[] { services, action, ServiceLifetime.Scoped, ServiceLifetime.Scoped });
|
||||
services.AddScoped(sp => new Management.ManagementDbProbe((DbContext)sp.GetRequiredService(dbCtxType)));
|
||||
}
|
||||
}
|
||||
return services;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using IM.ASPNETCore;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using IM.InitCommon.Management;
|
||||
|
||||
namespace IM.InitCommon
|
||||
{
|
||||
@@ -9,6 +10,7 @@ namespace IM.InitCommon
|
||||
{
|
||||
app.UseCors();
|
||||
app.UseAuthentication();
|
||||
app.UseManagementRuntime();
|
||||
app.UseAuthorization();
|
||||
app.UseMiddleware<ExceptionMiddleware>();
|
||||
app.UseForwardedHeaders();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace IM.InitCommon.Management;
|
||||
|
||||
public record PageResult<T>(IReadOnlyList<T> Items, int Total, int Page, int Size);
|
||||
public record InternalAction(Guid Id, Guid ActorId, Guid TargetId, string Action, string Reason);
|
||||
public record ActionReceipt(string TargetName, string Before, string After);
|
||||
public record EvidenceRequest(Guid ReporterId, string Type, Guid TargetId, Guid[] MessageIds);
|
||||
public record EvidenceSnapshot(Guid Id, Guid SenderId, string SenderName, string Text, string Type, Guid? FileId, DateTimeOffset SentAt);
|
||||
public record SubjectEvidence(string TargetName, IReadOnlyList<EvidenceSnapshot> Evidence);
|
||||
public record UserAccess(bool Enabled, string Stamp);
|
||||
public record ServiceHealth(string Service, string Status, double? LatencyMs, DateTime CheckedAt, DateTime? LastSuccessAt, string? Error, long? ConfigVersion = null);
|
||||
public sealed class Policy
|
||||
{
|
||||
public long Version { get; set; }
|
||||
public string PlatformName { get; set; } = "IM";
|
||||
public string Description { get; set; } = "";
|
||||
public string SupportEmail { get; set; } = "";
|
||||
public bool RegistrationEnabled { get; set; } = true;
|
||||
public int PasswordMinLength { get; set; } = 6;
|
||||
public int FriendLimit { get; set; }
|
||||
public int CreatedGroupLimit { get; set; }
|
||||
public int GroupMemberLimit { get; set; }
|
||||
public int DefaultJoinAuthority { get; set; }
|
||||
public int TextLimit { get; set; }
|
||||
public int RecallMinutes { get; set; }
|
||||
public long UploadMaxBytes { get; set; }
|
||||
public string[] AllowedFileTypes { get; set; } = [];
|
||||
public string[] ReportCategories { get; set; } = ["推广信息", "骚扰辱骂", "冒充身份", "其他"];
|
||||
public int ReportsPerDay { get; set; } = 20;
|
||||
public int ReportCooldownMinutes { get; set; } = 10;
|
||||
public int ClientAccessMinutes { get; set; }
|
||||
public int ClientRefreshDays { get; set; }
|
||||
public int AdminSessionMinutes { get; set; } = 480;
|
||||
public int AdminLockThreshold { get; set; } = 5;
|
||||
public int AdminLockMinutes { get; set; } = 15;
|
||||
}
|
||||
public static class ManagementResult
|
||||
{
|
||||
public static object Ok(object? data = null) => new { code = 0, message = "成功", data };
|
||||
public static object Fail(string message) => new { code = 1003, message, data = (object?)null };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace IM.InitCommon.Management;
|
||||
public static class DeploymentCommands
|
||||
{
|
||||
public static bool ApplyMigrationsIfRequested(this WebApplication app, string[] args)
|
||||
{
|
||||
if (!args.Contains("--migrate")) return false;
|
||||
using var scope = app.Services.CreateScope();
|
||||
foreach (var probe in scope.ServiceProvider.GetServices<ManagementDbProbe>().Where(x => x.Context.Database.GetMigrations().Any()))
|
||||
probe.Context.Database.Migrate();
|
||||
Console.WriteLine("服务数据库迁移完成"); return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace IM.InitCommon.Management;
|
||||
|
||||
public sealed class InternalClient(HttpClient http, IConfiguration config)
|
||||
{
|
||||
public async Task<T> Send<T>(string service, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
var address = config[$"Management:Services:{service}"] ?? throw new InvalidOperationException($"未配置服务 {service}");
|
||||
var key = config["Management:InternalKey"] ?? config["InternalApiKey"];
|
||||
if (string.IsNullOrWhiteSpace(key)) throw new InvalidOperationException("未配置内部服务密钥");
|
||||
using var request = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, new Uri(new Uri(address.TrimEnd('/') + "/"), path.TrimStart('/')));
|
||||
request.Headers.Add("X-IM-Management-Key", key);
|
||||
if (body is not null) request.Content = JsonContent.Create(body);
|
||||
using var response = await http.SendAsync(request, ct);
|
||||
if (!response.IsSuccessStatusCode) throw new InternalServiceException(service, (int)response.StatusCode);
|
||||
return await response.Content.ReadFromJsonAsync<T>(cancellationToken: ct) ?? throw new InvalidOperationException("服务响应为空");
|
||||
}
|
||||
public static bool Authorized(HttpContext context)
|
||||
{
|
||||
var c = context.RequestServices.GetService(typeof(IConfiguration)) as IConfiguration;
|
||||
var expected = c?["Management:InternalKey"] ?? c?["InternalApiKey"];
|
||||
var actual = context.Request.Headers["X-IM-Management-Key"].ToString();
|
||||
return !string.IsNullOrWhiteSpace(expected) && CryptographicOperations.FixedTimeEquals(SHA256.HashData(Encoding.UTF8.GetBytes(expected)), SHA256.HashData(Encoding.UTF8.GetBytes(actual)));
|
||||
}
|
||||
}
|
||||
public sealed class InternalServiceException(string service, int status) : Exception($"{service} 服务请求失败({status})")
|
||||
{
|
||||
public int Status { get; } = status;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Data;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace IM.InitCommon.Management;
|
||||
public static class ReceiptStore
|
||||
{
|
||||
public static async Task<ActionReceipt> Execute(DbContext db, InternalAction command, Func<Task<ActionReceipt>> apply, CancellationToken ct)
|
||||
{
|
||||
await using var tx = await db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
|
||||
var connection = db.Database.GetDbConnection();
|
||||
await using var read = connection.CreateCommand(); read.Transaction = tx.GetDbTransaction();
|
||||
var owner = db.GetType().Name;
|
||||
read.CommandText = "SELECT Payload FROM management_receipts WHERE Owner = @owner AND Id = @id FOR UPDATE";
|
||||
var ownerParameter = read.CreateParameter(); ownerParameter.ParameterName = "@owner"; ownerParameter.Value = owner; read.Parameters.Add(ownerParameter);
|
||||
var parameter = read.CreateParameter(); parameter.ParameterName = "@id"; parameter.Value = command.Id.ToString(); read.Parameters.Add(parameter);
|
||||
var existing = await read.ExecuteScalarAsync(ct);
|
||||
if (existing is string json) {
|
||||
var saved = JsonSerializer.Deserialize<SavedReceipt>(json)!;
|
||||
if (saved.Command != command) throw new InvalidOperationException("操作 ID 已被其他请求使用");
|
||||
await tx.CommitAsync(ct); return saved.Result;
|
||||
}
|
||||
var receipt = await apply(); await db.SaveChangesAsync(ct);
|
||||
await db.Database.ExecuteSqlInterpolatedAsync($"INSERT INTO management_receipts (Owner, Id, Payload, CreatedAt) VALUES ({owner}, {command.Id.ToString()}, {JsonSerializer.Serialize(new SavedReceipt(command, receipt))}, {DateTime.UtcNow})", ct);
|
||||
await tx.CommitAsync(ct); return receipt;
|
||||
}
|
||||
private record SavedReceipt(InternalAction Command, ActionReceipt Result);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace IM.InitCommon.Management;
|
||||
public record InfrastructureEnvelope(long Version, JsonObject Value, string Secret);
|
||||
public record ManagementDbProbe(DbContext Context);
|
||||
public sealed class RuntimePolicy(IServiceProvider services, IConfiguration config, ILogger<RuntimePolicy> logger) : BackgroundService
|
||||
{
|
||||
private Policy? current;
|
||||
public InfrastructureEnvelope? Storage { get; private set; }
|
||||
public bool Enabled => config.GetValue<bool>("Management:Enabled");
|
||||
public Policy Current => current ?? (!Enabled ? new Policy() : throw new InvalidOperationException("管理配置尚未加载"));
|
||||
public long AppliedVersion => current?.Version ?? 0;
|
||||
public DateTime? LastLoadedAt { get; private set; }
|
||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
if (!Enabled) return;
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(20));
|
||||
do {
|
||||
try {
|
||||
var client = services.GetRequiredService<InternalClient>();
|
||||
current = await client.Send<Policy>("admin", "/internal/management/policy", ct: ct);
|
||||
if (config["Management:ServiceName"] == "file") {
|
||||
var storage = await client.Send<InfrastructureEnvelope>("admin", "/internal/management/infrastructure/storage", ct: ct);
|
||||
if (Storage?.Version != storage.Version) { Storage = storage; services.GetService<IOptionsMonitorCache<StorageOptions>>()?.Clear(); }
|
||||
}
|
||||
LastLoadedAt = DateTime.UtcNow;
|
||||
} catch (Exception e) when (!ct.IsCancellationRequested) { logger.LogWarning("Policy refresh failed; keeping last committed version: {Type}", e.GetType().Name); }
|
||||
} while (await timer.WaitForNextTickAsync(ct));
|
||||
}
|
||||
public void CheckFile(string fileName, long size)
|
||||
{
|
||||
var p = Current;
|
||||
if (p.UploadMaxBytes > 0 && size > p.UploadMaxBytes) throw new IM.DomainCommons.DomainException("文件超过平台上传大小限制");
|
||||
if (p.AllowedFileTypes.Length > 0 && !p.AllowedFileTypes.Contains(Path.GetExtension(fileName).ToLowerInvariant())) throw new IM.DomainCommons.DomainException("平台不允许上传此文件类型");
|
||||
}
|
||||
}
|
||||
public sealed class DynamicStorageOptions(RuntimePolicy runtime) : IPostConfigureOptions<StorageOptions>
|
||||
{
|
||||
public void PostConfigure(string? name, StorageOptions options)
|
||||
{
|
||||
var envelope = runtime.Storage;
|
||||
if (envelope is null || !envelope.Value.ContainsKey("providers")) return;
|
||||
var configured = envelope.Value.Deserialize<StorageOptions>(new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
|
||||
var secrets = JsonNode.Parse(string.IsNullOrEmpty(envelope.Secret) ? "{}" : envelope.Secret)!.AsObject();
|
||||
foreach (var (code, provider) in configured.Providers) {
|
||||
if (secrets[code] is JsonObject credential) {
|
||||
provider.AccessKeyId = credential["accessKeyId"]?.GetValue<string>();
|
||||
provider.AccessKeySecret = credential["accessKeySecret"]?.GetValue<string>();
|
||||
}
|
||||
}
|
||||
options.DefaultProviderCode = configured.DefaultProviderCode; options.Providers = configured.Providers;
|
||||
}
|
||||
}
|
||||
public static class RuntimeManagementExtensions
|
||||
{
|
||||
public static IServiceCollection AddManagementRuntime(this IServiceCollection services)
|
||||
{
|
||||
services.AddHttpClient<InternalClient>(c => c.Timeout = TimeSpan.FromSeconds(4));
|
||||
services.AddSingleton<RuntimePolicy>(); services.AddHostedService(sp => sp.GetRequiredService<RuntimePolicy>());
|
||||
services.AddSingleton<IPostConfigureOptions<StorageOptions>, DynamicStorageOptions>();
|
||||
return services;
|
||||
}
|
||||
public static IApplicationBuilder UseManagementRuntime(this IApplicationBuilder app) => app.Use(async (c, next) => {
|
||||
if (c.Request.Path.StartsWithSegments("/internal/management")) {
|
||||
if (!InternalClient.Authorized(c)) { c.Response.StatusCode = 403; return; }
|
||||
await next(); return;
|
||||
}
|
||||
var policy = c.RequestServices.GetRequiredService<RuntimePolicy>();
|
||||
if (policy.Enabled && c.User.Identity?.IsAuthenticated == true && Guid.TryParse(c.User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId)) {
|
||||
try {
|
||||
var user = await c.RequestServices.GetRequiredService<InternalClient>().Send<UserAccess>("user", $"/internal/management/access/{userId}", ct: c.RequestAborted);
|
||||
if (!user.Enabled || user.Stamp != c.User.FindFirstValue("session_stamp")) { c.Response.StatusCode = 401; await c.Response.WriteAsJsonAsync(ManagementResult.Fail("账号已被封禁或会话已撤销,请重新登录")); return; }
|
||||
} catch { c.Response.StatusCode = 503; await c.Response.WriteAsJsonAsync(ManagementResult.Fail("账号状态验证暂不可用,请稍后重试")); return; }
|
||||
}
|
||||
await next();
|
||||
});
|
||||
public static void MapManagementHealth(this WebApplication app) => app.MapGet("/internal/management/health", async (RuntimePolicy runtime, IConfiguration config, IEnumerable<ManagementDbProbe> probes, CancellationToken ct) => {
|
||||
var ready = !runtime.Enabled || runtime.LastLoadedAt is not null;
|
||||
foreach (var probe in probes) ready &= await probe.Context.Database.CanConnectAsync(ct);
|
||||
return new { service = config["Management:ServiceName"], status = ready ? "healthy" : "unavailable", configVersion = runtime.AppliedVersion, storageVersion = runtime.Storage?.Version, runtime.LastLoadedAt };
|
||||
});
|
||||
}
|
||||
@@ -28,6 +28,10 @@ namespace IM.InitCommon
|
||||
c.Password(options.Password);
|
||||
});
|
||||
|
||||
cfg.UseMessageRetry(retry => retry.Intervals(
|
||||
TimeSpan.FromMilliseconds(200),
|
||||
TimeSpan.FromSeconds(1),
|
||||
TimeSpan.FromSeconds(5)));
|
||||
cfg.ConfigureEndpoints(context);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,9 +34,9 @@ namespace IM.InitCommon
|
||||
|
||||
public string? PublicBaseUrl { get; init; }
|
||||
|
||||
public string? AccessKeyId { get; init; }
|
||||
public string? AccessKeyId { get; set; }
|
||||
|
||||
public string? AccessKeySecret { get; init; }
|
||||
public string? AccessKeySecret { get; set; }
|
||||
|
||||
public string? LocalRootPath { get; init; }
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using FluentValidation;
|
||||
using FluentValidation;
|
||||
using FluentValidation.AspNetCore;
|
||||
using IM.ASPNETCore;
|
||||
using IM.Commons;
|
||||
@@ -14,6 +14,7 @@ using RedLockNet.SERedis.Configuration;
|
||||
using StackExchange.Redis;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
using Winton.Extensions.Configuration.Consul;
|
||||
using IM.InitCommon.Management;
|
||||
|
||||
namespace IM.InitCommon
|
||||
{
|
||||
@@ -21,7 +22,7 @@ namespace IM.InitCommon
|
||||
{
|
||||
public static void ConfigureDbConfiguration(this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Host.ConfigureAppConfiguration((hostCtx, configbuilder) =>
|
||||
if (!builder.Configuration.GetValue("Consul:Enabled", true)) return; builder.Host.ConfigureAppConfiguration((hostCtx, configbuilder) =>
|
||||
{
|
||||
var env = hostCtx.HostingEnvironment;
|
||||
|
||||
@@ -66,6 +67,7 @@ namespace IM.InitCommon
|
||||
{
|
||||
var services = builder.Services;
|
||||
var configuration = builder.Configuration;
|
||||
services.AddManagementRuntime();
|
||||
|
||||
|
||||
var assemblies = ReflectionHelper.GetAllReferencedAssemblies();
|
||||
|
||||
Reference in New Issue
Block a user