更新框架,增加云库搜索模块

This commit is contained in:
chfei committed 2026-09-18 18:40:10 +08:00
1 parent 8526e5e0d2
commit f2caec6083
192 files changed
+3625 -803

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
using MAX.Core.DependencyInjection;
using MAX.Core.Infrastructure;
using MAX.Core.Interfaces;
using System;
using System.Collections.Concurrent;
using System.Linq;
@@ -1,10 +1,12 @@
namespace MAX.Core.DependencyInjection;
using MAX.Core.Interfaces;
namespace MAX.Core.DependencyInjection;
/// <summary>
/// 依赖注册器:把当前模块的服务注册到 <see cref="IServiceRegistrar"/>。
/// 底层使用哪个容器由宿主项目决定,MAX.Core 不引用任何容器。
/// </summary>
public interface IDependencyRegistrar
public interface IDependencyRegistrar: IPriority
{
/// <summary>
/// 注册服务
+33
View File
@@ -0,0 +1,33 @@
namespace MAX.Core.Interfaces;
public interface IPriority
{
/// <summary> 优先级(优先级越高越先执行) </summary>
int Priority { get; }
}
public static class IntPriority
{
public const int Highest = 5000;
public const int VeryHigh = 4000;
public const int Higher = 3000;
public const int High = 2000;
public const int AboveNormal = 1000;
public const int Normal = 0;
public const int BelowNormal = -1000;
public const int Low = -2000;
public const int Lower = -3000;
public const int VeryLow = -4000;
public const int Lowest = -5000;
}
@@ -1,6 +1,6 @@
using MAX.Core.DependencyInjection;
namespace MAX.Core.Infrastructure;
namespace MAX.Core.Interfaces;
/// <summary>
/// 启动器:实现 <see cref="IDependencyRegistrar"/>,由宿主项目统一发现并按 <see cref="Priority"/> 排序执行。
@@ -1,7 +1,7 @@
using System;
using System.Reflection;
namespace MAX.Core.Infrastructure;
namespace MAX.Core.Interfaces;
/// <summary>
/// 单例,需要在当前类中定义
+1 -2
View File
@@ -1,5 +1,4 @@
using MAX.Core.Infrastructure;
namespace MAX.Core.Logger;
/// <summary>自定义日志记录器接口</summary>
+1 -1
View File
@@ -24,7 +24,7 @@
*/
using MAX.Core.DependencyInjection;
using MAX.Core.Infrastructure;
using MAX.Core.Interfaces;
namespace MAX.Core;
@@ -0,0 +1,18 @@
using MAX.Framework.Dtos;
using System;
using System.Collections.Generic;
using System.Text;
namespace MAX.Framework.Commons
{
public class AppException : Exception
{
public int Code { get; }
public AppException(string message, int code = ResultCode.BusinessError)
: base(message)
{
Code = code;
}
}
}
@@ -0,0 +1,42 @@
using MAX.Framework.Dtos;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Text;
namespace MAX.Framework.Commons
{
public class GlobalExceptionFilter : IExceptionFilter
{
private readonly ILogger<GlobalExceptionFilter> _logger;
public GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
{
_logger = logger;
}
public void OnException(ExceptionContext context)
{
var traceId = context.HttpContext.TraceIdentifier;
var result = context.Exception switch
{
AppException be => ApiResult.Fail(be.Message, be.Code),
ValidationException ve => ApiResult.Fail(ve.Message, ResultCode.ValidationError),
UnauthorizedAccessException => ApiResult.Fail("未授权访问", ResultCode.Unauthorized),
_ => ApiResult.Fail("服务器内部错误", ResultCode.ServerError)
};
_logger.LogError(context.Exception, "请求异常 TraceId: {TraceId}", traceId);
context.Result = new ObjectResult(result)
{
StatusCode = 200 // 关键:HTTP状态码统一200,业务状态用Code表达
};
context.ExceptionHandled = true;
}
}
}
+26
View File
@@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace MAX.Framework.Commons
{
public class PageData<T>
{
public long Total { get; set; }
public int PageIndex { get; set; }
public int PageSize { get; set; }
public int TotalPages => PageSize > 0 ? (int)Math.Ceiling((double)Total / PageSize) : 0;
public List<T> List { get; set; } = new();
public PageData() { }
public PageData(List<T> list, long total, int pageIndex, int pageSize)
{
List = list;
Total = total;
PageIndex = pageIndex;
PageSize = pageSize;
}
}
}
@@ -0,0 +1,52 @@
using MAX.Framework.Dtos;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using System;
using System.Collections.Generic;
using System.Text;
namespace MAX.Framework.Commons
{
/// <summary>
/// 返回过滤
/// </summary>
public class UnifyResultFilter : IActionFilter
{
public void OnActionExecuted(ActionExecutedContext context)
{
if (context.Result is ObjectResult objResult)
{
if (!IsAlreadyWrapped(objResult.Value))
{
var apiResult = ApiResult<object>.Success(objResult.Value);
objResult.Value = apiResult;
objResult.DeclaredType = null;
}
}
else if (context.Result is EmptyResult || context.Result is NoContentResult)
{
context.Result = new ObjectResult(ApiResult.Success());
}
}
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;
}
}
}
@@ -0,0 +1,53 @@
using MAX.Framework.Dtos;
using Microsoft.AspNetCore.Http;
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.Json;
namespace MAX.Framework.Commons
{
public class UnsupportedMediaTypeHandlingMiddleware
{
private readonly RequestDelegate _next;
public UnsupportedMediaTypeHandlingMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
// 用一个内存流替换原始的Response.Body,方便判断状态码后决定是否要改写
var originalBodyStream = context.Response.Body;
using var memoryStream = new MemoryStream();
context.Response.Body = memoryStream;
await _next(context);
if (context.Response.StatusCode == StatusCodes.Status415UnsupportedMediaType)
{
var result = ApiResult.Fail("请求参数格式不正确", ResultCode.ValidationError);
var json = JsonSerializer.Serialize(result);
var bytes = Encoding.UTF8.GetBytes(json);
context.Response.Clear();
context.Response.StatusCode = StatusCodes.Status200OK; // 统一成200,和其他接口一致
context.Response.ContentType = "application/json; charset=utf-8";
context.Response.ContentLength = bytes.Length;
await context.Response.Body.WriteAsync(bytes);
await context.Response.Body.FlushAsync(originalBodyStream is null ? default : default);
}
else
{
// 非415的情况,把内存流内容原样写回真正的Response.Body
memoryStream.Seek(0, SeekOrigin.Begin);
await memoryStream.CopyToAsync(originalBodyStream);
}
context.Response.Body = originalBodyStream;
}
}
}
@@ -4,7 +4,7 @@ using System;
using System.Collections.Generic;
using System.Text;
namespace StoreHub.Framework.DependencyInjection
namespace MAX.Framework.DependencyInjection
{
public static class FreeSqlDB
{
@@ -1,15 +1,15 @@
using Asp.Versioning.OpenApi;
using MAX.Framework.Security;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi;
using StoreHub.Framework.Security;
using System.Security.Claims;
using System.Text;
namespace StoreHub.Framework.DependencyInjection;
namespace MAX.Framework.DependencyInjection;
/// <summary>
/// JWT 认证服务注册入口(由宿主 Program.cs 调用)。
@@ -21,7 +21,7 @@ public static class JwtServiceCollectionExtensions
/// <list type="number">
/// <item>绑定 <c>Jwt</c> 配置节到 <see cref="JwtOptions"/>,并在启动时校验签名密钥长度;</item>
/// <item>注册 <see cref="ITokenService"/>(令牌生成/校验)与 <see cref="ICurrentUser"/>(当前用户访问器);</item>
/// <item>注册 <see cref="JwtBearerDefaults"/> 认证,认证失败/授权失败统一返回 <see cref="StoreHub.Framework.Commons.ApiResult{T}"/> 格式;</item>
/// <item>注册 <see cref="JwtBearerDefaults"/> 认证,认证失败/授权失败统一返回 <see cref="Dtos.ApiResult{T}"/> 格式;</item>
/// <item>注册 <see cref="IOnlineUserStore"/>(基于 <c>IMemoryCache</c> 的在线会话存储):单点登录、在线人员查询、退出销毁;</item>
/// <item>为 OpenAPI 文档声明 Bearer(JWT) 安全方案,并对标记了 [Authorize] 的接口声明需要携带令牌。</item>
/// </list>
@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NLog.Web;
using System;
using System.Collections.Generic;
using System.Text;
namespace MAX.Framework.DependencyInjection
{
public static class LoggerExtensions
{
public static WebApplicationBuilder AddLogger(this WebApplicationBuilder builder)
{
builder.Logging.ClearProviders();
//builder.Logging.SetMinimumLevel(LogLevel.Information);
builder.Host.UseNLog();
return builder;
}
}
}
@@ -1,10 +1,14 @@
using MAX.Core.DependencyInjection;
using MAX.Core.Infrastructure;
using MAX.Core.Interfaces;
using MAX.Framework.Commons;
using MAX.Framework.Dtos;
using MAX.Framework.Interfaces;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using System.Diagnostics;
using System.Reflection;
namespace StoreHub.Framework.DependencyInjection
namespace MAX.Framework.DependencyInjection
{
/// <summary>
/// IOC 注册入口(由宿主 Program.cs 调用)
@@ -28,10 +32,36 @@ namespace StoreHub.Framework.DependencyInjection
public static IServiceCollection AddStoreHub(this IServiceCollection services)
{
IEnumerable<Assembly> assemblies = GetApplicationAssemblies();
RegisterDependencyMarkers(services, assemblies);
RunStartups(services, assemblies);
RunModules(services, assemblies);
services.AddControllers(options =>
{
options.Filters.Add<GlobalExceptionFilter>();
options.Filters.Add<UnifyResultFilter>();
}).ConfigureApiBehaviorOptions(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
// 取出所有校验错误信息,拼成一个字符串(或者只取第一个)
var errors = context.ModelState
.Where(e => e.Value?.Errors.Count > 0)
.SelectMany(e => e.Value!.Errors.Select(err => err.ErrorMessage))
.ToList();
var msg = string.Join("; ", errors);
var result = ApiResult.Fail(msg, ResultCode.ValidationError);
return new ObjectResult(result)
{
StatusCode = 200 // 和其他接口保持一致,用Code表达失败,而不是HTTP 400
};
};
});
return services;
}
@@ -85,6 +115,17 @@ namespace StoreHub.Framework.DependencyInjection
startup.Register(registrar);
}
}
private static void RunModules(IServiceCollection services, IEnumerable<Assembly> assemblies)
{
IServiceRegistrar registrar = new ServiceRegistrarProxy(services);
foreach (IModule startup in GetConcreteTypes(assemblies)
.Where(t => typeof(IModule).IsAssignableFrom(t))
.Select(t => (IModule)Activator.CreateInstance(t, true)!))
{
startup.AddModule(services);
}
}
private static IEnumerable<Type> GetConcreteTypes(IEnumerable<Assembly> assemblies)
{
@@ -1,7 +1,7 @@
using MAX.Core.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
namespace StoreHub.Framework.DependencyInjection
namespace MAX.Framework.DependencyInjection
{
/// <summary>
/// 将 <see cref="IServiceRegistrar"/> 适配到 Microsoft.Extensions.DependencyInjection
@@ -1,6 +1,6 @@
using MAX.Core.Reflections;
namespace StoreHub.Framework.DependencyInjection
namespace MAX.Framework.DependencyInjection
{
/// <summary>
/// 本应用的类型查找器:在 AppTypeFinder 默认前缀(MAX. / Nexus.)之外,
@@ -12,7 +12,7 @@ namespace StoreHub.Framework.DependencyInjection
protected override List<string> AvailablePrefix()
{
return ["StoreHub"];
return ["Store"];
}
}
}
+69
View File
@@ -0,0 +1,69 @@
using MAX.Framework.Commons;
using static System.Runtime.InteropServices.JavaScript.JSType;
namespace MAX.Framework.Dtos
{
public static class ResultCode
{
public const int Success = 200;
public const int Fail = 500;
public const int Unauthorized = 401;
public const int Forbidden = 403;
public const int NotFound = 404;
/// <summary>
/// 参数校验失败
/// </summary>
public const int ValidationError = 1001;
/// <summary>
/// 业务逻辑异常
/// </summary>
public const int BusinessError = 2001;
/// <summary>
/// 服务器内部错误
/// </summary>
public const int ServerError = 5000;
}
/// <summary>
/// 非泛型基类,用于统一处理
/// </summary>
public abstract class ApiResultBase
{
/// <summary>业务状态码</summary>
public int Code { get; set; } = ResultCode.Success;
/// <summary>提示信息</summary>
public string? Msg { get; set; }
/// <summary>请求是否成功</summary>
public bool Success => Code == ResultCode.Success;
}
/// <summary>
/// 泛型响应
/// </summary>
public class ApiResult<T> : ApiResultBase
{
public T? Data { get; set; }
public static ApiResult<T> Success(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 };
}
/// <summary>
/// 响应
/// </summary>
public class ApiResult : ApiResult<object>
{
public static ApiResult Success(string msg = "操作成功")
=> new ApiResult { Code = ResultCode.Success, Msg = msg };
public static ApiResult Fail(string msg = "操作失败", int code = ResultCode.Fail)
=> new ApiResult { Code = code, Msg = msg };
}
}
@@ -4,7 +4,7 @@ using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
namespace StoreHub.Framework.Helpers;
namespace MAX.Framework.Helpers;
public static class ObjectHelper
{
@@ -3,7 +3,7 @@ using System;
using System.Diagnostics;
using System.Text;
namespace StoreHub.Framework.Helpers;
namespace MAX.Framework.Helpers;
public class WinCmdHelper
{
+12
View File
@@ -0,0 +1,12 @@
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.Text;
namespace MAX.Framework.Interfaces
{
public interface IModule
{
void AddModule(IServiceCollection services);
}
}
+138
View File
@@ -0,0 +1,138 @@
//using MAX.Core.Infrastructure;
//using MAX.Core.Logger;
//using NLog;
//using System;
//using LLogLevel = MAX.Core.Logger.LogLevel;
//using NLogLevel = NLog.LogLevel;
//namespace MAX.Framework.Logger;
//public class NLogger : SingleInstance<NLogger>, ILoggerFacade
//{
// private readonly ILogger _log;
// private NLogger()
// {
// _log = LogManager.GetLogger("GlobalLogger");
// }
// public bool IsDebugEnabled { get; private set; } = true;
// public void Debug(Exception ex, string message)
// {
// Execute(NLogLevel.Debug, ex, message);
// }
// public void Debug(string message)
// {
// Execute(NLogLevel.Debug, message);
// }
// public void Error(Exception ex)
// {
// Execute(NLogLevel.Error, ex);
// }
// public void Error(Exception ex, string message)
// {
// Execute(NLogLevel.Error, ex, message);
// }
// public void Error(string message)
// {
// Execute(NLogLevel.Error, message);
// }
// public void Fatal(Exception ex, string message)
// {
// Execute(NLogLevel.Fatal, ex, message);
// }
// public void Fatal(string message)
// {
// Execute(NLogLevel.Fatal, message);
// }
// public void Info(Exception ex, string message)
// {
// Execute(NLogLevel.Info, ex, message);
// }
// public void Info(string message)
// {
// Execute(NLogLevel.Info, message);
// }
// public void Warn(Exception ex, string message)
// {
// Execute(NLogLevel.Warn, ex, message);
// }
// public void Warn(string message)
// {
// Execute(NLogLevel.Warn, message);
// }
// public void Log(string message, LLogLevel level)
// {
// NLogLevel lv = NLogLevel.Info;
// switch (level)
// {
// case LLogLevel.Trace:
// lv = NLogLevel.Trace;
// break;
// case LLogLevel.Debug:
// lv = NLogLevel.Debug;
// break;
// case LLogLevel.Info:
// lv = NLogLevel.Info;
// break;
// case LLogLevel.Warn:
// lv = NLogLevel.Warn;
// break;
// case LLogLevel.Error:
// lv = NLogLevel.Error;
// break;
// case LLogLevel.Fatal:
// lv = NLogLevel.Fatal;
// break;
// case LLogLevel.None:
// lv = NLogLevel.Off;
// break;
// }
// Execute(lv, message);
// }
// /// <summary>
// /// 是否启用
// /// </summary>
// private bool Enabled(NLogLevel level)
// {
// if (level > NLogLevel.Debug)
// {
// return true;
// }
// return IsDebugEnabled;
// }
// /// <summary>
// /// 执行
// /// </summary>
// private void Execute(NLogLevel level, Exception ex, string message = "")
// {
// if (Enabled(level))
// {
// _log.Log(level, ex, message, null);
// }
// }
// /// <summary>
// /// 执行
// /// </summary>
// private void Execute(NLogLevel level, string message)
// {
// if (Enabled(level))
// {
// _log.Log(level, message);
// }
// }
//}
@@ -8,10 +8,6 @@
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Core\MAX.Core\MAX.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.2.1" />
<PackageReference Include="Asp.Versioning.OpenApi" Version="10.2.1" />
@@ -19,8 +15,16 @@
<PackageReference Include="FreeSql.Provider.MySql" Version="3.5.311" />
<PackageReference Include="FreeSql.Repository" Version="3.5.311" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageReference Include="NLog" Version="6.1.4" />
<PackageReference Include="NLog.Web.AspNetCore" Version="6.2.0" />
<PackageReference Include="Riok.Mapperly" Version="4.3.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MAX.Core\MAX.Core.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Models\" />
</ItemGroup>
</Project>
@@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Http;
using System.Globalization;
using System.Security.Claims;
namespace StoreHub.Framework.Security;
namespace MAX.Framework.Security;
/// <summary>
/// 从 <see cref="IHttpContextAccessor"/> 读取当前请求认证票据的默认实现。
@@ -1,6 +1,6 @@
using System.Security.Claims;
namespace StoreHub.Framework.Security;
namespace MAX.Framework.Security;
/// <summary>
/// 当前登录用户访问器:从当前请求的认证票据中读取用户信息。
@@ -1,4 +1,4 @@
namespace StoreHub.Framework.Security;
namespace MAX.Framework.Security;
/// <summary>
/// 在线用户会话存储:基于 <see cref="Microsoft.Extensions.Caching.Memory.IMemoryCache"/> 的进程内实现,
@@ -1,6 +1,6 @@
using System.Security.Claims;
namespace StoreHub.Framework.Security;
namespace MAX.Framework.Security;
/// <summary>
/// 令牌生成结果
@@ -1,10 +1,10 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using MAX.Framework.Dtos;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http;
using StoreHub.Framework.Commons;
using System.Text;
using System.Text.Json;
namespace StoreHub.Framework.Security;
namespace MAX.Framework.Security;
/// <summary>
/// JwtBearer 认证失败/授权失败时,统一返回 <see cref="ApiResult{T}"/> 格式的 JSON 响应。
@@ -38,7 +38,7 @@ internal static class JwtBearerResponseHandler
httpContext.Response.StatusCode = statusCode;
httpContext.Response.ContentType = "application/json; charset=utf-8";
string body = JsonSerializer.Serialize(ApiResult.Error(message, statusCode), JsonOptions);
string body = JsonSerializer.Serialize(ApiResult.Fail(message, statusCode), JsonOptions);
await httpContext.Response.WriteAsync(body, Encoding.UTF8);
}
}
@@ -1,4 +1,4 @@
namespace StoreHub.Framework.Security;
namespace MAX.Framework.Security;
/// <summary>
/// JWT 自定义声明类型常量。
@@ -1,7 +1,7 @@
using Microsoft.IdentityModel.Tokens;
using System.Text;
namespace StoreHub.Framework.Security;
namespace MAX.Framework.Security;
/// <summary>
/// JWT 配置选项,默认绑定配置节 <c>Jwt</c>。
@@ -1,4 +1,4 @@
namespace StoreHub.Framework.Security;
namespace MAX.Framework.Security;
/// <summary>
/// 在线用户会话信息(单点登录:同一用户仅保留一个有效会话)。
@@ -1,7 +1,7 @@
using Microsoft.Extensions.Caching.Memory;
using System.Collections.Concurrent;
namespace StoreHub.Framework.Security;
namespace MAX.Framework.Security;
/// <summary>
/// 基于 <see cref="IMemoryCache"/> 的在线用户会话存储。
@@ -4,7 +4,7 @@ using Microsoft.IdentityModel.Tokens;
using System.Security.Claims;
using System.Text;
namespace StoreHub.Framework.Security;
namespace MAX.Framework.Security;
/// <summary>
/// 基于 <see cref="JwtOptions"/> 的 JWT 令牌服务。
+24
View File
@@ -0,0 +1,24 @@
using FreeSql;
using MAX.Core.DependencyInjection;
using MAX.Core.Interfaces;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace MAX.Framework
{
public class Startup : IStartup
{
public int Priority => IntPriority.Highest;
public void Initialize()
{
}
public void Register(IServiceRegistrar registrar)
{
}
}
}
@@ -0,0 +1,328 @@
using Asp.Versioning;
using MAX.Framework.Commons;
using MAX.Framework.Dtos;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Store.Framework.Base;
using Store.Modules.CloudProduct.Dtos;
using Store.Modules.CloudProduct.Entities;
using Store.Modules.CloudProduct.ImportConverters;
using Store.Modules.CloudProduct.Services;
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.Json.Nodes;
namespace Store.Modules.CloudProduct.Controllers
{
[ApiVersionNeutral]
[Route("/CloudProduct/[action]")]
public class ProductController : AppControllerBase
{
private readonly IProductService _productService;
private readonly IOpenSearchServer _searchServer;
private readonly RawDataStorage _rawDataStorage;
public ProductController(IProductService productService, IOpenSearchServer searchServer, RawDataStorage rawDataStorage)
{
_productService = productService;
_searchServer = searchServer;
_rawDataStorage = rawDataStorage;
}
[HttpGet]
public async Task<string> Time()
{
return DateTime.Now.ToLongDateString();
}
/// <summary>
/// 搜索
/// </summary>
/// <param name="keyword"></param>
/// <returns></returns>
[HttpPost]
public async Task<PageData<ProductEntity>> SearchGoods(ProductSearchRequest request)
{
// 1. 参数基础校验
if (request == null)
{
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
}
// 2. 分页参数校验
if (request.PageIndex < 1)
{
throw new AppException("页码必须大于0", ResultCode.ValidationError);
}
if (request.PageSize < 1 || request.PageSize > 2000)
{
throw new AppException("每页数量必须在1-2000之间", ResultCode.ValidationError);
}
// 3. 价格区间校验
if (request.MinPrice.HasValue && request.MinPrice < 0)
{
throw new AppException("最低价格不能小于0", ResultCode.ValidationError);
}
if (request.MaxPrice.HasValue && request.MaxPrice < 0)
{
throw new AppException("最高价格不能小于0", ResultCode.ValidationError);
}
if (request.MinPrice.HasValue && request.MaxPrice.HasValue && request.MinPrice > request.MaxPrice)
{
throw new AppException("最低价格不能大于最高价格", ResultCode.ValidationError);
}
// 4. 销量区间校验
if (request.MinSoldQuantity.HasValue && request.MinSoldQuantity < 0)
{
throw new AppException("最低销量不能小于0", ResultCode.ValidationError);
}
if (request.MaxSoldQuantity.HasValue && request.MaxSoldQuantity < 0)
{
throw new AppException("最高销量不能小于0", ResultCode.ValidationError);
}
if (request.MinSoldQuantity.HasValue && request.MaxSoldQuantity.HasValue &&
request.MinSoldQuantity > request.MaxSoldQuantity)
{
throw new AppException("最低销量不能大于最高销量", ResultCode.ValidationError);
}
// 5. 类目层级校验
if (request.CatId2.HasValue && !request.CatId1.HasValue)
{
throw new AppException("选择二级类目时必须先选择一级类目", ResultCode.ValidationError);
}
if (request.CatId3.HasValue && !request.CatId2.HasValue)
{
throw new AppException("选择三级类目时必须先选择二级类目", ResultCode.ValidationError);
}
if (request.CatId4.HasValue && !request.CatId3.HasValue)
{
throw new AppException("选择四级类目时必须先选择三级类目", ResultCode.ValidationError);
}
// 6. 平台校验
if (request.Platform.HasValue && request.Platform < 0)
{
throw new AppException("平台类型无效", ResultCode.ValidationError);
}
// 10. 至少有一个搜索条件
if (string.IsNullOrWhiteSpace(request.Title) &&
!request.MinPrice.HasValue && !request.MaxPrice.HasValue &&
!request.CatId1.HasValue && !request.CatId2.HasValue &&
!request.CatId3.HasValue && !request.CatId4.HasValue &&
!request.MinSoldQuantity.HasValue && !request.MaxSoldQuantity.HasValue &&
!request.Platform.HasValue)
{
throw new AppException("请至少提供一个搜索条件", ResultCode.ValidationError);
}
var result = await _productService.SearchGoods(request);
return new PageData<ProductEntity>
{
List = result.List,
Total = result.Total,
PageSize = request.PageSize,
PageIndex = request.PageIndex,
};
}
[HttpGet]
public async Task<PageData<ProductSearchInfo>> SearchGoodsToIds(string keyword, int pageindex = 1, int pagesize = 20)
{
if (string.IsNullOrWhiteSpace(keyword))
{
throw new AppException("填写关键字", ResultCode.ValidationError);
}
var ret = await _searchServer.SearchByTitleAsync(new ProductSearchRequest
{
Title = keyword,
PageIndex = pageindex,
PageSize = pagesize,
});
return new PageData<ProductSearchInfo>
{
PageIndex = pageindex,
PageSize = pagesize,
List = ret.list,
Total = ret.Total,
};
}
/// <summary>
/// 导入测试数据:读取 ImportConverters/data.txt 并走完整解析 + 入库 + 分词流程。
/// </summary>
[HttpGet]
public async Task<GoodsImportResult> ImportTestData()
{
var filePath = GetDataFilePath();
if (!System.IO.File.Exists(filePath))
{
throw new AppException($"找不到测试数据文件: {filePath}", ResultCode.NotFound);
}
var content = await System.IO.File.ReadAllTextAsync(filePath);
var goods = ConverterToGoodsFactory.ConvertContent(content);
if (goods == null)
{
throw new AppException("未找到匹配的转换器,无法解析测试数据");
}
return await _productService.ImportGoodsAsync(new[]
{
new GoodsImportItem { ProductEntity = goods, RawData = content }
});
}
/// <summary>
/// 导入接口:接收客户端批量上传的 JSON 文件,解析后入库并写入分词索引。
/// </summary>
[HttpPost]
public async Task<GoodsImportResult> ImportFiles([FromForm] List<IFormFile> files)
{
if (files == null || files.Count == 0)
{
throw new AppException("请选择要导入的 JSON 文件", ResultCode.ValidationError);
}
var items = new List<GoodsImportItem>();
var parseErrors = new List<string>();
foreach (var file in files)
{
if (file == null || file.Length == 0)
{
continue;
}
try
{
using var reader = new StreamReader(file.OpenReadStream());
var content = await reader.ReadToEndAsync();
var goods = ConverterToGoodsFactory.ConvertContent(content);
if (goods == null)
{
parseErrors.Add($"{file.FileName}: 未找到匹配的转换器");
}
else
{
items.Add(new GoodsImportItem { ProductEntity = goods, RawData = content });
}
}
catch (Exception ex)
{
parseErrors.Add($"{file.FileName}: {ex.Message}");
}
}
var result = await _productService.ImportGoodsAsync(items);
result.Failed += parseErrors.Count;
result.Errors.InsertRange(0, parseErrors);
return result;
}
/// <summary>
/// 查询 OpenSearch 状态。
/// </summary>
[HttpGet]
public async Task<OpenSearchStatusDto> GetOpenSearchStatus()
{
return await _searchServer.GetStatusAsync();
}
/// <summary>
/// 读取指定商品详情:解压缩原始数据并返回 JSON。
/// </summary>
[HttpGet]
public async Task<JsonNode> GetGoodsDetail([FromQuery] long goodsId)
{
var raw = await GetRawDataOrThrowAsync(goodsId);
var node = JsonNode.Parse(raw);
return node ?? throw new AppException("商品详情解析失败", ResultCode.BusinessError);
}
/// <summary>
/// 读取指定商品的原始数据(解压缩后原样返回)。
/// </summary>
[HttpGet]
public async Task<string> GetGoodsRawData([FromQuery] long goodsId)
{
return await GetRawDataOrThrowAsync(goodsId);
}
/// <summary>
/// 重新从数据库读取指定商品,覆盖 OpenSearch 已有分词并重新分词。
/// ids 为空时重建全部商品。
/// </summary>
[HttpPost]
public async Task<long> RebuildIndex([FromBody] long[]? ids)
{
return await _productService.RebuildSearchIndexAsync(ids);
}
/// <summary>
/// 删除商品(按数据库主键 ID)。是否软删除由配置 Delete:SoftDelete 决定,
/// 软删除将 Status 置 1,硬删除物理删除记录;两种方式都会删除 OpenSearch 中对应文档。
/// </summary>
[HttpPost]
public async Task<long> DeleteGoods([FromBody] long[]? ids)
{
if (ids == null || ids.Length == 0)
{
throw new AppException("请提供要删除的商品ID", ResultCode.ValidationError);
}
return await _productService.DeleteGoodsAsync(ids);
}
[HttpGet]
public async Task<bool> ClearSearch()
{
return await _searchServer.ClearIndexAsync();
}
[HttpGet]
public async Task<long> GetSearchCount()
{
return await _searchServer.CountDocumentsAsync();
}
private async Task<string> GetRawDataOrThrowAsync(long goodsId)
{
if (goodsId <= 0)
throw new AppException("商品ID无效", ResultCode.ValidationError);
var raw = await _rawDataStorage.ReadAsync(goodsId);
if (raw == null)
throw new AppException($"商品原始数据不存在: {goodsId}", ResultCode.NotFound);
return raw;
}
private static string GetDataFilePath()
{
var outputPath = Path.Combine(AppContext.BaseDirectory, "ImportConverters", "data.txt");
if (System.IO.File.Exists(outputPath))
return outputPath;
var workPath = Path.Combine(Directory.GetCurrentDirectory(), "ImportConverters", "data.txt");
if (System.IO.File.Exists(workPath))
return workPath;
return outputPath;
}
}
}
@@ -0,0 +1,14 @@
using Store.Modules.CloudProduct.Entities;
namespace Store.Modules.CloudProduct.Dtos
{
/// <summary>
/// 导入项:商品模型 + 原始数据
/// </summary>
public class GoodsImportItem
{
public ProductEntity ProductEntity { get; set; } = null!;
public string RawData { get; set; } = string.Empty;
}
}
@@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace Store.Modules.CloudProduct.Dtos
{
/// <summary>
/// 商品导入结果
/// </summary>
public class GoodsImportResult
{
/// <summary>
/// 成功条数
/// </summary>
public int Success { get; set; }
/// <summary>
/// 失败条数
/// </summary>
public int Failed { get; set; }
/// <summary>
/// 总条数
/// </summary>
public int Total => Success + Failed;
/// <summary>
/// 错误明细
/// </summary>
public List<string> Errors { get; set; } = new();
}
}
@@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace Store.Modules.CloudProduct.Dtos
{
/// <summary>
/// OpenSearch 运行状态
/// </summary>
public class OpenSearchStatusDto
{
/// <summary>
/// 节点地址
/// </summary>
public string NodeUrl { get; set; } = string.Empty;
/// <summary>
/// 索引名称
/// </summary>
public string IndexName { get; set; } = string.Empty;
/// <summary>
/// 是否可连接
/// </summary>
public bool Connected { get; set; }
/// <summary>
/// 索引是否存在
/// </summary>
public bool IndexExists { get; set; }
/// <summary>
/// 文档总数(索引不存在时为 -1)
/// </summary>
public long DocumentCount { get; set; } = -1;
/// <summary>
/// 说明信息
/// </summary>
public string? Message { get; set; }
}
}
@@ -0,0 +1,60 @@
using OpenSearch.Client;
using System;
using System.Collections.Generic;
using System.Text;
namespace Store.Modules.CloudProduct.Dtos
{
/// <summary>
/// 商品搜索模型:Id 为主键,Title 用于分词搜索
/// </summary>
public class ProductSearchInfo
{
[Number(NumberType.Byte, Name = "platform")]
public byte Platform { get; set; }
[Number(NumberType.Long, Name = "id")]
public long Id { get; set; }
[Text(Name = "title", Analyzer = "ik_max_word", SearchAnalyzer = "ik_smart")]
public string Title { get; set; } = string.Empty;
[Text(Name = "thumbUrl")]
public string? ThumbUrl { get; set; } = string.Empty;
/// <summary>一级类目ID</summary>
[Number(NumberType.Integer, Name = "cat_id_1")]
public int? CatId1 { get; set; }
/// <summary>二级类目ID</summary>
[Number(NumberType.Integer, Name = "cat_id_2")]
public int? CatId2 { get; set; }
/// <summary>三级类目ID</summary>
[Number(NumberType.Integer, Name = "cat_id_3")]
public int? CatId3 { get; set; }
/// <summary>四级类目ID</summary>
[Number(NumberType.Integer, Name = "cat_id_4")]
public int? CatId4 { get; set; }
/// <summary>最低价快照,用于价格区间过滤和按价格排序</summary>
[Number(NumberType.Long, Name = "min_price")]
public long? MinPrice { get; set; }
/// <summary>销量,用于按销量排序</summary>
[Number(NumberType.Integer, Name = "sold_quantity")]
public int SoldQuantity { get; set; }
public override string ToString() => $"[Id={Id}] Title={Title} Cat=({CatId1},{CatId2},{CatId3},{CatId4}) Price={MinPrice} Sold={SoldQuantity}";
}
/// <summary>
/// 搜索排序方式
/// </summary>
public enum GoodsSearchSortBy
{
Relevance = 0, // 相关性(默认)
PriceAsc = 1, // 价格从低到高
PriceDesc = 2, // 价格从高到低
SoldDesc = 3, // 销量从高到低
}
}
@@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace Store.Modules.CloudProduct.Dtos
{
/// <summary>
/// 商品搜索请求
/// </summary>
public class ProductSearchRequest
{
/// <summary>
/// 标题(模糊搜索)
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 最低价格(分)
/// </summary>
public long? MinPrice { get; set; }
/// <summary>
/// 最高价格(分)
/// </summary>
public long? MaxPrice { get; set; }
/// <summary>
/// 一级类目ID
/// </summary>
public int? CatId1 { get; set; }
/// <summary>
/// 二级类目ID
/// </summary>
public int? CatId2 { get; set; }
/// <summary>
/// 三级类目ID
/// </summary>
public int? CatId3 { get; set; }
/// <summary>
/// 四级类目ID
/// </summary>
public int? CatId4 { get; set; }
/// <summary>
/// 最低销量
/// </summary>
public int? MinSoldQuantity { get; set; }
/// <summary>
/// 最高销量
/// </summary>
public int? MaxSoldQuantity { get; set; }
/// <summary>
/// 平台类型
/// </summary>
public byte? Platform { get; set; }
/// <summary>
/// 页码(默认1)
/// </summary>
public int PageIndex { get; set; } = 1;
/// <summary>
/// 每页数量(默认20)
/// </summary>
public int PageSize { get; set; } = 20;
/// <summary>
/// 排序字段
/// </summary>
public string SortBy { get; set; } = "CreatedTime";
/// <summary>
/// 排序方式:asc/desc(默认desc)
/// </summary>
public string SortOrder { get; set; } = "desc";
}
}
@@ -0,0 +1,124 @@
using FreeSql.DataAnnotations;
using System.Text.Json.Serialization;
namespace Store.Modules.CloudProduct.Entities
{
/// <summary>
/// 商品表实体
/// </summary>
[Table(Name = "goods")] // 请根据实际表名调整
public class ProductEntity
{
/// <summary>
/// 自增ID
/// </summary>
[Column(IsPrimary = true, IsIdentity = true)]
[JsonIgnore]
public long Id { get; set; }
/// <summary>
/// 平台类型
/// 1 PDD
/// </summary>
[Column(IsNullable = false)]
public byte Platform { get; set; }
/// <summary>
/// 商品编号
/// </summary>
[Column(Name = "goods_id", IsNullable = false)]
public long GoodsId { get; set; }
/// <summary>
/// 标题
/// </summary>
[Column(Name = "title", IsNullable = false, StringLength = 500)]
public string Title { get; set; } = string.Empty;
/// <summary>
/// 描述
/// </summary>
[Column(Name = "description", IsNullable = true)]
public string? Description { get; set; }
/// <summary>
/// 缩略图
/// </summary>
[Column(Name = "thumb_url", IsNullable = true, StringLength = 512)]
public string? ThumbUrl { get; set; }
/// <summary>
/// 一级类目ID
/// </summary>
[Column(Name = "cat_id_1", IsNullable = true)]
public int? CatId1 { get; set; }
/// <summary>
/// 二级类目ID
/// </summary>
[Column(Name = "cat_id_2", IsNullable = true)]
public int? CatId2 { get; set; }
/// <summary>
/// 三级类目ID
/// </summary>
[Column(Name = "cat_id_3", IsNullable = true)]
public int? CatId3 { get; set; }
/// <summary>
/// 四级类目ID
/// </summary>
[Column(Name = "cat_id_4", IsNullable = true)]
public int? CatId4 { get; set; }
/// <summary>
/// 最低价快照(元),导入时计算
/// </summary>
[Column(Name = "min_price")]
public long? MinPrice { get; set; }
/// <summary>
/// 最高价快照(元),导入时计算
/// </summary>
[Column(Name = "max_price")]
public long? MaxPrice { get; set; }
/// <summary>
/// 销量
/// </summary>
[Column(Name = "sold_quantity", IsNullable = false)]
public int SoldQuantity { get; set; }
/// <summary>
/// 状态:0正常 1已删除
/// </summary>
[Column(IsNullable = false)]
[JsonIgnore]
public byte Status { get; set; }
/// <summary>
/// 创建时间
/// </summary>
[Column(Name = "createdTime", IsNullable = false, ServerTime = DateTimeKind.Utc)]
[JsonIgnore]
public DateTime CreatedTime { get; set; }
/// <summary>
/// 更新时间(自动更新)
/// </summary>
[Column(Name = "updatedTime", IsNullable = false, ServerTime = DateTimeKind.Utc)]
[JsonIgnore]
public DateTime UpdatedTime { get; set; }
/// <summary>
/// 是否已分词:0未 1已
/// </summary>
[Column(Name = "is_segmented", IsNullable = false)]
[JsonIgnore]
public byte IsSegmented { get; set; }
}
}
@@ -0,0 +1,9 @@
namespace Store.Modules.CloudProduct.Enums
{
public enum PlatformType
{
None = 0,
PDD = 1,
WX = 2,
}
}
@@ -0,0 +1,58 @@
using Store.Modules.CloudProduct.Entities;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Store.Modules.CloudProduct.ImportConverters
{
/// <summary>
/// 导入商品转换工厂:按顺序尝试各转换器,返回标准商品模型。
/// </summary>
public class ConverterToGoodsFactory
{
private static readonly List<IGoodsConverter> _converters = new List<IGoodsConverter>
{
new ConverterV1(),
};
/// <summary>
/// 根据本地 JSON 文件路径转换。
/// </summary>
public static ProductEntity? Convert(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
throw new ArgumentNullException(nameof(filePath));
if (!File.Exists(filePath))
throw new FileNotFoundException("找不到导入文件", filePath);
string content = File.ReadAllText(filePath);
return ConvertContent(content);
}
/// <summary>
/// 根据 JSON 内容字符串转换。
/// </summary>
public static ProductEntity? ConvertContent(string content)
{
if (string.IsNullOrWhiteSpace(content))
throw new ArgumentNullException(nameof(content));
foreach (IGoodsConverter item in _converters)
{
item.ReadContent(content);
if (item.IsMatch())
{
var goods = item.Convert();
if (goods != null)
return goods;
}
}
return null;
}
}
}
@@ -0,0 +1,180 @@
using Store.Modules.CloudProduct.Entities;
using Store.Modules.CloudProduct.Enums;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
namespace Store.Modules.CloudProduct.ImportConverters
{
/// <summary>
/// PDD 前端商品详情数据转换器(数据源格式见 data.txt)
/// </summary>
internal class ConverterV1 : GoodsConverterBase
{
public override string ConverterName => "ConverterV1";
public override PlatformType Platform => PlatformType.PDD;
protected override bool IsMatchProvider()
{
try
{
if (JsonNode.Parse(RawData) is not JsonObject root)
return false;
// data.txt:顶层包含 goods.goods_id
if (root["goods"] is JsonObject goodsObj && goodsObj["goods_id"] != null)
return true;
// 也兼容顶层直接是商品对象的情况
if (root["goods_id"] != null && (root["goods_name"] != null || root["title"] != null))
return true;
return false;
}
catch
{
return false;
}
}
protected override ProductEntity ConverterProvider()
{
if (JsonNode.Parse(RawData) is not JsonObject root)
throw new Exception("无法解析 PDD 商品数据");
var goodsNode = root["goods"] as JsonObject ?? root;
var priceNode = root["price"] as JsonObject;
var goods = new ProductEntity
{
Platform = (byte)PlatformType.PDD,
GoodsId = GetLong(goodsNode, "goods_id"),
Title = GetString(goodsNode, "goods_name")
?? GetString(goodsNode, "short_name")
?? string.Empty,
Description = GetString(goodsNode, "share_desc") ?? GetString(goodsNode, "short_name"),
ThumbUrl = GetString(goodsNode, "hd_url") ?? GetString(goodsNode, "thumb_url") ?? GetString(goodsNode, "hd_thumb_url") ?? GetString(goodsNode, "image_url"),
CatId1 = GetIntOrNull(goodsNode, "cat_id_1"),
CatId2 = GetIntOrNull(goodsNode, "cat_id_2"),
CatId3 = GetIntOrNull(goodsNode, "cat_id_3"),
CatId4 = GetIntOrNull(goodsNode, "cat_id_4"),
SoldQuantity = GetInt(goodsNode, "sold_quantity"),
Status = 0,
};
var price = GetPriceRange(priceNode, root);
goods.MinPrice = price.Min;
goods.MaxPrice = price.Max;
return goods;
}
private static string? GetString(JsonObject? obj, string key)
{
if (obj == null || !obj.TryGetPropertyValue(key, out var node) || node == null)
return null;
if (node is JsonValue value && value.TryGetValue<string>(out var str))
return str;
return node.ToJsonString();
}
private static long GetLong(JsonObject? obj, string key)
{
if (obj == null || !obj.TryGetPropertyValue(key, out var node) || node == null)
return 0;
if (node is JsonValue value)
{
if (value.TryGetValue<long>(out var l)) return l;
if (value.TryGetValue<decimal>(out var d)) return (long)d;
if (long.TryParse(value.ToString(), out var l2)) return l2;
}
return 0;
}
private static int GetInt(JsonObject? obj, string key)
{
if (obj == null || !obj.TryGetPropertyValue(key, out var node) || node == null)
return 0;
if (node is JsonValue value)
{
if (value.TryGetValue<int>(out var i)) return i;
if (value.TryGetValue<long>(out var l)) return (int)l;
if (int.TryParse(value.ToString(), out var i2)) return i2;
}
return 0;
}
private static int? GetIntOrNull(JsonObject? obj, string key)
{
if (obj == null || !obj.TryGetPropertyValue(key, out var node) || node == null)
return null;
if (node is JsonValue value)
{
if (value.TryGetValue<int>(out var i) && i > 0) return i;
if (value.TryGetValue<long>(out var l) && l > 0) return (int)l;
if (int.TryParse(value.ToString(), out var i2) && i2 > 0) return i2;
}
return null;
}
private static long? GetLongOrNull(JsonObject? obj, string key)
{
if (obj == null || !obj.TryGetPropertyValue(key, out var node) || node == null)
return null;
if (node is JsonValue value)
{
if (value.TryGetValue<long>(out var d)) return d;
if (value.TryGetValue<long>(out var l)) return l;
if (long.TryParse(value.ToString(), out var d2)) return d2;
}
return null;
}
/// <summary>
/// 计算价格区间。优先使用顶层 price 节点,其次遍历 sku。
/// </summary>
private static (long? Min, long? Max) GetPriceRange(JsonObject? priceNode, JsonObject root)
{
long? min = null;
long? max = null;
if (priceNode != null)
{
min = GetLongOrNull(priceNode, "min_group_price");
max = GetLongOrNull(priceNode, "max_group_price");
if (min.HasValue || max.HasValue)
return (min, max);
}
if (root["sku"] is JsonArray skuArray && skuArray.Count > 0)
{
foreach (var item in skuArray.OfType<JsonObject>())
{
var price = GetLongOrNull(item, "group_price") ?? GetLongOrNull(item, "normal_price");
if (price == null) continue;
min = min.HasValue ? Math.Min(min.Value, price.Value) : price;
max = max.HasValue ? Math.Max(max.Value, price.Value) : price;
}
if (min.HasValue || max.HasValue)
return (min, max);
}
return (null, null);
}
}
}
@@ -0,0 +1,99 @@
using Store.Modules.CloudProduct.Entities;
using Store.Modules.CloudProduct.Enums;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
namespace Store.Modules.CloudProduct.ImportConverters
{
/// <summary>
/// 导入商品数据转换器
/// </summary>
public interface IGoodsConverter
{
/// <summary>
/// 原始数据
/// </summary>
string RawData { get; }
/// <summary>
/// 获取转换器名称
/// </summary>
string ConverterName { get; }
/// <summary>
/// 支持的平台类型
/// </summary>
PlatformType Platform { get; }
/// <summary>
/// 读取内容
/// </summary>
/// <param name="content"></param>
void ReadContent(string content);
/// <summary>
/// 判断是否匹配当前转换器
/// </summary>
/// <returns>是否匹配</returns>
bool IsMatch();
/// <summary>
/// 转换为标准商品模型
/// </summary>
ProductEntity Convert();
}
public abstract class GoodsConverterBase : IGoodsConverter
{
private bool _isMatch = false;
public string RawData { get; private set; }
public abstract string ConverterName { get; }
public abstract PlatformType Platform { get; }
void IGoodsConverter.ReadContent(string filePath)
{
ReadContent(filePath);
}
protected virtual void ReadContent(string filePath)
{
RawData = filePath;
}
public virtual bool IsMatch()
{
_isMatch = true;
return IsMatchProvider();
}
protected abstract bool IsMatchProvider();
/// <summary>
/// 带校验的转换(基类统一实现)
/// </summary>
public virtual ProductEntity Convert()
{
if (_isMatch == false) throw new Exception("未校验数据");
try
{
JsonNode.Parse(RawData);
}
catch { throw new Exception("数据解析失败,不是有效的JSON格式"); }
var product = ConverterProvider();
if (product == null)
throw new Exception("转换失败");
// 基础校验
if (product.GoodsId == 0)
throw new Exception("商品ID不能为空");
return product;
}
protected abstract ProductEntity ConverterProvider();
}
}
@@ -0,0 +1,498 @@
using MAX.Core.DependencyInjection;
using MAX.Framework.Commons;
using Microsoft.Extensions.Configuration;
using MySqlX.XDevAPI;
using OpenSearch.Client;
using OpenSearch.Net;
using Store.Modules.CloudProduct.Dtos;
using Store.Modules.CloudProduct.Entities;
namespace Store.Modules.CloudProduct.Services
{
public interface IOpenSearchServer: ISingletonDependency
{
Task AddOneRecordAsync(ProductSearchInfo goods);
Task BulkIndexGoodsAsync(IEnumerable<ProductSearchInfo> docs);
Task<bool> ClearIndexAsync();
Task<long> CountDocumentsAsync();
Task<bool> CreateIndexAsync();
Task DeleteDocumentsAsync(IEnumerable<long> ids);
Task DeleteIndexAsync();
Task<OpenSearchStatusDto> GetStatusAsync();
Task<long> RebuildIndexAsync(IEnumerable<ProductEntity> goodsList);
Task<(long Total, List<ProductSearchInfo> list)> SearchByTitleAsync(ProductSearchRequest request, GoodsSearchSortBy sortBy = GoodsSearchSortBy.Relevance);
}
internal class OpenSearchServer : IOpenSearchServer
{
private readonly string nodeUrl;
private IOpenSearchClient _client = null!;
private readonly string IndexName = "";
// 查询类操作(search/count/status/ping)统一超时
private static readonly TimeSpan QueryTimeout = TimeSpan.FromSeconds(5);
// 批量写入/建索引/删除索引等管理类操作超时(数据量大时需要更长,可挪到配置文件)
private static readonly TimeSpan AdminTimeout = TimeSpan.FromSeconds(30);
public OpenSearchServer(IConfiguration configuration)
{
var config = configuration.GetSection("Search");
nodeUrl = config.GetValue<string>("Url") ?? "";
IndexName = config.GetValue<string>("IndexName") ?? "";
_client = CreateClient();
}
private IOpenSearchClient CreateClient()
{
var pool = new SingleNodeConnectionPool(new Uri(nodeUrl));
var settings = new ConnectionSettings(pool)
.DefaultIndex(IndexName)
.DefaultMappingFor<ProductSearchInfo>(m => m.IndexName(IndexName))
.RequestTimeout(QueryTimeout)
.MaxRetryTimeout(QueryTimeout)
.PrettyJson()
.DisableDirectStreaming(); // 便于调试打印请求/响应报文
return new OpenSearchClient(settings);
}
/// <summary>
/// ============ 1. 创建索引 ============
/// </summary>
public async Task<bool> CreateIndexAsync()
{
var existsResponse = await _client.Indices.ExistsAsync(IndexName, d => d
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout)));
if (existsResponse.Exists)
{
if (await IsIndexMappingHealthyAsync())
{
Console.WriteLine($"索引 [{IndexName}] 已存在且映射正确,无需重复创建。");
return true;
}
Console.WriteLine($"索引 [{IndexName}] 的映射不完整(可能由清空索引等操作造成),正在重建...");
var deleteResponse = await _client.Indices.DeleteAsync(IndexName, d => d
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout)));
if (!deleteResponse.IsValid)
{
throw new AppException("删除损坏索引失败: " + deleteResponse.DebugInformation);
}
}
// 优先尝试使用 IK 中文分词(需集群安装 analysis-ik 插件)
var createResponse = await TryCreateIndexAsync(useIk: true);
if (!createResponse.IsValid)
{
Console.WriteLine("IK 分词插件不可用,已回退到 standard 分析器,中文分词将失效,请检查集群是否安装 analysis-ik 插件。");
createResponse = await TryCreateIndexAsync(useIk: false);
}
if (createResponse.IsValid)
{
Console.WriteLine($"索引 [{IndexName}] 创建成功。");
return true;
}
else
{
throw new AppException("索引创建失败: " + createResponse.DebugInformation);
}
}
/// <summary>
/// 校验索引映射是否健康:title 字段必须使用 IK 分析器
/// </summary>
private async Task<bool> IsIndexMappingHealthyAsync()
{
try
{
var getResponse = await _client.Indices.GetAsync(IndexName, d => d
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
if (!getResponse.IsValid || !getResponse.Indices.TryGetValue(IndexName, out var indexState))
{
return false;
}
var mapping = indexState.Mappings;
if (mapping?.Properties == null
|| !mapping.Properties.TryGetValue(new PropertyName("title"), out var property)
|| property is not ITextProperty textProperty)
{
return false;
}
return textProperty.Analyzer == "ik_max_word" && textProperty.SearchAnalyzer == "ik_smart";
}
catch
{
return false;
}
}
private async Task<CreateIndexResponse> TryCreateIndexAsync(bool useIk)
{
var indexAnalyzer = useIk ? "ik_max_word" : "standard";
var searchAnalyzer = useIk ? "ik_smart" : "standard";
return await _client.Indices.CreateAsync(IndexName, c => c
.Settings(s => s
.NumberOfShards(1)
.NumberOfReplicas(0)
)
.Map<ProductSearchInfo>(m => m
.Properties(p => p
.Number(n => n.Name(x => x.Id).Type(NumberType.Long))
.Text(t => t
.Name(x => x.Title)
.Analyzer(indexAnalyzer)
.SearchAnalyzer(searchAnalyzer)
)
.Number(n => n.Name(x => x.CatId1).Type(NumberType.Integer))
.Number(n => n.Name(x => x.CatId2).Type(NumberType.Integer))
.Number(n => n.Name(x => x.CatId3).Type(NumberType.Integer))
.Number(n => n.Name(x => x.CatId4).Type(NumberType.Integer))
.Number(n => n.Name(x => x.MinPrice).Type(NumberType.Long))
.Number(n => n.Name(x => x.SoldQuantity).Type(NumberType.Integer))
)
)
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout))
);
}
/// <summary>
/// ============ 2. 删除索引 ============
/// </summary>
public async Task DeleteIndexAsync()
{
var response = await _client.Indices.DeleteAsync(IndexName, d => d
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout)));
if (!response.IsValid)
{
throw new AppException("删除失败: " + response.DebugInformation);
}
}
/// <summary>
/// ============ 3.1 批量写入商品搜索文档(存在则覆盖) ============
/// </summary>
public async Task BulkIndexGoodsAsync(IEnumerable<ProductSearchInfo> docs)
{
if (docs == null) throw new ArgumentNullException(nameof(docs));
var list = docs.ToList();
if (list.Count == 0) return;
// 确保索引已按 IK 分词映射创建,否则中文分词无效
await CreateIndexAsync();
var bulkResponse = await _client.BulkAsync(b => b
.Index(IndexName)
.IndexMany(list, (descriptor, doc) => descriptor.Id(doc.Id))
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout))
);
if (!bulkResponse.IsValid)
{
throw new AppException("写入 OpenSearch 失败: " + bulkResponse.DebugInformation);
}
// 刷新索引,保证数据立即可搜索(仅用于演示,生产环境不建议频繁 Refresh)
await _client.Indices.RefreshAsync(IndexName, d => d
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout)));
}
/// <summary>
/// ============ 3.2 根据数据库商品重建搜索索引 ============
/// </summary>
public async Task<long> RebuildIndexAsync(IEnumerable<ProductEntity> goodsList)
{
if (goodsList == null) throw new ArgumentNullException(nameof(goodsList));
var docs = goodsList
.Where(x => x != null && x.GoodsId > 0)
.Select(x => new ProductSearchInfo
{
Id = x.GoodsId,
Title = x.Title ?? string.Empty,
CatId1 = x.CatId1,
CatId2 = x.CatId2,
CatId3 = x.CatId3,
CatId4 = x.CatId4,
MinPrice = x.MinPrice,
SoldQuantity = x.SoldQuantity
})
.ToList();
if (docs.Count == 0) return 0;
await BulkIndexGoodsAsync(docs);
return docs.Count;
}
/// <summary>
/// ============ 3.3 按主键批量删除 OpenSearch 文档(软/硬删除共用) ============
/// </summary>
public async Task DeleteDocumentsAsync(IEnumerable<long> ids)
{
var list = ids?.Distinct().ToList() ?? new List<long>();
if (list.Count == 0) return;
var exists = await _client.Indices.ExistsAsync(IndexName, d => d
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
if (!exists.Exists)
{
return;
}
var response = await _client.BulkAsync(b =>
{
foreach (var id in list)
{
b.Delete<ProductSearchInfo>(d => d.Index(IndexName).Id(id));
}
b.RequestConfiguration(r => r.RequestTimeout(AdminTimeout));
return b;
});
if (!response.IsValid)
{
throw new AppException("删除 OpenSearch 文档失败: " + response.DebugInformation);
}
await _client.Indices.RefreshAsync(IndexName, d => d
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout)));
}
/// <summary>
/// ============ 4. 手动新增一条 ============
/// </summary>
public async Task AddOneRecordAsync(ProductSearchInfo goods)
{
if (goods == null) throw new ArgumentNullException(nameof(goods));
var response = await _client.IndexAsync(goods, i => i
.Index(IndexName)
.Id(goods.Id)
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout))
);
if (response.IsValid)
{
await _client.Indices.RefreshAsync(IndexName, d => d
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
}
else
{
throw new AppException("新增失败: " + response.DebugInformation);
}
}
/// <summary>
/// ============ 5. 分词搜索(分页 + 类目/价格过滤 + 排序) ============
/// </summary>
/// <param name="keyword">搜索关键词</param>
/// <param name="catId1">一级类目,不传则不限制</param>
/// <param name="catId2">二级类目,不传则不限制</param>
/// <param name="catId3">三级类目,不传则不限制</param>
/// <param name="catId4">四级类目,不传则不限制</param>
/// <param name="minPrice">最低价(含),不传则不限制</param>
/// <param name="maxPrice">最高价(含),不传则不限制</param>
/// <param name="sortBy">排序方式</param>
/// <param name="pageIndex">页码,从 1 开始</param>
/// <param name="pageSize">每页条数</param>
public async Task<(long Total, List<ProductSearchInfo> list)> SearchByTitleAsync(ProductSearchRequest request,
GoodsSearchSortBy sortBy = GoodsSearchSortBy.Relevance)
{
string keyword = request.Title;
int? catId1 = request.CatId1;
int? catId2 = request.CatId2;
int? catId3 = request.CatId3;
int? catId4 = request.CatId4;
long? minPrice = request.MinPrice;
long? maxPrice = request.MaxPrice;
int? minsold = request.MinSoldQuantity;
int? maxsold = request.MaxSoldQuantity;
int? platform = request.Platform;
int pageIndex = request.PageIndex;
int pageSize = request.PageSize;
//if (string.IsNullOrWhiteSpace(keyword))
//{
// throw new AppException("关键词不能为空。");
//}
if (pageIndex < 1) pageIndex = 1;
if (pageSize < 1) pageSize = 20;
if (pageSize > 2000) pageSize = 2000; // 避免深分页/单页过大拖垮性能,按需调整上限
var from = (pageIndex - 1) * pageSize;
ISearchResponse<ProductSearchInfo> searchResponse;
try
{
searchResponse = await _client.SearchAsync<ProductSearchInfo>(s => s
.Index(IndexName)
.Query(q => q
.Bool(b =>
{
if (string.IsNullOrWhiteSpace(keyword) == false)
{
b.Must(m => m.Match(mm => mm.Field(f => f.Title).Query(keyword)));
}
var filters = new List<Func<QueryContainerDescriptor<ProductSearchInfo>, QueryContainer>>();
if (platform.HasValue && platform!.Value > 0) filters.Add(f => f.Term(t => t.Field(x => x.Platform).Value(platform.Value)));
if (catId1.HasValue) filters.Add(f => f.Term(t => t.Field(x => x.CatId1).Value(catId1.Value)));
if (catId2.HasValue) filters.Add(f => f.Term(t => t.Field(x => x.CatId2).Value(catId2.Value)));
if (catId3.HasValue) filters.Add(f => f.Term(t => t.Field(x => x.CatId3).Value(catId3.Value)));
if (catId4.HasValue) filters.Add(f => f.Term(t => t.Field(x => x.CatId4).Value(catId4.Value)));
if (minPrice.HasValue || maxPrice.HasValue)
{
filters.Add(f => f.Range(r =>
{
var range = r.Field(x => x.MinPrice);
if (minPrice.HasValue) range = range.GreaterThanOrEquals(minPrice.Value);
if (maxPrice.HasValue) range = range.LessThanOrEquals(maxPrice.Value);
return range;
}));
}
if (minsold.HasValue || maxsold.HasValue)
{
filters.Add(f => f.Range(r =>
{
var range = r.Field(x => x.SoldQuantity);
if (minsold.HasValue) range = range.GreaterThanOrEquals(minsold.Value);
if (maxsold.HasValue) range = range.LessThanOrEquals(maxsold.Value);
return range;
}));
}
if (filters.Count > 0) b.Filter(filters.ToArray());
return b;
})
)
.Sort(sort => sortBy switch
{
GoodsSearchSortBy.PriceAsc => sort.Ascending(f => f.MinPrice),
GoodsSearchSortBy.PriceDesc => sort.Descending(f => f.MinPrice),
GoodsSearchSortBy.SoldDesc => sort.Descending(f => f.SoldQuantity),
_ => sort.Descending(SortSpecialField.Score)
})
.From(from)
.Size(pageSize)
.TrackTotalHits(true) // 命中数超过 10000 时默认不精确统计,需要显式开启
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout))
);
}
catch (OpenSearchClientException ex) when (ex.Response?.Success == false)
{
throw new AppException($"搜索超时或失败({QueryTimeout.TotalSeconds}秒): " + ex.Message);
}
if (!searchResponse.IsValid)
{
throw new AppException("搜索失败: " + searchResponse.DebugInformation);
}
//var ids = searchResponse.Documents.Select(d => d.Id).ToList();
return (searchResponse.Total, searchResponse.Documents.ToList());
}
/// <summary>
/// ============ 6. 统计文档数 ============
/// </summary>
public async Task<long> CountDocumentsAsync()
{
var countResponse = await _client.CountAsync<ProductSearchInfo>(c => c
.Index(IndexName)
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout))
);
if (countResponse.IsValid)
{
Console.WriteLine($"索引 [{IndexName}] 当前文档总数: {countResponse.Count}");
return countResponse.Count;
}
else
{
Console.WriteLine("统计失败: " + countResponse.DebugInformation);
throw new AppException(countResponse.DebugInformation);
}
}
/// <summary>
/// ============ 7. 查询 OpenSearch 状态 ============
/// </summary>
public async Task<OpenSearchStatusDto> GetStatusAsync()
{
var status = new OpenSearchStatusDto
{
NodeUrl = nodeUrl,
IndexName = IndexName,
};
try
{
var ping = await _client.PingAsync(p => p
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
status.Connected = ping.IsValid;
if (!ping.IsValid)
{
status.Message = ping.DebugInformation;
return status;
}
var exists = await _client.Indices.ExistsAsync(IndexName, d => d
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
status.IndexExists = exists.Exists;
if (exists.Exists)
{
var count = await _client.CountAsync<ProductSearchInfo>(c => c
.Index(IndexName)
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
status.DocumentCount = count.IsValid ? count.Count : -1;
if (!count.IsValid)
status.Message = count.DebugInformation;
}
}
catch (Exception ex)
{
status.Connected = false;
status.Message = ex.Message;
}
return status;
}
/// <summary>
/// 清空索引:删除后按统一映射重建,重建期间该索引不可查(由后台定时任务负责回填数据,接受短暂空窗)
/// </summary>
public async Task<bool> ClearIndexAsync()
{
var existsResponse = await _client.Indices.ExistsAsync(IndexName, d => d
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
if (!existsResponse.Exists)
{
Console.WriteLine($"索引 {IndexName} 不存在");
return false;
}
var deleteResponse = await _client.Indices.DeleteAsync(IndexName, d => d
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout)));
if (!deleteResponse.IsValid)
{
Console.WriteLine($"删除索引 {IndexName} 失败: {deleteResponse.DebugInformation}");
return false;
}
Console.WriteLine($"索引 {IndexName} 已删除");
return await CreateIndexAsync();
}
}
}
@@ -0,0 +1,13 @@
using FreeSql;
using MAX.Core.DependencyInjection;
using Store.Modules.CloudProduct.Entities;
namespace Store.Modules.CloudProduct.Services
{
public class ProductRepository : BaseRepository<ProductEntity>, IScopedDependency
{
public ProductRepository(IFreeSql fsql) : base(fsql)
{
}
}
}
@@ -0,0 +1,409 @@
using MAX.Core.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Store.Modules.CloudProduct.Dtos;
using Store.Modules.CloudProduct.Entities;
namespace Store.Modules.CloudProduct.Services
{
public interface IProductService : IScopedDependency
{
Task<int> DeleteGoodsAsync(IEnumerable<long> ids);
Task<List<ProductEntity>> GetGoodsByIdsAsync(IEnumerable<long> ids);
Task<GoodsImportResult> ImportGoodsAsync(IEnumerable<GoodsImportItem> items);
Task<int> IndexPendingGoodsAsync(int batchSize);
Task<long> RebuildSearchIndexAsync(IEnumerable<long>? ids = null);
Task<(List<ProductEntity> List, long Total)> SearchGoods(ProductSearchRequest request);
Task<int> UpdateImage(IEnumerable<ProductEntity> goods);
}
internal class ProductService : IProductService
{
private readonly ProductRepository _repository;
private readonly OpenSearchServer _search;
private readonly RawDataStorage _rawDataStorage;
private readonly IConfiguration _configuration;
public ProductService(ProductRepository repository, OpenSearchServer search, RawDataStorage rawDataStorage, IConfiguration configuration)
{
_repository = repository;
_search = search;
_rawDataStorage = rawDataStorage;
_configuration = configuration;
}
public async Task<(List<ProductEntity> List, long Total)> SearchGoods(ProductSearchRequest request)
{
// 1. 从搜索引擎获取标题匹配的商品ID列表
List<ProductEntity> goodsList = new();
long total = 0;
// 从搜索引擎查询标题匹配的ID
(long Total, List<ProductSearchInfo> list) searchRet = (await _search.SearchByTitleAsync(request));
goodsList = searchRet.list.Select(a => new ProductEntity
{
Platform = a.Platform,
GoodsId = a.Id,
Title = a.Title,
CatId1 = a.CatId1,
CatId2 = a.CatId2,
CatId3 = a.CatId3,
CatId4 = a.CatId4,
MinPrice = a.MinPrice,
MaxPrice = 0,
SoldQuantity = a.SoldQuantity,
ThumbUrl = a.ThumbUrl,
}).ToList();
//goodsIds = searchRet.list.Select(d => d.Id).ToList();
total = searchRet.Total;
//// 如果没有匹配的结果,直接返回空
//if (!goodsIds.Any())
//{
// return (new List<ProductEntity>(), 0);
//}
return (goodsList, total);
//// 2. 构建数据库查询(只查正常状态,排除软删除)
//var query = _repository.Select.Where(x => x.Status == 0);
//// 如果有标题搜索,先用ID过滤
//if (goodsIds.Any())
//{
// query = query.Where(x => goodsIds.Contains(x.GoodsId));
//}
//// 3. 价格区间查询
//if (request.MinPrice.HasValue)
//{
// query = query.Where(x => x.MinPrice >= request.MinPrice.Value);
//}
//if (request.MaxPrice.HasValue)
//{
// query = query.Where(x => x.MaxPrice <= request.MaxPrice.Value);
//}
//// 4. 类目查询
//if (request.CatId1.HasValue)
//{
// query = query.Where(x => x.CatId1 == request.CatId1);
//}
//if (request.CatId2.HasValue)
//{
// query = query.Where(x => x.CatId2 == request.CatId2);
//}
//if (request.CatId3.HasValue)
//{
// query = query.Where(x => x.CatId3 == request.CatId3);
//}
//if (request.CatId4.HasValue)
//{
// query = query.Where(x => x.CatId4 == request.CatId4);
//}
//// 5. 销量区间查询
//if (request.MinSoldQuantity.HasValue)
//{
// query = query.Where(x => x.SoldQuantity >= request.MinSoldQuantity.Value);
//}
//if (request.MaxSoldQuantity.HasValue)
//{
// query = query.Where(x => x.SoldQuantity <= request.MaxSoldQuantity.Value);
//}
//// 6. 平台过滤
//if (request.Platform.HasValue)
//{
// query = query.Where(x => x.Platform == request.Platform);
//}
//// 8. 获取总数
//total = await query.CountAsync();
//// 9. 排序
//if (!string.IsNullOrEmpty(request.SortBy))
//{
// var isAsc = request.SortOrder?.ToLower() == "asc";
// query = query.OrderByPropertyName(request.SortBy, isAsc);
//}
//else
//{
// query = query.OrderByDescending(x => x.CreatedTime);
//}
//// 10. 分页查询
//var list = await query
// //.Skip((request.PageIndex - 1) * request.PageSize)
// //.Limit(request.PageSize)
// .ToListAsync();
////内存排序
//var orderMap = goodsIds.Select((id, idx) => new { id, idx })
// .ToDictionary(x => x.id, x => x.idx);
//list = list.OrderBy(x => orderMap.TryGetValue(x.GoodsId, out var idx) ? idx : int.MaxValue)
// .ToList();
//return (list, total);
}
/// <summary>
/// 批量导入商品:先落库(按平台 + 商品编号去重)+ 保存原始数据,
/// 然后根据配置决定“立即分词”还是“标记未同步交给后台任务分词”。
/// </summary>
public async Task<GoodsImportResult> ImportGoodsAsync(IEnumerable<GoodsImportItem> items)
{
var result = new GoodsImportResult();
if (items == null)
return result;
var indexImmediately = IsImmediateIndexing();
var docs = new List<ProductSearchInfo>();
var indexedIds = new List<long>();
foreach (var item in items)
{
var goods = item?.ProductEntity;
if (goods == null)
{
result.Failed++;
result.Errors.Add("商品数据为空");
continue;
}
var goodsId = goods.GoodsId;
if (goodsId == 0)
{
result.Failed++;
result.Errors.Add("商品编号(GoodsId)不能为空");
continue;
}
try
{
// 导入时统一先标记“未同步”;立即分词成功后再置为“已同步”,
// 后台模式下保持“未同步”,由 Hangfire 定时任务分词。
goods.IsSegmented = 0;
var saved = await UpsertGoodsAsync(goods);
var rawData = item?.RawData ?? string.Empty;
if (!string.IsNullOrWhiteSpace(rawData))
{
await _rawDataStorage.SaveAsync(saved.GoodsId, rawData);
}
if (indexImmediately)
{
docs.Add(new ProductSearchInfo
{
Platform = saved.Platform,
Id = saved.GoodsId,
Title = saved.Title ?? string.Empty,
ThumbUrl = saved.ThumbUrl,
CatId1 = saved.CatId1,
CatId2 = saved.CatId2,
CatId3 = saved.CatId3,
CatId4 = saved.CatId4,
MinPrice = saved.MinPrice,
SoldQuantity = saved.SoldQuantity,
});
indexedIds.Add(saved.GoodsId);
}
result.Success++;
}
catch (Exception ex)
{
result.Failed++;
result.Errors.Add($"GoodsId={goodsId}: {ex.Message}");
}
}
if (docs.Count > 0)
{
try
{
await _search.BulkIndexGoodsAsync(docs);
await MarkIndexedAsync(indexedIds);
}
catch (Exception ex)
{
// 写入失败时保持“未同步”,由后台定时任务重试
result.Failed += docs.Count;
result.Success -= docs.Count;
result.Errors.Add("写入 OpenSearch 失败: " + ex.Message);
}
}
return result;
}
/// <summary>
/// 后台任务:批量处理未同步(IndexStatus=0)的商品,写入 OpenSearch 后标记为已同步。
/// </summary>
public async Task<int> IndexPendingGoodsAsync(int batchSize)
{
if (batchSize <= 0)
batchSize = 200;
var pending = await _repository.Select
.Where(x => x.IsSegmented == 0 && x.Status == 0)
.OrderBy(x => x.GoodsId)
.Limit(batchSize)
.ToListAsync();
if (pending.Count == 0)
return 0;
var docs = pending
.Select(x => new ProductSearchInfo
{
Platform = x.Platform,
Id = x.GoodsId,
Title = x.Title ?? string.Empty,
ThumbUrl = x.ThumbUrl,
CatId1 = x.CatId1,
CatId2 = x.CatId2,
CatId3 = x.CatId3,
CatId4 = x.CatId4,
MinPrice = x.MinPrice,
SoldQuantity = x.SoldQuantity,
})
.ToList();
await _search.BulkIndexGoodsAsync(docs);
await MarkIndexedAsync(pending.Select(x => x.GoodsId));
return pending.Count;
}
/// <summary>
/// 按平台 + 商品编号查找已存在记录,存在则更新,不存在则新增。
/// </summary>
private async Task<ProductEntity> UpsertGoodsAsync(ProductEntity goods)
{
var existing = await _repository.Select
.Where(x => x.Platform == goods.Platform && x.GoodsId == goods.GoodsId)
.FirstAsync();
if (existing != null)
{
goods.Id = existing.Id;
goods.GoodsId = existing.GoodsId;
goods.CreatedTime = existing.CreatedTime;
goods.UpdatedTime = DateTime.Now;
await _repository.UpdateAsync(goods);
return goods;
}
goods.CreatedTime = DateTime.Now;
goods.UpdatedTime = DateTime.Now;
return await _repository.InsertAsync(goods);
}
/// <summary>
/// 根据数据库主键 ID 列表读取商品。
/// </summary>
public async Task<List<ProductEntity>> GetGoodsByIdsAsync(IEnumerable<long> ids)
{
var idList = ids?.ToList();
if (idList == null || idList.Count == 0)
return new List<ProductEntity>();
return await _repository.Select
.Where(x => idList.Contains(x.GoodsId))
.ToListAsync();
}
/// <summary>
/// 重新从数据库读取指定商品,覆盖 OpenSearch 中已有的分词并重新分词。
/// ids 为空时重建全部商品。重建成功的记录标记为已同步。
/// </summary>
public async Task<long> RebuildSearchIndexAsync(IEnumerable<long>? ids = null)
{
List<ProductEntity> goodsList;
var idList = ids?.ToList();
if (idList == null || idList.Count == 0)
{
goodsList = await _repository.Select.ToListAsync();
}
else
{
goodsList = await _repository.Select
.Where(x => idList.Contains(x.GoodsId))
.ToListAsync();
}
var count = await _search.RebuildIndexAsync(goodsList);
await MarkIndexedAsync(goodsList.Where(x => x.GoodsId > 0).Select(x => x.GoodsId));
return count;
}
/// <summary>
/// 删除商品:按配置决定软删除(Status=1)还是硬删除(物理删除),
/// 两种方式都会删除 OpenSearch 中对应的文档。
/// </summary>
public async Task<int> DeleteGoodsAsync(IEnumerable<long> ids)
{
var idList = ids?.Distinct().ToList();
if (idList == null || idList.Count == 0)
return 0;
if (IsSoftDelete())
{
// 软删除:仅用 Status 标记(0正常 1已删除)
await _repository.Orm.Update<ProductEntity>()
.Set(x => x.Status, (byte)1)
.Where(x => idList.Contains(x.GoodsId))
.ExecuteAffrowsAsync();
}
else
{
// 硬删除:直接删除数据库记录
await _repository.Orm.Delete<ProductEntity>()
.Where(x => idList.Contains(x.GoodsId))
.ExecuteAffrowsAsync();
}
// 无论软删除还是硬删除,都要删除 OpenSearch 中的对应文档
await _search.DeleteDocumentsAsync(idList);
return idList.Count;
}
public async Task<int> UpdateImage(IEnumerable<ProductEntity> goods)
{
//string sql = _repository.Orm.Update<ProductEntity>().SetSource(goods, a => a.GoodsId).UpdateColumns(a => new { a.ThumbUrl }).ToSql();
return await _repository.Orm.Update<ProductEntity>()
.SetSource(goods, a => a.GoodsId)
.UpdateColumns(a => new { a.ThumbUrl })
.ExecuteAffrowsAsync();
}
/// <summary>
/// 是否导入时立即分词(Indexing:Mode = Immediate)。
/// </summary>
private bool IsImmediateIndexing()
{
var mode = _configuration.GetValue<string>("Indexing:Mode");
return string.Equals(mode, "Immediate", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// 是否软删除(Delete:SoftDelete,默认 true)。
/// </summary>
private bool IsSoftDelete()
{
return _configuration.GetValue<bool?>("Delete:SoftDelete") ?? true;
}
private async Task MarkIndexedAsync(IEnumerable<long> ids)
{
var idList = ids?.ToList();
if (idList == null || idList.Count == 0)
return;
await _repository.Orm.Update<ProductEntity>()
.Set(x => x.IsSegmented, (byte)1)
.Where(x => idList.Contains(x.GoodsId))
.ExecuteAffrowsAsync();
}
}
}
@@ -0,0 +1,77 @@
using MAX.Core.DependencyInjection;
using Microsoft.Extensions.Configuration;
using System.IO.Compression;
using System.Text;
namespace Store.Modules.CloudProduct.Services
{
/// <summary>
/// 商品原始数据文件存储:按 goodsId % 100 分目录,文件名即商品ID,GZip 压缩保存。
/// </summary>
public class RawDataStorage : IScopedDependency
{
private readonly string _rootPath;
public RawDataStorage(IConfiguration configuration)
{
var configured = configuration.GetValue<string>("RawData:Path");
if (string.IsNullOrWhiteSpace(configured))
configured = "RawData";
_rootPath = Path.GetFullPath(configured);
}
/// <summary>
/// 保存原始数据,返回保存后的完整路径。
/// </summary>
public async Task<string> SaveAsync(long goodsId, string rawData)
{
var filePath = GetFilePath(goodsId);
var dir = Path.GetDirectoryName(filePath)!;
Directory.CreateDirectory(dir);
await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None);
await using var gzip = new GZipStream(fileStream, CompressionLevel.Fastest);
await using var writer = new StreamWriter(gzip, new UTF8Encoding(false));
await writer.WriteAsync(rawData);
return filePath;
}
/// <summary>
/// 读取并解压原始数据;文件不存在时返回 null。
/// </summary>
public async Task<string?> ReadAsync(long goodsId)
{
var filePath = GetFilePath(goodsId);
if (!File.Exists(filePath))
return null;
await using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
await using var gzip = new GZipStream(fileStream, CompressionMode.Decompress);
using var reader = new StreamReader(gzip, new UTF8Encoding(false));
return await reader.ReadToEndAsync();
}
/// <summary>
/// 判断指定商品的原始数据文件是否存在。
/// </summary>
public bool Exists(long goodsId)
{
return File.Exists(GetFilePath(goodsId));
}
private string GetFilePath(long goodsId)
{
var subDir = (goodsId % 100).ToString();
return Path.Combine(_rootPath, subDir, goodsId.ToString());
}
public async Task<string?> ReadAsync(string filePath)
{
await using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
await using var gzip = new GZipStream(fileStream, CompressionMode.Decompress);
using var reader = new StreamReader(gzip, new UTF8Encoding(false));
return await reader.ReadToEndAsync();
}
}
}
+28
View File
@@ -0,0 +1,28 @@
using MAX.Core.DependencyInjection;
using MAX.Core.Interfaces;
using MAX.Framework.Interfaces;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Store.Modules.CloudProduct
{
public class Startup : IStartup, IModule
{
public int Priority => IntPriority.Normal;
public void AddModule(IServiceCollection services)
{
services.AddControllers();
}
public void Initialize()
{
}
public void Register(IServiceRegistrar registrar)
{
}
}
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Store.Framework\Store.Framework.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Mappers\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="OpenSearch.Client" Version="2.1.0" />
</ItemGroup>
</Project>
@@ -1,27 +1,25 @@
using Asp.Versioning;
using MAX.Framework.Dtos;
using MAX.Framework.Security;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using StoreHub.Framework.Commons;
using StoreHub.Framework.Security;
using StoreHub.Services;
using StoreHub.Services.Dtos;
using System.Security.Claims;
namespace StoreHub.Controllers
namespace Store.Api.Controllers
{
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]/[action]")]
public class AuthController : ControllerBase
{
private readonly IUserService _userService;
//private readonly IUserService _userService;
private readonly ITokenService _tokenService;
private readonly ICurrentUser _currentUser;
private readonly IOnlineUserStore _onlineUserStore;
public AuthController(IUserService userService, ITokenService tokenService, ICurrentUser currentUser, IOnlineUserStore onlineUserStore)
public AuthController( ITokenService tokenService, ICurrentUser currentUser, IOnlineUserStore onlineUserStore)
{
_userService = userService;
//_userService = userService;
_tokenService = tokenService;
_currentUser = currentUser;
_onlineUserStore = onlineUserStore;
@@ -36,23 +34,23 @@ namespace StoreHub.Controllers
{
if (request is null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password))
{
return ApiResult.Error("用户名或密码不能为空", 400);
return ApiResult.Fail("用户名或密码不能为空", 400);
}
UserDto? user = await _userService.LoginAsync(request.UserName, request.Password);
if (user is null)
{
return ApiResult.Error("用户名或密码错误", 401);
}
//UserDto? user = await _userService.LoginAsync(request.UserName, request.Password);
//if (user is null)
//{
// return ApiResult.Error("用户名或密码错误", 401);
//}
var claims = new List<Claim>
{
new(JwtClaimTypes.UserName, user.UserName),
new(JwtClaimTypes.NickName, user.NickName),
};
//var claims = new List<Claim>
//{
// new(JwtClaimTypes.UserName, user.UserName),
// new(JwtClaimTypes.NickName, user.NickName),
//};
TokenResult token = _tokenService.CreateToken(claims);
return ApiResult.Success<object?>(token);
//TokenResult token = _tokenService.CreateToken(claims);
return ApiResult<object?>.Success(111);
}
/// <summary>
@@ -67,7 +65,7 @@ namespace StoreHub.Controllers
_onlineUserStore.SignOut(username);
}
return ApiResult.Success<object?>(null);
return ApiResult<object?>.Success(null);
}
/// <summary>
@@ -77,7 +75,7 @@ namespace StoreHub.Controllers
[Authorize]
public ApiResult<object?> Me()
{
return ApiResult.Success<object?>(new
return ApiResult<object?>.Success(new
{
UserName = _currentUser.UserName,
NickName = _currentUser.NickName,
@@ -92,7 +90,7 @@ namespace StoreHub.Controllers
public ApiResult<object?> OnlineUsers()
{
IReadOnlyList<OnlineUserInfo> users = _onlineUserStore.GetOnlineUsers();
return ApiResult.Success<object?>(new
return ApiResult<object?>.Success(new
{
Count = users.Count,
Users = users,
@@ -1,16 +1,15 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using StoreHub.Framework.Repositorys;
using Store.Framework.Repositorys;
using System.ComponentModel;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StoreHub.Controllers
namespace Store.Api.Controllers
{
[ApiController]
[Description("测试温度")]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiVersionNeutral]
[Route("wxxd_user/[action]")]
public class UserController : ControllerBase
{
private readonly IUserRepository _userRepository;
@@ -1,9 +1,10 @@
using Asp.Versioning;
using MAX.Framework.Dtos;
using Microsoft.AspNetCore.Mvc;
using StoreHub.Framework.Commons;
using Store.Api;
using System.ComponentModel;
namespace StoreHub.Controllers
namespace Store.Api.Controllers
{
[ApiController]
[Description("测试温度")]
@@ -17,7 +18,7 @@ namespace StoreHub.Controllers
];
[HttpGet(Name = "GetWeatherForecast")]
public ApiResult<IEnumerable<WeatherForecast>> Get()
public IEnumerable<WeatherForecast> Get()
{
IEnumerable<WeatherForecast> data = Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
@@ -27,7 +28,7 @@ namespace StoreHub.Controllers
})
.ToArray();
return ApiResult.Success(data);
return data;
}
}
}
@@ -1,8 +1,9 @@
using Asp.Versioning;
using MAX.Framework.Dtos;
using Microsoft.AspNetCore.Mvc;
using StoreHub.Framework.Commons;
using Store.Api;
namespace StoreHub.Controllers.v2
namespace Store.Api.Controllers.v2
{
[ApiController]
[ApiVersion("2.0")]
@@ -26,7 +27,7 @@ namespace StoreHub.Controllers.v2
})
.ToArray();
return ApiResult.Success(data);
return ApiResult<IEnumerable<WeatherForecastV2>>.Success(data);
}
}
}
+3 -5
View File
@@ -1,6 +1,6 @@
using Asp.Versioning;
using MAX.Framework.DependencyInjection;
using Scalar.AspNetCore;
using StoreHub.Framework.DependencyInjection;
public partial class Program
{
@@ -38,13 +38,11 @@ public partial class Program
#endregion
var app = builder.Build();
// 初始化 KJD 迁移的静态提供者(MysqlUtils 连接提供 / 业务配置读取)
StoreHub.KJDWebService.Commons.AdoProvider.SetServiceProvider(app.Services);
StoreHub.KJDWebService.Commons.KJDConfig.SetConfiguration(app.Configuration);
//StoreHub.KJDWebService.Commons.AdoProvider.SetServiceProvider(app.Services);
//StoreHub.KJDWebService.Commons.KJDConfig.SetConfiguration(app.Configuration);
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
@@ -6,7 +6,7 @@
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "scalar/v1",
"applicationUrl": "http://localhost:5295",
"applicationUrl": "http://localhost:9000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
@@ -16,7 +16,7 @@
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "scalar/v1",
"applicationUrl": "https://localhost:7241;http://localhost:5295",
"applicationUrl": "https://localhost:7241;http://localhost:9000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
@@ -5,6 +5,13 @@
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<Compile Remove="Log\**" />
<Content Remove="Log\**" />
<EmbeddedResource Remove="Log\**" />
<None Remove="Log\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
@@ -12,9 +19,8 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StoreHub.Framework\StoreHub.Framework.csproj" />
<ProjectReference Include="..\StoreHub.KJDWebService\StoreHub.KJDWebService.csproj" />
<ProjectReference Include="..\StoreHub.Services\StoreHub.Services.csproj" />
<ProjectReference Include="..\Models\CloudProduct\Store.Modules.CloudProduct.csproj" />
<ProjectReference Include="..\Store.Framework\Store.Framework.csproj" />
</ItemGroup>
</Project>
File renamed without changes.
@@ -1,4 +1,4 @@
namespace StoreHub
namespace Store.Api
{
public class WeatherForecast
{
@@ -1,4 +1,4 @@
namespace StoreHub
namespace Store.Api
{
public class WeatherForecastV2
{
File renamed without changes.
@@ -6,12 +6,13 @@
}
},
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Port=3306;Database=erp;Uid=root;Pwd=123456;",
"UserConnection": "Server=localhost;Port=3306;Database=wxxd;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;",
"KJDBAKConnection": "Max Pool Size=512;Database='kjd';Data Source='nj-cynosdbmysql-grp-9s7x16mt.sql.tencentcdb.com';Port=24049;User Id='kjdbak';Password='Temukjd0126';Charset=utf8mb4;",
"ConnKJD": "Max Pool Size=512;Database='kjd';Data Source='nj-cynosdbmysql-grp-hl51pkfr.sql.tencentcdb.com';Port=27660;User Id='kjd';Password='Temukjd0625';Charset=utf8mb4;",
"ConnKJDBAK": "Max Pool Size=512;Database='kjd';Data Source='nj-cynosdbmysql-grp-9s7x16mt.sql.tencentcdb.com';Port=24049;User Id='kjdbak';Password='Temukjd0126';Charset=utf8mb4;"
"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;",
//"KJDBAKConnection": "Max Pool Size=512;Database='kjd';Data Source='nj-cynosdbmysql-grp-9s7x16mt.sql.tencentcdb.com';Port=24049;User Id='kjdbak';Password='Temukjd0126';Charset=utf8mb4;",
//"ConnKJD": "Max Pool Size=512;Database='kjd';Data Source='nj-cynosdbmysql-grp-hl51pkfr.sql.tencentcdb.com';Port=27660;User Id='kjd';Password='Temukjd0625';Charset=utf8mb4;",
//"ConnKJDBAK": "Max Pool Size=512;Database='kjd';Data Source='nj-cynosdbmysql-grp-9s7x16mt.sql.tencentcdb.com';Port=24049;User Id='kjdbak';Password='Temukjd0126';Charset=utf8mb4;"
},
"KJDConfig": {
"UpdateVersion": "525",
+16
View File
@@ -0,0 +1,16 @@
<Solution>
<Folder Name="/Core/">
<Project Path="Core/MAX.Core/MAX.Core.csproj" />
<Project Path="Core/MAX.Framework/MAX.Framework.csproj" />
</Folder>
<Folder Name="/Modules/">
<Project Path="Models/CloudProduct/Store.Modules.CloudProduct.csproj" Id="9720597a-661b-47d8-90c7-83e17c6d5920" />
</Folder>
<Folder Name="/_SolutionItems/">
<File Path="Directory.Build.props" />
</Folder>
<Project Path="Store.API/Store.Api.csproj" />
<Project Path="Store.Framework/Store.Framework.csproj" />
<Project Path="StoreHub.KJDWebService/StoreHub.KJDWebService.csproj" />
<Project Path="StoreHub.Services/Store.Services.csproj" />
</Solution>
+12
View File
@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Text;
namespace Store.Framework.Base
{
[ApiController]
public class AppControllerBase : ControllerBase
{
}
}
@@ -3,7 +3,7 @@ using System;
using System.Collections.Generic;
using System.Text;
namespace StoreHub.Framework.Models
namespace Store.Framework.Models
{
/// <summary>
/// 用户实体
@@ -1,12 +1,12 @@
using FreeSql;
using MAX.Core.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using StoreHub.Framework.Models;
using Store.Framework.Models;
using System;
using System.Collections.Generic;
using System.Text;
namespace StoreHub.Framework.Repositorys
namespace Store.Framework.Repositorys
{
public interface IUserRepository : IBaseRepository<User>
{
@@ -1,11 +1,10 @@
using FreeSql;
using MAX.Core.DependencyInjection;
using MAX.Core.Infrastructure;
using MAX.Core.Interfaces;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StoreHub.Framework.Repositorys;
namespace StoreHub.Framework
namespace Store.Framework
{
public class Startup : IStartup
{
@@ -18,20 +17,20 @@ namespace StoreHub.Framework
public void Register(IServiceRegistrar registrar)
{
Func<IServiceProvider, IFreeSql> userFsqlFactory = r =>
{
var config = r.GetRequiredService<IConfiguration>();
var connection = config.GetConnectionString("UserConnection");
IFreeSql fsql = new FreeSql.FreeSqlBuilder()
.UseConnectionString(FreeSql.DataType.MySql, connection)
.UseAdoConnectionPool(true)
.Build();
return fsql;
};
registrar.RegisterSingleton<IFreeSql>("UserDB", (s, k) =>
{
return userFsqlFactory(s);
});
//Func<IServiceProvider, IFreeSql> userFsqlFactory = r =>
//{
// var config = r.GetRequiredService<IConfiguration>();
// var connection = config.GetConnectionString("UserConnection");
// IFreeSql fsql = new FreeSql.FreeSqlBuilder()
// .UseConnectionString(FreeSql.DataType.MySql, connection)
// .UseAdoConnectionPool(true)
// .Build();
// return fsql;
//};
//registrar.RegisterSingleton<IFreeSql>("UserDB", (s, k) =>
//{
// return userFsqlFactory(s);
//});
+20
View File
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Core\MAX.Core\MAX.Core.csproj" />
<ProjectReference Include="..\Core\MAX.Framework\MAX.Framework.csproj" />
</ItemGroup>
<ItemGroup>
</ItemGroup>
</Project>
@@ -15,7 +15,16 @@ namespace StoreHub.KJDWebService.Commons
{
_configuration = configuration;
}
public static string GetConnection(string key)
{
if (_configuration == null)
return null;
var v = _configuration[$"ConnectionStrings:{key}"];
if (!string.IsNullOrEmpty(v))
return v;
v = _configuration.GetConnectionString(key);
return v;
}
/// <summary>读取配置值(对应旧 connectionStrings["key"].ConnectionString)</summary>
public static string Get(string key)
{
+835
View File
@@ -0,0 +1,835 @@
using FreeSql;
using Microsoft.Extensions.DependencyInjection;
using MySql.Data.MySqlClient;
using System.Data;
using System.Security.Cryptography;
namespace StoreHub.KJDWebService.Commons
{
public class MysqlUtils
{
public string Message = "";
/// <summary>
/// 数据库连接字符串
/// </summary>
private string ConnString = "";
/// <summary>
/// 数据库连接
/// </summary>
private MySqlConnection Conn;
/// <summary>
/// 数据库读取对象
/// </summary>
private MySqlDataReader reader;
/// <summary>
/// 数据库执行对象
/// </summary>
private MySqlCommand cmd = new MySqlCommand();
/// <summary>
/// 错误信息
/// </summary>
//public static string ErrorString = "";
/// <summary>
/// 超时(秒)
/// </summary>
public int TimeOut = 120;
/// <summary>
/// 链接状态
/// </summary>
public bool Connected
{
get { return Conn != null && Conn.State == ConnectionState.Open; }
}
/// <summary>
/// 是否正在执行
/// </summary>
public bool IsRunning = false;
public bool IsUseTrans { get; set; } = false;
public MySqlTransaction Transaction { get; set; } = null;
/// <summary>
/// 初始化数据库链接
/// </summary>
/// <param name="connString">数据库链接</param>
public MysqlUtils(string connString, bool isUseTrans = false)
{
this.IsUseTrans = isUseTrans;
ConnString = KJDConfig.GetConnection(connString);
ConnTo();
}
/// <summary>
/// 去掉SQL中的特殊字符
/// </summary>
/// <param name="value">字符串</param>
/// <returns></returns>
public string ReplaceSql(string value)
{
return ReplaceSqlSpecial(value);
}
/// <summary>
/// 反转字符串,用于mysql反向索引
/// </summary>
/// <param name="temp"></param>
/// <returns></returns>
public static string ReverseString(string temp)
{
if (string.IsNullOrEmpty(temp))
return null;
char[] l = temp.ToCharArray();
Array.Reverse(l);
return new string(l);
}
/// <summary>
/// 去掉SQL中的特殊字符
/// </summary>
/// <param name="value">字符串</param>
/// <returns></returns>
public static string ReplaceSqlSpecial(string value)
{
if (string.IsNullOrEmpty(value))
return null;
value = value.Replace("\\", "\\\\");
value = value.Replace("'", "''");
//value = value.Replace("\"", "\\\"");
return value;
}
///// <summary>
///// 开放一个Conn获取路径,以便外部使用事务提交
///// </summary>
//public MySqlConnection GetConn()
//{
// //Transaction = Conn.BeginTransaction();
// return Conn;
//}
/// <summary>
/// 插入数据
/// </summary>
/// <param name="table"></param>
/// <param name="keys"></param>
/// <returns></returns>
public int Insert(string table, List<MysqlKeyValue> keys)
{
string temp = "", tempx = "";
for (int i = 0; i < keys.Count; i++)
{
temp += ",`" + keys[i].Key + "`";
tempx += ",'" + keys[i].Value + "'";
}
temp = temp.Substring(1);
tempx = tempx.Substring(1);
string sql = "insert into " + table + " (" + temp + ") values (" + tempx + ") ";
return (int)ExecuteInsertId(sql);
}
/// <summary>
/// 返回执行表
/// </summary>
/// <param name="SqlString"></param>
/// <returns></returns>
public DataTable ExecuteDataTable(string SqlString)
{
return ExecuteDataTable(SqlString, null);
}
/// <summary>
/// 执行sql返回DataTable
/// </summary>
/// <param name="SqlString">SQL语句</param>
/// <param name="parms">Sql参数</param>
/// <returns>DataTable</returns>
public DataTable ExecuteDataTable(string SqlString, MySqlParameter[] parms)
{
SleepOrGo();
bool frist = true;
Retry:
if (Conn == null || Conn.State != ConnectionState.Open)
ConnTo();
try
{
CmdIni();
cmd.CommandText = SqlString;
if (parms != null)
foreach (MySqlParameter pram in parms)
cmd.Parameters.Add(pram);
DataSet ds = new DataSet();
MySqlDataAdapter adapter = new MySqlDataAdapter();
adapter.SelectCommand = cmd;
adapter.Fill(ds);
return ds.Tables[0];
//DataTable dt = new DataTable();
//try
//{
// reader = cmd.ExecuteReader();
// dt.Load(reader);
//}
//catch
//{
// reader = cmd.ExecuteReader();
// dt = Read(ref reader);
//}
//return dt;
}
catch (Exception e)
{
if (frist)
{
frist = false;
ErrorDoing(e.Message);
goto Retry;
}
AddError(e.Message, SqlString);
return null;
}
finally
{
CloseReader();
IsRunning = false;
}
}
/// <summary>
/// 针对不同错误,执行不同的修正
/// </summary>
/// <param name="message"></param>
private void ErrorDoing(string message)
{
Message = message;
if (string.IsNullOrEmpty(message))
return;
if (message.Contains("Incorrect string value"))
return;
if (message.Contains("Connection must be valid and open"))
ConnTo();
if (message.Contains("There is already an open DataReader"))
{
Thread.Sleep(500);
ConnTo();
}
if (message.Contains("Fatal error encountered during command execution."))
ConnTo();
if (message.Contains(" StreamWriter ") || message.Contains("未将对象引用设置到对象的实例") || message.Contains("未将对象"))
ConnTo();
CloseReader();
Random ra = new Random(Guid.NewGuid().GetHashCode());
Thread.Sleep(ra.Next(200, 2000));
}
/// <summary>
/// 关闭读取线程
/// </summary>
private void CloseReader()
{
try
{
if (reader != null && !reader.IsClosed)
reader.Close();
cmd.Dispose();
//reader = null;
}
catch
{
}
}
/// <summary>
/// 返回执行行
/// </summary>
/// <param name="SqlString"></param>
/// <returns></returns>
public DataRow ExecuteDataTableRow(string SqlString)
{
return ExecuteDataTableRow(SqlString, null);
}
/// <summary>
/// 返回第一行
/// </summary>
/// <param name="SqlString"></param>
/// <returns></returns>
public DataRow ExecuteDataTableRow(string SqlString, MySqlParameter[] parms)
{
SleepOrGo();
bool frist = true;
Retry:
if (Conn == null || Conn.State != ConnectionState.Open)
ConnTo();
try
{
CmdIni();
cmd.CommandText = SqlString;
if (parms != null)
foreach (MySqlParameter pram in parms)
cmd.Parameters.Add(pram);
DataSet ds = new DataSet();
MySqlDataAdapter adapter = new MySqlDataAdapter();
adapter.SelectCommand = cmd;
adapter.Fill(ds);
DataTable dt = ds.Tables[0];
if (dt.Rows.Count > 0)
return dt.Rows[0];
}
catch (Exception e)
{
if (frist)
{
frist = false;
ErrorDoing(e.Message);
goto Retry;
}
AddError(e.Message, SqlString);
}
finally
{
CloseReader();
IsRunning = false;
}
return null;
}
/// <summary>
/// 返回第一个执行结果
/// </summary>
/// <param name="SqlString"></param>
/// <returns></returns>
public int ExecuteRowCount(string SqlString)
{
string f = ExecuteFirst(SqlString, null);
int c;
int.TryParse(f, out c);
return c;
}
public int ExecuteRowCount(string SqlString, MySqlParameter[] parms)
{
string f = ExecuteFirst(SqlString, parms);
int c;
int.TryParse(f, out c);
return c;
}
/// <summary>
/// 返回第一个执行结果
/// </summary>
/// <param name="SqlString"></param>
/// <returns></returns>
public string ExecuteFirst(string SqlString)
{
return ExecuteFirst(SqlString, null);
}
/// <summary>
/// 返回第一个执行结果
/// </summary>
/// <param name="SqlString"></param>
/// <returns></returns>
public int ExecuteFirstInt(string SqlString, MySqlParameter[] parms = null)
{
string id = ExecuteFirst(SqlString, parms);
int i;
int.TryParse(id, out i);
return i;
}
/// <summary>
/// 返回第一个执行结果
/// </summary>
/// <param name="SqlString"></param>
/// <returns></returns>
public decimal ExecuteFirstDecimal(string SqlString, MySqlParameter[] parms = null)
{
string id = ExecuteFirst(SqlString, parms);
decimal i;
decimal.TryParse(id, out i);
return i;
}
/// <summary>
/// 返回第一个值
/// </summary>
/// <param name="SqlString"></param>
/// <returns></returns>
public string ExecuteFirst(string SqlString, MySqlParameter[] parms)
{
SleepOrGo();
bool frist = true;
Retry:
if (Conn == null || Conn.State != ConnectionState.Open)
ConnTo();
try
{
CmdIni();
cmd.CommandText = SqlString;
if (parms != null)
foreach (MySqlParameter pram in parms)
cmd.Parameters.Add(pram);
reader = cmd.ExecuteReader();
if (reader.Read())
return reader[0].ToString();
return "";
}
catch (Exception e)
{
if (e.Message.Contains("Illegal mix of collations"))
return null;
if (frist)
{
frist = false;
ErrorDoing(e.Message);
goto Retry;
}
AddError(e.Message, SqlString);
}
finally
{
CloseReader();
IsRunning = false;
}
return null;
}
/// <summary>
/// 返回更改、插入或删除的行数
/// </summary>
/// <param name="SqlString"></param>
/// <param name="parms"></param>
/// <returns></returns>
public int ExecuteAffectedRows(string SqlString)
{
return ExecuteAffectedRows(SqlString, null);
}
/// <summary>
/// 返回更改、插入或删除的行数
/// </summary>
/// <param name="SqlString"></param>
/// <param name="parms"></param>
/// <returns></returns>
public int ExecuteAffectedRows(string SqlString, MySqlParameter[] parms)
{
SleepOrGo();
bool frist = true;
Retry:
if (Conn == null || Conn.State != ConnectionState.Open)
ConnTo();
try
{
CmdIni();
cmd.CommandText = SqlString;
if (parms != null)
foreach (MySqlParameter pram in parms)
cmd.Parameters.Add(pram);
reader = cmd.ExecuteReader();
if (reader.Read())
return reader.RecordsAffected;
return reader.RecordsAffected;
}
catch (Exception e)
{
if (e.Message.Contains("Illegal mix of collations"))
return 0;
if (frist)
{
frist = false;
ErrorDoing(e.Message);
goto Retry;
}
AddError(e.Message, SqlString);
}
finally
{
CloseReader();
IsRunning = false;
}
return 0;
}
/// <summary>
/// 返回插入ID
/// </summary>
/// <param name="SqlString"></param>
/// <returns></returns>
public long ExecuteInsertId(string SqlString)
{
return ExecuteInsertId(SqlString, null);
}
/// <summary>
/// 返回第一个值
/// </summary>
/// <param name="SqlString"></param>
/// <returns></returns>
public long ExecuteInsertId(string SqlString, MySqlParameter[] parms)
{
SleepOrGo();
bool frist = true;
Retry:
if (Conn == null || Conn.State != ConnectionState.Open)
ConnTo();
try
{
CmdIni();
cmd.CommandText = SqlString;
if (parms != null)
foreach (MySqlParameter pram in parms)
cmd.Parameters.Add(pram);
cmd.ExecuteNonQuery();
long id = cmd.LastInsertedId;
return id;
}
catch (Exception e)
{
if (e.Message.Contains(" for key 'PRIMARY'") && e.Message.Contains("Duplicate entry"))
return 0;
if (frist)
{
frist = false;
ErrorDoing(e.Message);
goto Retry;
}
AddError(e.Message, SqlString);
}
finally
{
CloseReader();
IsRunning = false;
}
return 0;
}
/// <summary>
/// 无返回
/// </summary>
/// <param name="SqlString"></param>
/// <returns></returns>
public bool ExecuteNonQuery(string SqlString)
{
return ExecuteNonQuery(SqlString, null);
}
/// <summary>
/// 只保留mysql可识别的字符串
/// </summary>
/// <param name="text"></param>
public string JustUtf8Words(string text)
{
JustUtf8Words(ref text);
return text;
}
/// <summary>
/// 只保留mysql可识别的字符串
/// </summary>
/// <param name="text"></param>
public void JustUtf8Words(ref string text)
{
if (string.IsNullOrEmpty(text))
return;
var cs = text.ToCharArray();
List<char> cc = new List<char>();
foreach (char c in cs)
{
if (c > 7814 && c < 8416 || c > 57096 && c < 59501 || c > 63707 && c < 64309 || c > 65505 || c < 0)
{
if (cc.Contains(c))
continue;
text = text.Replace(c.ToString(), "");
cc.Add(c);
}
}
}
/// <summary>
/// 执行无返回SQL语句
/// </summary>
/// <param name="SqlString">SQL语句</param>
/// <param name="parms">Sql参数</param>
///<returns>是否执行成功</returns>
public bool ExecuteNonQuery(string SqlString, MySqlParameter[] parms)
{
SleepOrGo();
bool frist = true;
Retry:
if (Conn == null || Conn.State != ConnectionState.Open)
ConnTo();
try
{
CmdIni();
cmd.CommandText = SqlString;
if (parms != null)
foreach (MySqlParameter pram in parms)
cmd.Parameters.Add(pram);
cmd.ExecuteNonQuery();
return true;
}
catch (Exception e)
{
if (e.Message.Contains("Illegal mix of collations") || e.Message.Contains("Incorrect string value")
|| e.Message.Contains(" for key 'PRIMARY'") && e.Message.Contains("Duplicate entry"))
return false;
if (frist)
{
frist = false;
ErrorDoing(e.Message);
goto Retry;
}
AddError(e.Message, SqlString);
return false;
}
finally
{
CloseReader();
IsRunning = false;
}
}
/// <summary>
/// 判断是否存在
/// </summary>
/// <param name="SqlString"></param>
/// <returns></returns>
public bool ExecuteExists(string SqlString)
{
return ExecuteExists(SqlString, null);
}
/// <summary>
/// 查询是否存在
/// </summary>
/// <param name="SqlString">SQL语句</param>
/// <param name="parms">SQL参数</param>
/// <returns>是否存在</returns>
public bool ExecuteExists(string SqlString, MySqlParameter[] parms)
{
SleepOrGo();
bool frist = true;
Retry:
if (Conn == null || Conn.State != ConnectionState.Open)
ConnTo();
try
{
CmdIni();
cmd.CommandText = SqlString;
if (parms != null)
foreach (MySqlParameter pram in parms)
cmd.Parameters.Add(pram);
reader = cmd.ExecuteReader();
if (reader.Read())
return true;
return false;
}
catch (Exception e)
{
if (frist)
{
frist = false;
ErrorDoing(e.Message);
goto Retry;
}
AddError(e.Message, SqlString);
return false;
}
finally
{
CloseReader();
IsRunning = false;
}
}
/// <summary>
/// 停止还是前进
/// </summary>
private void SleepOrGo()
{
int i = 0;
while (IsRunning && i < 300)
{
Thread.Sleep(100);
i++;
}
IsRunning = true;
}
/// <summary>
/// 初始化CMD
/// </summary>
private void CmdIni()
{
if (reader != null && !reader.IsClosed)
reader.Close();
if (cmd != null)
cmd.Dispose();
cmd = new MySqlCommand();
cmd.Connection = Conn;
cmd.CommandType = CommandType.Text;
cmd.CommandTimeout = TimeOut;
if (Transaction != null)
cmd.Transaction = Transaction;
}
/// <summary>
/// 连接数据库
/// </summary>
private void ConnTo()
{
Close();
try
{
Conn = new MySqlConnection(ConnString);
Conn.Open();
if (IsUseTrans)
this.Transaction = Conn.BeginTransaction();
}
catch (Exception e)
{
AddError(e.Message, ConnString);
}
}
/// <summary>
/// 错误信息
/// </summary>
/// <param name="message"></param>
/// <param name="sql"></param>
private void AddError(string message, string sql)
{
if (string.IsNullOrEmpty(message) || message.Contains("Illegal mix of collations"))
return;
ErrorDoing(message);
string errStr = "数据库连接错误:" + message + "\r\nSQL语句:" + sql;
try
{
if (!Directory.Exists("Log"))
{
Directory.CreateDirectory("Log");
}
File.AppendAllText("Log\\log-" + DateTime.Now.ToString("yyyyMMdd") + ".txt", errStr);
}
catch (Exception ex)
{
}
}
public void Commit()
{
if (Transaction != null)
Transaction.Commit();
}
public void RollBack()
{
if (Transaction != null)
Transaction.Rollback();
}
/// <summary>
/// 关闭数据库链接
/// </summary>
public void Close()
{
IsRunning = false;
try
{
if (Conn != null && Conn.State == ConnectionState.Open)
{
Conn.Close();
}
Conn = null;
try
{
if (reader != null && !reader.IsClosed)
reader.Close();
}
catch
{
}
reader = null;
try
{
if (Transaction != null)
Transaction.Dispose();
}
catch
{
}
Transaction = null;
}
catch
{
}
}
}
public class MysqlKeyValues
{
public List<MysqlKeyValue> List = new List<MysqlKeyValue>();
}
public class MysqlKeyValue
{
public MysqlKeyValue(string key, object value)
{
Key = key;
Value = value == null ? "" : MysqlUtils.ReplaceSqlSpecial(value.ToString());
}
public string Key = "";
public string Value;
}
/// <summary>
/// 根据连接串 key 提供 FreeSql IAdo(由框架 DI 注册的 keyed IFreeSql 提供)
/// </summary>
public static class AdoProvider
{
private static IServiceProvider _serviceProvider;
public static void SetServiceProvider(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public static IAdo Get(string connKey)
{
if (string.IsNullOrEmpty(connKey))
connKey = "ConnKJD";
string key = connKey switch
{
"ConnKJD" => "KJDDB",
"ConnKJDBAK" => "KJDBAKDB",
_ => "KJDDB",
};
var fsql = _serviceProvider?.GetKeyedService<IFreeSql>(key);
if (fsql == null)
{
var fsql2 = _serviceProvider?.GetService<IFreeSql>();
if (fsql2 != null)
return fsql2.Ado;
throw new InvalidOperationException("IFreeSql not registered for key " + key);
}
return fsql.Ado;
}
}
}
@@ -1,6 +1,6 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using StoreHub.Framework.Commons;
using Store.Framework.Commons;
using StoreHub.KJDWebService.Services;
namespace StoreHub.KJDWebService.Controllers
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff. Show more