diff --git a/Core/MAX.Framework/Commons/UnifyResultFilter.cs b/Core/MAX.Framework/Commons/UnifyResultFilter.cs index 912855f..17ff72d 100644 --- a/Core/MAX.Framework/Commons/UnifyResultFilter.cs +++ b/Core/MAX.Framework/Commons/UnifyResultFilter.cs @@ -16,37 +16,31 @@ namespace MAX.Framework.Commons { if (context.Result is ObjectResult objResult) { - if (!IsAlreadyWrapped(objResult.Value)) + if (objResult.Value is ApiResultBase) + { + objResult.StatusCode = 200; + objResult.DeclaredType = objResult.Value.GetType(); + return; + } + + // 非成功响应交由状态码中间件处理,避免先包装成业务成功。 + var statusCode = objResult.StatusCode ?? 200; + if (statusCode >= 200 && statusCode < 300) { var apiResult = ApiResult.Ok(objResult.Value); objResult.Value = apiResult; objResult.DeclaredType = null; + objResult.StatusCode = 200; } } else if (context.Result is EmptyResult || context.Result is NoContentResult) { - context.Result = new ObjectResult(ApiResult.Ok()); + context.Result = new ObjectResult(ApiResult.Ok()) { StatusCode = 200 }; } } public void OnActionExecuting(ActionExecutingContext context) { } - private static bool IsAlreadyWrapped(object? value) - { - if (value is null) return false; - - var type = value.GetType(); - - // 递归判断:只要类型链上有任何一层是 ApiResult<> 的开放泛型定义,或直接是 ApiResult,就算已包装 - while (type is not null) - { - if (type == typeof(ApiResult)) return true; - if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ApiResult<>)) return true; - type = type.BaseType; - } - - return false; - } } } diff --git a/Core/MAX.Framework/Commons/UnifyStatusCodeMiddleware.cs b/Core/MAX.Framework/Commons/UnifyStatusCodeMiddleware.cs index e948483..1330c84 100644 --- a/Core/MAX.Framework/Commons/UnifyStatusCodeMiddleware.cs +++ b/Core/MAX.Framework/Commons/UnifyStatusCodeMiddleware.cs @@ -59,7 +59,7 @@ namespace MAX.Framework.Commons } var originalBody = context.Response.Body; - var buffer = _streamManager.GetStream(); + using var buffer = _streamManager.GetStream(); context.Response.Body = buffer; @@ -126,7 +126,6 @@ namespace MAX.Framework.Commons PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); - buffer.Dispose(); } private static bool IsPassthroughByRequest(HttpContext context) diff --git a/Core/MAX.Framework/Dtos/ApiResult.cs b/Core/MAX.Framework/Dtos/ApiResult.cs index c821136..2df8c1e 100644 --- a/Core/MAX.Framework/Dtos/ApiResult.cs +++ b/Core/MAX.Framework/Dtos/ApiResult.cs @@ -1,4 +1,4 @@ -using MAX.Framework.Commons; +using System.Text.Json.Serialization; namespace MAX.Framework.Dtos { @@ -29,10 +29,10 @@ namespace MAX.Framework.Dtos public abstract class ApiResultBase { /// 业务状态码 - public int Code { get; set; } = ResultCode.Success; + public int Code { get; init; } = ResultCode.Success; /// 提示信息 - public string? Msg { get; set; } + public string? Msg { get; init; } /// 请求是否成功 public bool Success => Code == ResultCode.Success; @@ -44,13 +44,25 @@ namespace MAX.Framework.Dtos /// public class ApiResult : ApiResultBase { - public T? Data { get; set; } + /// 响应数据;失败时不应依赖该值,值类型默认值并非 null。 + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public T? Data { get; init; } + + /// 将无数据响应转换为当前数据类型,保留状态码和提示信息。 + public static implicit operator ApiResult(ApiResult result) + { + ArgumentNullException.ThrowIfNull(result); + return new ApiResult { Code = result.Code, Msg = result.Msg, Data = default }; + } public static ApiResult Ok(T data, string msg = "操作成功") => new ApiResult { Code = ResultCode.Success, Data = data, Msg = msg }; public static ApiResult Fail(string msg = "操作失败", int code = ResultCode.Fail, T? data = default) - => new ApiResult { Code = code, Msg = msg, Data = data }; + { + ApiResult.ValidateFailureCode(code); + return new ApiResult { Code = code, Msg = msg, Data = data }; + } } /// @@ -58,11 +70,32 @@ namespace MAX.Framework.Dtos /// public class ApiResult : ApiResultBase { - public static ApiResult Ok(string msg = "操作成功") - => new ApiResult { Code = ResultCode.Success, Msg = msg }; + /// 无数据响应仍输出 data: null,保持统一的响应字段。 + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public object? Data => null; + + /// 创建带数据的成功响应,自动推断数据类型。 + public static ApiResult Ok(T data, string msg = "操作成功") + => ApiResult.Ok(data, msg); + + /// 创建带数据的失败响应,自动推断数据类型。 + public static ApiResult Fail(string msg, T data, int code = ResultCode.Fail) + => ApiResult.Fail(msg, code, data); + + public static ApiResult Ok() + => new ApiResult { Code = ResultCode.Success, Msg = "操作成功" }; public static ApiResult Fail(string msg = "操作失败", int code = ResultCode.Fail) - => new ApiResult { Code = code, Msg = msg }; + { + ValidateFailureCode(code); + return new ApiResult { Code = code, Msg = msg }; + } + + internal static void ValidateFailureCode(int code) + { + if (code == ResultCode.Success) + throw new ArgumentOutOfRangeException(nameof(code), code, "失败响应不能使用成功状态码。"); + } } } diff --git a/Modules/Store.ImageCache/Controllers/ImageCacheController.cs b/Modules/Store.ImageCache/Controllers/ImageCacheController.cs new file mode 100644 index 0000000..a147feb --- /dev/null +++ b/Modules/Store.ImageCache/Controllers/ImageCacheController.cs @@ -0,0 +1,107 @@ +using Asp.Versioning; +using MAX.Framework.Dtos; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using MySqlX.XDevAPI.Common; +using Store.Framework.Base; +using Store.Framework.Models; +using Store.ImageCache.Dtos; +using Store.ImageCache.Services; +using System; +using System.Collections.Generic; + + +namespace KJD_Web.Controllers +{ + [ApiVersionNeutral] + [Route("api/ImageCache/[action]")] + public class ImageCacheController : AppControllerBase + { + private readonly ImageCacheService _imageCacheService; + private readonly ILogger _logger; + + public ImageCacheController(ImageCacheService imageCacheService, ILogger logger) + { + if (imageCacheService == null) + { + throw new ArgumentNullException("imageCacheService"); + } + + _imageCacheService = imageCacheService; + _logger = logger; + } + + + /// + /// 批量按 MD5 查询图片缓存。 + /// + [HttpPost] + public ApiResult> GetImageCache(ImageCacheBatchQueryRequest request) + { + List data = new List(); + try + { + if (request != null && request.Md5s != null) + { + data = _imageCacheService.GetByMd5s(request.Md5s, request.Platform); + } + return ApiResult.Ok(data); + } + catch (ArgumentException ex) + { + return ApiResult.Fail(ex.Message); + } + } + + + + /// + /// 按 MD5 和平台更新平台图片 URL。 + /// + [HttpPost] + public ApiResult> AddOrUpdate(ImageCacheUpdateUrlRequest[] requests) + { + if (requests == null || requests.Length == 0) + { + return ApiResult.Fail("参数不能为空"); + } + + var failedItems = new List(); + foreach (var item in requests) + { + if (item == null) + { + return ApiResult.Fail("图片参数不能包含空项", ResultCode.ValidationError); + } + try + { + if (!_imageCacheService.AddOrUpdate(item.Md5, item.Platform, item.PlatformUrl)) + { + failedItems.Add(item.Md5); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "AddOrUpdate 失败, Md5={Md5}", item.Md5); + failedItems.Add(item.Md5); + } + } + + return failedItems.Count == 0 + ? ApiResult.Ok() + : ApiResult.Fail("部分图片失败", failedItems); + } + public class ImageCacheBatchQueryRequest + { + public List Md5s { get; set; } + public byte? Platform { get; set; } + } + + public class ImageCacheUpdateUrlRequest + { + public string Md5 { get; set; } + public byte Platform { get; set; } + public string PlatformUrl { get; set; } + } + } +} diff --git a/Modules/Store.ImageCache/Dtos/ImageCacheDto.cs b/Modules/Store.ImageCache/Dtos/ImageCacheDto.cs new file mode 100644 index 0000000..c5f29db --- /dev/null +++ b/Modules/Store.ImageCache/Dtos/ImageCacheDto.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.Json.Serialization; + +namespace Store.ImageCache.Dtos +{ + public class ImageCacheDto + { + public byte platform { get; set; } + public string md5 { get; set; } + public string platform_url { get; set; } + } +} diff --git a/Modules/Store.ImageCache/Entities/ImageCacheEntity.cs b/Modules/Store.ImageCache/Entities/ImageCacheEntity.cs new file mode 100644 index 0000000..0a56a50 --- /dev/null +++ b/Modules/Store.ImageCache/Entities/ImageCacheEntity.cs @@ -0,0 +1,32 @@ +using FreeSql.DataAnnotations; +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.Json.Serialization; + +namespace Store.ImageCache.Entities +{ + [Table(Name = "images_0")] + public class ImageCacheEntity + { + [JsonIgnore] + [Column(Name = "id", IsIdentity = true, IsPrimary = true)] + public long id { get; set; } + + [Column(Name = "platform")] + public byte platform { get; set; } + + //[JsonProperty("md5")] + [Column(Name = "source_url_md5", StringLength = 32)] + public string source_url_md5 { get; set; } + + [Column(Name = "platform_url", StringLength = 1000)] + public string platform_url { get; set; } + [JsonIgnore] + [Column(Name = "create_time")] + public DateTime create_time { get; set; } + [JsonIgnore] + [Column(Name = "update_time")] + public DateTime update_time { get; set; } + } +} diff --git a/Modules/Store.ImageCache/Mapper/ImageCacheMapper.cs b/Modules/Store.ImageCache/Mapper/ImageCacheMapper.cs new file mode 100644 index 0000000..ad3b397 --- /dev/null +++ b/Modules/Store.ImageCache/Mapper/ImageCacheMapper.cs @@ -0,0 +1,17 @@ +using Riok.Mapperly.Abstractions; +using Store.Framework.Models; +using Store.ImageCache.Dtos; +using Store.ImageCache.Entities; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Store.ImageCache.Mapper +{ + [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] + public partial class ImageCacheMapper + { + [MapProperty(nameof(ImageCacheEntity.source_url_md5), nameof(ImageCacheDto.md5))] + public partial ImageCacheDto ToDto(ImageCacheEntity entity); + } +} diff --git a/Modules/Store.ImageCache/Module.cs b/Modules/Store.ImageCache/Module.cs new file mode 100644 index 0000000..5c41215 --- /dev/null +++ b/Modules/Store.ImageCache/Module.cs @@ -0,0 +1,47 @@ + +using MAX.Core.Interfaces; +using MAX.Framework.Interfaces; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using MySql.Data.MySqlClient; + +namespace Store.Modules.CloudProduct +{ + public class Module : IModule + { + /// + /// 本模块自己的配置文件 + /// + public const string ConfigurationFileName = "appsettings.ImageCache.json"; + + public int Priority => IntPriority.Normal; + + + /// + /// 服务注册阶段(builder.Build() 之前):这里只能拿到容器和配置,碰不到 app + /// + public void AddModule(IHostApplicationBuilder host) + { + IServiceCollection services = host.Services; + IConfigurationManager configuration = host.Configuration; + + if (!string.IsNullOrWhiteSpace(ConfigurationFileName)) + { + string path = Path.IsPathRooted(ConfigurationFileName)? ConfigurationFileName: Path.Combine(AppContext.BaseDirectory, ConfigurationFileName); + configuration.AddJsonFile(path, optional: false, reloadOnChange: true); + } + } + + /// + /// 管道装配阶段(builder.Build() 之后、app.Run() 之前):这里可以挂中间件、解析容器里的服务 + /// + public void UseModule(IApplicationBuilder app) + { + + } + + + } +} diff --git a/Modules/Store.ImageCache/Services/ImageCacheService.cs b/Modules/Store.ImageCache/Services/ImageCacheService.cs new file mode 100644 index 0000000..5c83cf1 --- /dev/null +++ b/Modules/Store.ImageCache/Services/ImageCacheService.cs @@ -0,0 +1,172 @@ +using MAX.Core.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Store.ImageCache.Dtos; +using Store.ImageCache.Entities; +using Store.ImageCache.Mapper; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Store.ImageCache.Services +{ + public class ImageCacheService : ISingletonDependency + { + private const int Md5Length = 32; + private const int PlatformUrlMaxLength = 1000; + + private readonly IFreeSql _freeSql; + private ImageCacheMapper mapper; + public ImageCacheService([FromKeyedServices("ImageCacheConnection")] IFreeSql freeSql) + { + mapper = new ImageCacheMapper(); + if (freeSql == null) + { + throw new ArgumentNullException("freeSql"); + } + + _freeSql = freeSql; + } + + /// + /// 按 MD5 查询图片缓存;platform 为空时返回该 MD5 在所有平台的缓存。 + /// + public ImageCacheDto GetByMd5(string sourceUrlMd5, byte? platform = null) + { + var md5 = NormalizeMd5(sourceUrlMd5); + return GetByMd5sInternal(new List { md5 }, platform).FirstOrDefault(); + } + + /// + /// 批量按 MD5 查询图片缓存。MD5 会按首字符分组,只查询对应的 images_0 到 images_f 分表。 + /// + public List GetByMd5s(IEnumerable sourceUrlMd5s, byte? platform = null) + { + return GetByMd5sInternal(NormalizeMd5s(sourceUrlMd5s), platform); + } + + + + /// + /// 按 MD5 和平台直接修改平台图片 URL,未找到时返回 false。 + /// + public bool AddOrUpdate(string sourceUrlMd5, byte platform, string platformUrl) + { + var md5 = NormalizeMd5(sourceUrlMd5); + ValidatePlatformUrl(platformUrl); + var tableName = GetTableName(md5[0]); + var existing = _freeSql.Select() + .AsTable((type, oldName) => tableName) + .Where(a => a.source_url_md5 == sourceUrlMd5 && a.platform == platform) + .First(); + + var now = DateTime.Now; + if (existing != null) + { + var affected = _freeSql.Update() + .AsTable(tableName) + .Set(a => a.platform_url, platformUrl) + .Set(a => a.update_time, now) + .Where(a => a.source_url_md5 == md5 && a.platform == platform) + .ExecuteAffrows(); + return affected > 0; + } + else + { + ImageCacheEntity imageCache = new ImageCacheEntity(); + imageCache.id = 0; + imageCache.source_url_md5 = md5; + imageCache.platform = platform; + imageCache.platform_url = platformUrl; + imageCache.create_time = now; + imageCache.update_time = now; + imageCache.id = _freeSql.Insert(imageCache) + .AsTable(tableName) + .ExecuteIdentity(); + return imageCache.id > 0; + } + } + + private List GetByMd5sInternal(List md5s, byte? platform) + { + var result = new List(); + if (md5s.Count == 0) + { + return result; + } + + foreach (var shard in md5s.GroupBy(a => a[0])) + { + var tableName = GetTableName(shard.Key); + var md5sInTable = shard.ToList(); + var query = _freeSql.Select() + .AsTable((type, oldName) => tableName) + .Where(a => md5sInTable.Contains(a.source_url_md5)); + + if (platform.HasValue) + { + var platformValue = platform.Value; + query = query.Where(a => a.platform == platformValue); + } + result.AddRange(query.ToList().Select(mapper.ToDto)); + } + + return result; + } + + private List NormalizeMd5s(IEnumerable sourceUrlMd5s) + { + if (sourceUrlMd5s == null) + { + throw new ArgumentNullException("sourceUrlMd5s"); + } + + var result = new List(); + foreach (var sourceUrlMd5 in sourceUrlMd5s) + { + var md5 = NormalizeMd5(sourceUrlMd5); + if (!result.Contains(md5)) + { + result.Add(md5); + } + } + + return result; + } + + private string NormalizeMd5(string sourceUrlMd5) + { + if (string.IsNullOrWhiteSpace(sourceUrlMd5)) + { + throw new ArgumentException("MD5 不能为空", "sourceUrlMd5"); + } + + var md5 = sourceUrlMd5.Trim().ToLowerInvariant(); + if (md5.Length != Md5Length || md5.Any(a => !Uri.IsHexDigit(a))) + { + throw new ArgumentException("MD5 必须是 32 位十六进制字符串", "sourceUrlMd5"); + } + + return md5; + } + + private string GetTableName(char shard) + { + return "images_" + shard; + } + + + + private void ValidatePlatformUrl(string platformUrl) + { + if (string.IsNullOrWhiteSpace(platformUrl)) + { + throw new ArgumentException("平台图片 URL 不能为空", "platformUrl"); + } + + if (platformUrl.Length > PlatformUrlMaxLength) + { + throw new ArgumentException("平台图片 URL 长度不能超过 1000", "platformUrl"); + } + } + } +} diff --git a/Modules/Store.ImageCache/Startup.cs b/Modules/Store.ImageCache/Startup.cs new file mode 100644 index 0000000..e90b47d --- /dev/null +++ b/Modules/Store.ImageCache/Startup.cs @@ -0,0 +1,45 @@ +using MAX.Core.DependencyInjection; +using MAX.Core.Interfaces; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.ComponentModel; + +namespace Store.Modules.CloudProduct +{ + + public class Startup : IStartup + { + public int Priority => IntPriority.Normal; + + public void Initialize() + { + } + + public void Register(IServiceRegistrar registrar) + { + + + + Func KJDFsqlFactory = r => + { + var config = r.GetRequiredService(); + var connection = config.GetValue("ImageCache:ConnectionString"); + IFreeSql fsql = new FreeSql.FreeSqlBuilder() + .UseConnectionString(FreeSql.DataType.MySql, connection) + .UseAdoConnectionPool(true) + .Build(); + return fsql; + }; + registrar.RegisterSingleton("ImageCacheConnection", (s, k) => + { + return KJDFsqlFactory(s); + }); + } + + public void Start(IServiceProvider serviceProvider) + { + + } + } +} diff --git a/Modules/Store.ImageCache/Store.ImageCache.csproj b/Modules/Store.ImageCache/Store.ImageCache.csproj new file mode 100644 index 0000000..559ce0a --- /dev/null +++ b/Modules/Store.ImageCache/Store.ImageCache.csproj @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + PreserveNewest + + + diff --git a/Modules/Store.ImageCache/appsettings.ImageCache.json b/Modules/Store.ImageCache/appsettings.ImageCache.json new file mode 100644 index 0000000..c80c9ec --- /dev/null +++ b/Modules/Store.ImageCache/appsettings.ImageCache.json @@ -0,0 +1,5 @@ +{ + "ImageCache": { + "ConnectionString": "Server=192.168.0.200;Port=3306;Database=ImageCache;Uid=root;Pwd=123456;Allow User Variables=true" + } +} diff --git a/Store.API/Store.Api.csproj b/Store.API/Store.Api.csproj index 97aa213..4091a9b 100644 --- a/Store.API/Store.Api.csproj +++ b/Store.API/Store.Api.csproj @@ -7,6 +7,10 @@ + + + + @@ -17,6 +21,7 @@ + diff --git a/Store.API/appsettings.json b/Store.API/appsettings.json index 9c0aeb9..82d3a32 100644 --- a/Store.API/appsettings.json +++ b/Store.API/appsettings.json @@ -6,7 +6,7 @@ } }, "ConnectionStrings": { - "DefaultConnection": "Server=11192.168.0.200;Port=3306;Database=mysql;Uid=root;Pwd=123456;", + "DefaultConnection": "Server=192.168.0.200;Port=3306;Database=mysql;Uid=root;Pwd=123456;", "KJDConnection": "Server=192.168.0.200;Port=3306;Database=mysql;Uid=root;Pwd=123456;", "ConnKJD": "Server=192.168.0.200;Port=3306;Database=mysql;Uid=root;Pwd=123456;" //"KJDConnection": "Max Pool Size=512;Database='kjd';Data Source='nj-cynosdbmysql-grp-hl51pkfr.sql.tencentcdb.com';Port=27660;User Id='kjd';Password='Temukjd0625';Charset=utf8mb4;", diff --git a/Store.Api.slnx b/Store.Api.slnx index 27d1543..53d346d 100644 --- a/Store.Api.slnx +++ b/Store.Api.slnx @@ -5,6 +5,7 @@ + @@ -15,6 +16,6 @@ - +