142 lines
8.6 KiB
C#
142 lines
8.6 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Security.Claims;
|
|
using System.Text.Encodings.Web;
|
|
using System.Text.Json;
|
|
using MAX.Framework.Commons;
|
|
using MAX.Framework.Security;
|
|
using MAX.Plugins;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Hosting.Server;
|
|
using Microsoft.AspNetCore.Hosting.Server.Features;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using Store.Modules.CloudProduct.Controllers;
|
|
using Store.Modules.CloudProduct.Entities;
|
|
using Store.Modules.CloudProduct.Services;
|
|
|
|
/// <summary>通过真实 HTTP 路由验证图片识别的 DTO 绑定、ApiResult 包装及缓存。</summary>
|
|
internal static class HttpChecks
|
|
{
|
|
public static async Task RunAsync(IFreeSql database)
|
|
{
|
|
var userName = "http-check-" + Guid.NewGuid().ToString("N");
|
|
var builder = WebApplication.CreateBuilder();
|
|
builder.Logging.ClearProviders();
|
|
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
|
using var context = new PluginContext("CloudProduct", "Plugins/CloudProduct", builder.Environment);
|
|
context.Configuration["ProductInspection:Model"] = "test-vision";
|
|
context.Configuration["ProductInspection:ApiKey"] = "test-key";
|
|
builder.Services.AddSingleton(new PluginContext<Store.Modules.CloudProduct.CloudProductPlugin>(context));
|
|
builder.Services.AddKeyedSingleton<SemaphoreSlim>("ProductInspectionAnalysisGate", (_, _) => new SemaphoreSlim(5, 5));
|
|
builder.Services.AddKeyedSingleton<IFreeSql>("ProductInspectionConnection", database);
|
|
builder.Services.AddSingleton<Store.Modules.CloudProduct.Contracts.IInspectionIntegration, QwenInspectionIntegration>();
|
|
builder.Services.AddScoped<ProductInspectionService>();
|
|
builder.Services.AddHttpContextAccessor();
|
|
builder.Services.AddScoped<ICurrentUser, CurrentUser>();
|
|
builder.Services.AddAuthentication("test").AddScheme<AuthenticationSchemeOptions, TestAuthentication>("test", _ => { });
|
|
builder.Services.AddAuthorization();
|
|
builder.Services.AddControllers(options =>
|
|
{
|
|
options.Conventions.Add(new MAX.Framework.Conventions.ApiRoutePrefixConvention());
|
|
options.Filters.Add<UnifyResultFilter>();
|
|
options.Filters.Add<GlobalExceptionFilter>();
|
|
}).AddApplicationPart(typeof(ProductInspectionController).Assembly);
|
|
await using var app = builder.Build();
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
app.MapControllers();
|
|
app.MapPost("/test/chat", RecognitionEndpoint.RespondAsync);
|
|
await app.StartAsync();
|
|
var address = app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.Single();
|
|
context.Configuration["ProductInspection:Endpoint"] = address + "/test/chat";
|
|
using var client = new HttpClient { BaseAddress = new Uri(address + "/api/ProductInspection/") };
|
|
var checks = 0;
|
|
void Check(bool condition, string message)
|
|
{
|
|
if (!condition) throw new Exception(message);
|
|
checks++;
|
|
}
|
|
// 随机化图片内容及登录身份,清理时只删除本次检查创建的行。
|
|
var data = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jZQAAAABJRU5ErkJggg==")
|
|
.Concat(Guid.NewGuid().ToByteArray()).ToArray();
|
|
var image = Convert.ToBase64String(data);
|
|
var key = Guid.NewGuid().ToString("N");
|
|
async Task<JsonElement> Inspect(string image, string? md5)
|
|
{
|
|
using var response = await client.PostAsJsonAsync("InspectImage", new { userName, md5, image });
|
|
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
|
return doc.RootElement.Clone();
|
|
}
|
|
try
|
|
{
|
|
client.DefaultRequestHeaders.Add("X-Test-User", userName);
|
|
var first = await Inspect(image, key.ToUpperInvariant());
|
|
Check(first.TryGetProperty("code", out var firstCode) && firstCode.GetInt32() == 200, first.ToString());
|
|
string[] expected = ["急速发货", "螺旋伸缩", "金属挂钩", "可调节", "不挡扇叶", "倒挂钩卡口"];
|
|
Check(first.GetProperty("data").Deserialize<string[]>()!.SequenceEqual(expected), "Ordered recognition array");
|
|
Check((await Inspect(image, key)).GetProperty("data").Deserialize<string[]>()!.SequenceEqual(expected), "Cache hit preserves item order");
|
|
var stored = await database.Select<ImageAnalysisCache>().Where(x => x.CacheKey == key).FirstAsync();
|
|
Check(stored.RecognitionResult == """["急速发货","螺旋伸缩","金属挂钩","可调节","不挡扇叶","倒挂钩卡口"]""", "Cache stores distinct items with readable Chinese");
|
|
await database.Update<ImageAnalysisCache>().Set(x => x.RecognitionResult, "[]").Where(x => x.CacheKey == key).ExecuteAffrowsAsync();
|
|
var otherImage = Convert.ToBase64String(data.Append((byte)42).ToArray());
|
|
var hit = (await Inspect(otherImage, key)).GetProperty("data");
|
|
Check(hit.GetArrayLength() == 0, "Same client key reuses cached result for different image bytes");
|
|
await database.Update<ImageAnalysisCache>().Set(x => x.RecognitionResult, """{"text":"legacy","logoBrands":[]}""")
|
|
.Where(x => x.CacheKey == key).ExecuteAffrowsAsync();
|
|
Check((await Inspect(image, key)).GetProperty("data").Deserialize<string[]>()!.SequenceEqual(expected), "Refresh legacy cache");
|
|
Check((await Inspect(Convert.ToBase64String(new byte[30]), key)).GetProperty("code").GetInt32() != 200, "Invalid image denied");
|
|
Check((await Inspect("invalid!", key)).GetProperty("code").GetInt32() != 200, "Malformed Base64 denied before cache hit");
|
|
foreach (var invalid in new[] { "invalid", new string('z', 32), null })
|
|
{
|
|
var rejected = await Inspect(otherImage, invalid);
|
|
Check(!rejected.TryGetProperty("code", out var code) || code.GetInt32() != 200, "Reject missing or malformed MD5 before cache hit");
|
|
}
|
|
Console.WriteLine($"PASS {checks} HTTP and MySQL checks");
|
|
}
|
|
finally
|
|
{
|
|
await app.StopAsync();
|
|
await database.Delete<ImageAnalysisCache>().Where(x => x.CacheKey == key).ExecuteAffrowsAsync();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>仅测试宿主使用的身份处理器,生产插件始终沿用宿主 Bearer 认证。</summary>
|
|
internal sealed class TestAuthentication(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder)
|
|
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
|
{
|
|
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
|
{
|
|
var name = Request.Headers["X-Test-User"].ToString();
|
|
if (string.IsNullOrEmpty(name)) return Task.FromResult(AuthenticateResult.NoResult());
|
|
var principal = new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.UserName, name)], Scheme.Name));
|
|
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)));
|
|
}
|
|
}
|
|
|
|
/// <summary>返回模型协议示例,使 HTTP 检查无需外部密钥。</summary>
|
|
internal static class RecognitionEndpoint
|
|
{
|
|
private const string Content = """["\u6025\u901F\u53D1\u8D27","急速发货","螺旋伸缩","急速发货","金属挂钩","可调节","不挡扇叶","倒挂钩卡口"]""";
|
|
|
|
internal static async Task<IResult> RespondAsync(HttpRequest request, CancellationToken cancellationToken)
|
|
{
|
|
using var body = await JsonDocument.ParseAsync(request.Body, cancellationToken: cancellationToken);
|
|
var root = body.RootElement;
|
|
var parts = root.GetProperty("messages")[0].GetProperty("content");
|
|
if (root.GetProperty("model").GetString() != "test-vision"
|
|
|| request.Headers.Authorization.ToString() != "Bearer test-key"
|
|
|| root.GetProperty("stream").GetBoolean() || root.GetProperty("enable_thinking").GetBoolean()
|
|
|| !parts[0].GetProperty("image_url").GetProperty("url").GetString()!.StartsWith("data:image/png;base64,iVBOR")
|
|
|| !parts[1].GetProperty("text").GetString()!.Contains("JSON 字符串数组"))
|
|
throw new Exception("Invalid model request");
|
|
return Results.Json(new { choices = new[] { new { message = new { content = Content } } } });
|
|
}
|
|
}
|