fix: align backend APIs and upload flow

This commit is contained in:
2026-09-15 14:10:55 +08:00
parent 32177a7293
commit 53e6195938
149 changed files with 4791 additions and 435 deletions
+1
View File
@@ -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();
+42
View File
@@ -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;
}
+29
View File
@@ -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);
}
+92
View File
@@ -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 };
});
}
+4
View File
@@ -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);
});
});
+2 -2
View File
@@ -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();