增加ImageCache微信 图片缓存

This commit is contained in:
chfei committed 2026-10-21 17:04:26 +08:00
1 parent d6cb5b966b
commit d0f9f00b76
15 files changed
+518 -30

No files matched your search

+12 -18
View File
@@ -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<object>.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;
}
}
}
@@ -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)
+41 -8
View File
@@ -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
{
/// <summary>业务状态码</summary>
public int Code { get; set; } = ResultCode.Success;
public int Code { get; init; } = ResultCode.Success;
/// <summary>提示信息</summary>
public string? Msg { get; set; }
public string? Msg { get; init; }
/// <summary>请求是否成功</summary>
public bool Success => Code == ResultCode.Success;
@@ -44,13 +44,25 @@ namespace MAX.Framework.Dtos
/// </summary>
public class ApiResult<T> : ApiResultBase
{
public T? Data { get; set; }
/// <summary>响应数据;失败时不应依赖该值,值类型默认值并非 null。</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public T? Data { get; init; }
/// <summary>将无数据响应转换为当前数据类型,保留状态码和提示信息。</summary>
public static implicit operator ApiResult<T>(ApiResult result)
{
ArgumentNullException.ThrowIfNull(result);
return new ApiResult<T> { Code = result.Code, Msg = result.Msg, Data = default };
}
public static ApiResult<T> Ok(T data, string msg = "操作成功")
=> new ApiResult<T> { Code = ResultCode.Success, Data = data, Msg = msg };
public static ApiResult<T> Fail(string msg = "操作失败", int code = ResultCode.Fail, T? data = default)
=> new ApiResult<T> { Code = code, Msg = msg, Data = data };
{
ApiResult.ValidateFailureCode(code);
return new ApiResult<T> { Code = code, Msg = msg, Data = data };
}
}
/// <summary>
@@ -58,11 +70,32 @@ namespace MAX.Framework.Dtos
/// </summary>
public class ApiResult : ApiResultBase
{
public static ApiResult Ok(string msg = "操作成功")
=> new ApiResult { Code = ResultCode.Success, Msg = msg };
/// <summary>无数据响应仍输出 data: null,保持统一的响应字段。</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public object? Data => null;
/// <summary>创建带数据的成功响应,自动推断数据类型。</summary>
public static ApiResult<T> Ok<T>(T data, string msg = "操作成功")
=> ApiResult<T>.Ok(data, msg);
/// <summary>创建带数据的失败响应,自动推断数据类型。</summary>
public static ApiResult<T> Fail<T>(string msg, T data, int code = ResultCode.Fail)
=> ApiResult<T>.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, "失败响应不能使用成功状态码。");
}
}
}
@@ -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<ImageCacheController> _logger;
public ImageCacheController(ImageCacheService imageCacheService, ILogger<ImageCacheController> logger)
{
if (imageCacheService == null)
{
throw new ArgumentNullException("imageCacheService");
}
_imageCacheService = imageCacheService;
_logger = logger;
}
/// <summary>
/// 批量按 MD5 查询图片缓存。
/// </summary>
[HttpPost]
public ApiResult<List<ImageCacheDto>> GetImageCache(ImageCacheBatchQueryRequest request)
{
List<ImageCacheDto> data = new List<ImageCacheDto>();
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);
}
}
/// <summary>
/// 按 MD5 和平台更新平台图片 URL。
/// </summary>
[HttpPost]
public ApiResult<List<string>> AddOrUpdate(ImageCacheUpdateUrlRequest[] requests)
{
if (requests == null || requests.Length == 0)
{
return ApiResult.Fail("参数不能为空");
}
var failedItems = new List<string>();
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<string> 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; }
}
}
}
@@ -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; }
}
}
@@ -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; }
}
}
@@ -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);
}
}
+47
View File
@@ -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
{
/// <summary>
/// 本模块自己的配置文件
/// </summary>
public const string ConfigurationFileName = "appsettings.ImageCache.json";
public int Priority => IntPriority.Normal;
/// <summary>
/// 服务注册阶段(builder.Build() 之前):这里只能拿到容器和配置,碰不到 app
/// </summary>
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);
}
}
/// <summary>
/// 管道装配阶段(builder.Build() 之后、app.Run() 之前):这里可以挂中间件、解析容器里的服务
/// </summary>
public void UseModule(IApplicationBuilder app)
{
}
}
}
@@ -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;
}
/// <summary>
/// 按 MD5 查询图片缓存;platform 为空时返回该 MD5 在所有平台的缓存。
/// </summary>
public ImageCacheDto GetByMd5(string sourceUrlMd5, byte? platform = null)
{
var md5 = NormalizeMd5(sourceUrlMd5);
return GetByMd5sInternal(new List<string> { md5 }, platform).FirstOrDefault();
}
/// <summary>
/// 批量按 MD5 查询图片缓存。MD5 会按首字符分组,只查询对应的 images_0 到 images_f 分表。
/// </summary>
public List<ImageCacheDto> GetByMd5s(IEnumerable<string> sourceUrlMd5s, byte? platform = null)
{
return GetByMd5sInternal(NormalizeMd5s(sourceUrlMd5s), platform);
}
/// <summary>
/// 按 MD5 和平台直接修改平台图片 URL,未找到时返回 false。
/// </summary>
public bool AddOrUpdate(string sourceUrlMd5, byte platform, string platformUrl)
{
var md5 = NormalizeMd5(sourceUrlMd5);
ValidatePlatformUrl(platformUrl);
var tableName = GetTableName(md5[0]);
var existing = _freeSql.Select<ImageCacheEntity>()
.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<ImageCacheEntity>()
.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<ImageCacheDto> GetByMd5sInternal(List<string> md5s, byte? platform)
{
var result = new List<ImageCacheDto>();
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<ImageCacheEntity>()
.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<string> NormalizeMd5s(IEnumerable<string> sourceUrlMd5s)
{
if (sourceUrlMd5s == null)
{
throw new ArgumentNullException("sourceUrlMd5s");
}
var result = new List<string>();
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");
}
}
}
}
+45
View File
@@ -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<IServiceProvider, IFreeSql> KJDFsqlFactory = r =>
{
var config = r.GetRequiredService<IConfiguration>();
var connection = config.GetValue<string>("ImageCache:ConnectionString");
IFreeSql fsql = new FreeSql.FreeSqlBuilder()
.UseConnectionString(FreeSql.DataType.MySql, connection)
.UseAdoConnectionPool(true)
.Build();
return fsql;
};
registrar.RegisterSingleton<IFreeSql>("ImageCacheConnection", (s, k) =>
{
return KJDFsqlFactory(s);
});
}
public void Start(IServiceProvider serviceProvider)
{
}
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Store.Framework\Store.Framework.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.ImageCache.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,5 @@
{
"ImageCache": {
"ConnectionString": "Server=192.168.0.200;Port=3306;Database=ImageCache;Uid=root;Pwd=123456;Allow User Variables=true"
}
}
+5
View File
@@ -7,6 +7,10 @@
</PropertyGroup>
<ItemGroup>
<None Remove="Logs\**" />
</ItemGroup>
<ItemGroup>
@@ -17,6 +21,7 @@
<ItemGroup>
<ProjectReference Include="..\Modules\CloudProduct\Store.CloudProduct.csproj" />
<ProjectReference Include="..\Modules\Store.ImageCache\Store.ImageCache.csproj" />
<ProjectReference Include="..\Modules\Store.Users\Store.Users.csproj" />
<ProjectReference Include="..\Store.Framework\Store.Framework.csproj" />
</ItemGroup>
+1 -1
View File
@@ -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;",
+2 -1
View File
@@ -5,6 +5,7 @@
</Folder>
<Folder Name="/Modules/">
<Project Path="Modules/CloudProduct/Store.CloudProduct.csproj" />
<Project Path="Modules/Store.ImageCache/Store.ImageCache.csproj" Id="53cc9cf1-62f9-4980-bad9-958bb3314370" />
<Project Path="Modules/Store.Shops.Contracts/Store.Shops.Contracts.csproj" Id="7d313823-f86a-450a-be29-8f915fc4b5b3" />
<Project Path="Modules/Store.Shops/Store.Shops.csproj" Id="b1c06d62-9cfb-4e87-b495-7077dd0c088d" />
<Project Path="Modules/Store.Users.Contracts/Store.Users.Contracts.csproj" Id="d4a8177b-503c-4205-96ce-da0e01c855db" />
@@ -15,6 +16,6 @@
</Folder>
<Project Path="Store.API/Store.Api.csproj" />
<Project Path="Store.Framework/Store.Framework.csproj" />
<Project Path="StoreHub.KJDWebService/StoreHub.KJDWebService.csproj" />
<Project Path="Store.KJDWebService/StoreHub.KJDWebService.csproj" />
<Project Path="StoreHub.Services/Store.Services.csproj" />
</Solution>