修改代码
增加云商品数据
This commit is contained in:
1 parent
2f406c840f
commit
db4f030c17
58 files changed
+1550
-120
No files matched your search
@@ -0,0 +1,117 @@
|
||||
using Hangfire;
|
||||
using Hangfire.MySql;
|
||||
using HangfireBasicAuthenticationFilter;
|
||||
using MAX.Core.Interfaces;
|
||||
using MAX.Framework.Interfaces;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using MySql.Data.MySqlClient;
|
||||
using Store.Modules.CloudProduct.Services;
|
||||
|
||||
namespace Store.Modules.CloudProduct
|
||||
{
|
||||
|
||||
public class CloudProductModule : IModule
|
||||
{
|
||||
/// <summary>
|
||||
/// 本模块自己的配置文件
|
||||
/// </summary>
|
||||
public const string ConfigurationFileName = "appsettings.CloudProduct.json";
|
||||
|
||||
public int Priority => IntPriority.Normal;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 服务注册阶段(builder.Build() 之前):这里只能拿到容器和配置,碰不到 app
|
||||
/// </summary>
|
||||
public void AddModule(IHostApplicationBuilder host)
|
||||
{
|
||||
IServiceCollection services = host.Services;
|
||||
IConfigurationManager configuration = host.Configuration;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ConfigurationFileName))
|
||||
{
|
||||
string path = Path.IsPathRooted(ConfigurationFileName)? ConfigurationFileName: Path.Combine(AppContext.BaseDirectory, ConfigurationFileName);
|
||||
configuration.AddJsonFile(path, optional: false, reloadOnChange: true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
//// 本模块的配置节(Search / RawData / Indexing / Delete)都在模块自己的配置文件里,
|
||||
//// 关键项缺失直接报错,避免 OpenSearch 地址为空、原始数据写到意外目录之类的隐性故障
|
||||
RequireConfiguration(configuration, "Search:Url");
|
||||
RequireConfiguration(configuration, "Search:IndexName");
|
||||
RequireConfiguration(configuration, "RawData:Path");
|
||||
|
||||
// ===== Hangfire 后台定时任务(商品分词同步) =====
|
||||
var hangfireConnection = configuration.GetConnectionString("DefaultConnection")
|
||||
?? throw new InvalidOperationException("缺少配置 ConnectionStrings:DefaultConnection,CloudProduct 模块的 Hangfire 无法启动。");
|
||||
|
||||
// AllowUserVariables=true:Hangfire.MySqlStorage 的建表/查询语句依赖用户变量
|
||||
var connectionStringBuilder = new MySqlConnectionStringBuilder(hangfireConnection)
|
||||
{
|
||||
AllowUserVariables = true
|
||||
};
|
||||
|
||||
services.AddHangfire(config => config
|
||||
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
||||
.UseSimpleAssemblyNameTypeSerializer()
|
||||
.UseRecommendedSerializerSettings()
|
||||
.UseStorage(new MySqlStorage(connectionStringBuilder.ConnectionString, new MySqlStorageOptions() { TablesPrefix = "Hangfire_" })));
|
||||
services.AddHangfireServer();
|
||||
services.AddTransient<GoodsIndexJob>();
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 管道装配阶段(builder.Build() 之后、app.Run() 之前):这里可以挂中间件、解析容器里的服务
|
||||
/// </summary>
|
||||
public void UseModule(IApplicationBuilder app)
|
||||
{
|
||||
try
|
||||
{
|
||||
var Configuration = app.ApplicationServices.GetService<IConfiguration>()!;
|
||||
// Hangfire 后台任务看板(默认地址 /hangfire)
|
||||
app.UseHangfireDashboard("/hangfire", new DashboardOptions
|
||||
{
|
||||
Authorization = new[]
|
||||
{
|
||||
new HangfireCustomBasicAuthenticationFilter(){
|
||||
User = Configuration.GetValue<string>("Hangfire:Dashboard:User") ?? "admin",
|
||||
Pass = Configuration.GetValue<string>("Hangfire:Dashboard:Pass") ?? "1234560"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 注册定时任务:把未同步(IndexStatus=0)的商品写入 OpenSearch 分词索引
|
||||
var cron = Configuration.GetValue<string>("Indexing:Cron") ?? "*/5 * * * *";
|
||||
|
||||
// 用容器里的 IRecurringJobManager,而不是静态 RecurringJob:
|
||||
// AddHangfire 只把 JobStorage 注册进容器(没有设置 JobStorage.Current),静态 API 在 Build() 之后调用会直接抛异常。
|
||||
app.ApplicationServices.GetRequiredService<IRecurringJobManager>().AddOrUpdate<GoodsIndexJob>(
|
||||
"goods-index-pending",
|
||||
job => job.ProcessPendingAsync(),
|
||||
cron);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 数据库不可用/表未初始化时避免阻断应用启动(看板与定时任务都会连库),恢复后重启即可重新装配
|
||||
Console.WriteLine("[Hangfire] 看板/定时任务装配失败: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验模块配置项存在(值来自模块自己的 <see cref="ConfigurationFileName"/>)
|
||||
/// </summary>
|
||||
private static void RequireConfiguration(IConfiguration configuration, string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuration.GetValue<string>(key)))
|
||||
{
|
||||
throw new InvalidOperationException($"缺少配置 {key},请检查模块配置文件 {ConfigurationFileName}。");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
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.Text.Json.Nodes;
|
||||
|
||||
namespace Store.Modules.CloudProduct.Controllers
|
||||
{
|
||||
[ApiVersionNeutral]
|
||||
[Route("api/CloudGoods/[action]")]
|
||||
public class GoodsController : AppControllerBase
|
||||
{
|
||||
private readonly IGoodsService _goodsService;
|
||||
private readonly IOpenSearchService _searchService;
|
||||
private readonly IRawDataStorage _rawDataStorage;
|
||||
private readonly GoodsIndexJob _goodsIndexJob;
|
||||
|
||||
public GoodsController(IGoodsService goodsService, IOpenSearchService searchService, IRawDataStorage rawDataStorage, GoodsIndexJob goodsIndexJob)
|
||||
{
|
||||
_goodsService = goodsService;
|
||||
_searchService = searchService;
|
||||
_rawDataStorage = rawDataStorage;
|
||||
_goodsIndexJob = goodsIndexJob;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<string> Time()
|
||||
{
|
||||
return DateTime.Now.ToLongDateString();
|
||||
}
|
||||
[HttpGet]
|
||||
public async Task Job()
|
||||
{
|
||||
await _goodsIndexJob.ProcessPendingAsync();
|
||||
}
|
||||
/// <summary>
|
||||
/// 搜索
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost]
|
||||
public async Task<PageData<GoodsEntity>> SearchGoods(GoodsSearchRequest 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 _goodsService.SearchGoodsAsync(request);
|
||||
return new PageData<GoodsEntity>
|
||||
{
|
||||
List = result.List,
|
||||
Total = result.Total,
|
||||
PageSize = request.PageSize,
|
||||
PageIndex = request.PageIndex,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分词搜索,直接返回索引中的商品摘要(路由保持 SearchGoodsToIds 不变)
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ActionName("SearchGoodsToIds")]
|
||||
public async Task<PageData<GoodsSearchDocument>> SearchGoodsSummary(string keyword, int pageindex = 1, int pagesize = 20)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
throw new AppException("填写关键字", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
var result = await _searchService.SearchByTitleAsync(new GoodsSearchRequest
|
||||
{
|
||||
Title = keyword,
|
||||
PageIndex = pageindex,
|
||||
PageSize = pagesize,
|
||||
});
|
||||
|
||||
return new PageData<GoodsSearchDocument>
|
||||
{
|
||||
PageIndex = pageindex,
|
||||
PageSize = pagesize,
|
||||
List = result.Documents,
|
||||
Total = result.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 = GoodsConverterFactory.ConvertContent(content);
|
||||
if (goods == null)
|
||||
{
|
||||
throw new AppException("未找到匹配的转换器,无法解析测试数据");
|
||||
}
|
||||
|
||||
return await _goodsService.ImportGoodsAsync(new[]
|
||||
{
|
||||
new GoodsImportItem { Goods = 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 = GoodsConverterFactory.ConvertContent(content);
|
||||
if (goods == null)
|
||||
{
|
||||
parseErrors.Add($"{file.FileName}: 未找到匹配的转换器");
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new GoodsImportItem { Goods = goods, RawData = content });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
parseErrors.Add($"{file.FileName}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
var result = await _goodsService.ImportGoodsAsync(items);
|
||||
result.FailedCount += parseErrors.Count;
|
||||
result.Errors.InsertRange(0, parseErrors);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询 OpenSearch 状态。
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<OpenSearchStatusDto> GetOpenSearchStatus()
|
||||
{
|
||||
return await _searchService.GetStatusAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查搜索索引是否存在。
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<bool> IndexExists()
|
||||
{
|
||||
return await _searchService.IndexExistsAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查索引映射(分析器)是否符合当前服务要求。
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<bool> IndexMappingHealthy()
|
||||
{
|
||||
return await _searchService.IsIndexMappingHealthyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建搜索索引(含 IK 分词映射,已存在且映射正常时跳过)。
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<bool> CreateIndex()
|
||||
{
|
||||
return await _searchService.CreateIndexAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除搜索索引。
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task DeleteIndex()
|
||||
{
|
||||
await _searchService.DeleteIndexAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 手动刷新搜索索引,使最新写入立即可搜索。
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task RefreshIndex()
|
||||
{
|
||||
await _searchService.RefreshIndexAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 手动新增或覆盖一条搜索文档。
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task AddSearchDocument([FromBody] GoodsSearchDocument document)
|
||||
{
|
||||
if (document == null || document.Id <= 0)
|
||||
{
|
||||
throw new AppException("请提供有效的搜索文档", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
await _searchService.AddDocumentAsync(document);
|
||||
}
|
||||
|
||||
/// <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 _goodsService.RebuildSearchIndexAsync(ids);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除商品(按商品编号)。是否软删除由配置 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 _goodsService.DeleteGoodsAsync(ids);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清空并重建搜索索引(路由保持 ClearSearch 不变)。
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ActionName("ClearSearch")]
|
||||
public async Task<bool> ClearSearchIndex()
|
||||
{
|
||||
return await _searchService.ClearIndexAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 统计索引中的文档数(路由保持 GetSearchCount 不变)。
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ActionName("GetSearchCount")]
|
||||
public async Task<long> GetSearchDocumentCount()
|
||||
{
|
||||
return await _searchService.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,20 @@
|
||||
using Store.Modules.CloudProduct.Entities;
|
||||
|
||||
namespace Store.Modules.CloudProduct.Dtos
|
||||
{
|
||||
/// <summary>
|
||||
/// 导入项:商品模型 + 原始数据
|
||||
/// </summary>
|
||||
public class GoodsImportItem
|
||||
{
|
||||
/// <summary>
|
||||
/// 解析后的商品模型
|
||||
/// </summary>
|
||||
public GoodsEntity Goods { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 原始数据(JSON 原文,压缩后落盘)
|
||||
/// </summary>
|
||||
public string RawData { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Store.Modules.CloudProduct.Dtos
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品导入结果
|
||||
/// </summary>
|
||||
public class GoodsImportResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 成功条数
|
||||
/// </summary>
|
||||
public int SuccessCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 失败条数
|
||||
/// </summary>
|
||||
public int FailedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总条数
|
||||
/// </summary>
|
||||
public int Total => SuccessCount + FailedCount;
|
||||
|
||||
/// <summary>
|
||||
/// 错误明细
|
||||
/// </summary>
|
||||
public List<string> Errors { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using OpenSearch.Client;
|
||||
|
||||
namespace Store.Modules.CloudProduct.Dtos
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品搜索文档:OpenSearch 索引中的商品模型,Id 为主键,Title 用于分词搜索
|
||||
/// </summary>
|
||||
public class GoodsSearchDocument
|
||||
{
|
||||
[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}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
namespace Store.Modules.CloudProduct.Dtos
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品搜索请求
|
||||
/// </summary>
|
||||
public class GoodsSearchRequest
|
||||
{
|
||||
/// <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,108 @@
|
||||
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>
|
||||
/// OpenSearch 服务及目标索引是否健康
|
||||
/// </summary>
|
||||
public bool Healthy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 集群名称
|
||||
/// </summary>
|
||||
public string? ClusterName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 集群状态(green、yellow 或 red)
|
||||
/// </summary>
|
||||
public string? ClusterStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 集群节点总数
|
||||
/// </summary>
|
||||
public int NumberOfNodes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 数据节点数量
|
||||
/// </summary>
|
||||
public int NumberOfDataNodes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活跃主分片数量
|
||||
/// </summary>
|
||||
public int ActivePrimaryShards { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活跃分片总数
|
||||
/// </summary>
|
||||
public int ActiveShards { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 正在迁移的分片数量
|
||||
/// </summary>
|
||||
public int RelocatingShards { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 正在初始化的分片数量
|
||||
/// </summary>
|
||||
public int InitializingShards { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 未分配的分片数量
|
||||
/// </summary>
|
||||
public int UnassignedShards { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 索引是否存在
|
||||
/// </summary>
|
||||
public bool IndexExists { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 索引映射是否符合当前服务要求
|
||||
/// </summary>
|
||||
public bool MappingHealthy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标题字段的索引分析器
|
||||
/// </summary>
|
||||
public string? IndexAnalyzer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标题字段的搜索分析器
|
||||
/// </summary>
|
||||
public string? SearchAnalyzer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 文档总数(索引不存在或读取失败时为 -1)
|
||||
/// </summary>
|
||||
public long DocumentCount { get; set; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// 状态检测耗时(毫秒)
|
||||
/// </summary>
|
||||
public long ElapsedMilliseconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态说明或错误信息
|
||||
/// </summary>
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using FreeSql.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Store.Modules.CloudProduct.Entities
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品表实体(对应数据库表 goods)
|
||||
/// </summary>
|
||||
[Table(Name = "goods")]
|
||||
public class GoodsEntity
|
||||
{
|
||||
/// <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,20 @@
|
||||
namespace Store.Modules.CloudProduct.Enums
|
||||
{
|
||||
/// <summary>
|
||||
/// 搜索排序方式
|
||||
/// </summary>
|
||||
public enum GoodsSearchSortBy
|
||||
{
|
||||
/// <summary>相关性(默认)</summary>
|
||||
Relevance = 0,
|
||||
|
||||
/// <summary>价格从低到高</summary>
|
||||
PriceAsc = 1,
|
||||
|
||||
/// <summary>价格从高到低</summary>
|
||||
PriceDesc = 2,
|
||||
|
||||
/// <summary>销量从高到低</summary>
|
||||
SoldDesc = 3,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Store.Modules.CloudProduct.Enums
|
||||
{
|
||||
public enum PlatformType
|
||||
{
|
||||
None = 0,
|
||||
PDD = 1,
|
||||
WX = 2,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using Store.Modules.CloudProduct.Entities;
|
||||
using Store.Modules.CloudProduct.Enums;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Store.Modules.CloudProduct.ImportConverters
|
||||
{
|
||||
/// <summary>
|
||||
/// 导入商品数据转换器
|
||||
/// </summary>
|
||||
public interface IGoodsConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// 原始数据(JSON 原文)
|
||||
/// </summary>
|
||||
string RawData { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取转换器名称
|
||||
/// </summary>
|
||||
string ConverterName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 支持的平台类型
|
||||
/// </summary>
|
||||
PlatformType Platform { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 读取内容
|
||||
/// </summary>
|
||||
/// <param name="content">JSON 原文内容</param>
|
||||
void ReadContent(string content);
|
||||
|
||||
/// <summary>
|
||||
/// 判断是否匹配当前转换器
|
||||
/// </summary>
|
||||
/// <returns>是否匹配</returns>
|
||||
bool IsMatch();
|
||||
|
||||
/// <summary>
|
||||
/// 转换为标准商品模型
|
||||
/// </summary>
|
||||
GoodsEntity Convert();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 转换器基类:统一实现读取、校验与转换骨架,子类只需实现匹配判定与字段映射。
|
||||
/// </summary>
|
||||
public abstract class GoodsConverter : IGoodsConverter
|
||||
{
|
||||
private bool _isMatch = false;
|
||||
|
||||
public string RawData { get; private set; } = null!;
|
||||
|
||||
public abstract string ConverterName { get; }
|
||||
|
||||
public abstract PlatformType Platform { get; }
|
||||
|
||||
void IGoodsConverter.ReadContent(string content)
|
||||
{
|
||||
ReadContent(content);
|
||||
}
|
||||
|
||||
protected virtual void ReadContent(string content)
|
||||
{
|
||||
RawData = content;
|
||||
}
|
||||
|
||||
public virtual bool IsMatch()
|
||||
{
|
||||
_isMatch = true;
|
||||
return IsMatchCore();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 由子类实现的具体匹配判定
|
||||
/// </summary>
|
||||
protected abstract bool IsMatchCore();
|
||||
|
||||
/// <summary>
|
||||
/// 带校验的转换(基类统一实现)
|
||||
/// </summary>
|
||||
public virtual GoodsEntity Convert()
|
||||
{
|
||||
if (_isMatch == false) throw new Exception("未校验数据");
|
||||
try
|
||||
{
|
||||
JsonNode.Parse(RawData);
|
||||
}
|
||||
catch { throw new Exception("数据解析失败,不是有效的JSON格式"); }
|
||||
|
||||
var goods = ConvertCore();
|
||||
if (goods == null)
|
||||
throw new Exception("转换失败");
|
||||
|
||||
// 基础校验
|
||||
if (goods.GoodsId == 0)
|
||||
throw new Exception("商品ID不能为空");
|
||||
|
||||
return goods;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 由子类实现的具体字段映射
|
||||
/// </summary>
|
||||
protected abstract GoodsEntity ConvertCore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Store.Modules.CloudProduct.Entities;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace Store.Modules.CloudProduct.ImportConverters
|
||||
{
|
||||
/// <summary>
|
||||
/// 导入商品转换工厂:按顺序尝试各转换器,返回标准商品模型。
|
||||
/// </summary>
|
||||
public class GoodsConverterFactory
|
||||
{
|
||||
private static readonly List<IGoodsConverter> _converters = new List<IGoodsConverter>
|
||||
{
|
||||
new PddGoodsConverter(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 根据本地 JSON 文件路径转换。
|
||||
/// </summary>
|
||||
public static GoodsEntity? Convert(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
throw new ArgumentNullException(nameof(filePath));
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
throw new FileNotFoundException("找不到导入文件", filePath);
|
||||
|
||||
var content = File.ReadAllText(filePath);
|
||||
return ConvertContent(content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据 JSON 内容字符串转换。
|
||||
/// </summary>
|
||||
public static GoodsEntity? ConvertContent(string content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
throw new ArgumentNullException(nameof(content));
|
||||
|
||||
foreach (var converter in _converters)
|
||||
{
|
||||
converter.ReadContent(content);
|
||||
if (converter.IsMatch())
|
||||
{
|
||||
var goods = converter.Convert();
|
||||
if (goods != null)
|
||||
return goods;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
using Store.Modules.CloudProduct.Entities;
|
||||
using Store.Modules.CloudProduct.Enums;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Store.Modules.CloudProduct.ImportConverters
|
||||
{
|
||||
/// <summary>
|
||||
/// PDD 前端商品详情数据转换器(数据源格式见 data.txt)
|
||||
/// </summary>
|
||||
internal class PddGoodsConverter : GoodsConverter
|
||||
{
|
||||
public override string ConverterName => nameof(PddGoodsConverter);
|
||||
|
||||
public override PlatformType Platform => PlatformType.PDD;
|
||||
|
||||
protected override bool IsMatchCore()
|
||||
{
|
||||
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 GoodsEntity ConvertCore()
|
||||
{
|
||||
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 GoodsEntity
|
||||
{
|
||||
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,17 @@
|
||||
using FreeSql;
|
||||
using MAX.Core.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Store.Modules.CloudProduct.Entities;
|
||||
|
||||
namespace Store.Modules.CloudProduct.Repositorys
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品仓储(对应 goods 表)
|
||||
/// </summary>
|
||||
public class GoodsRepository : BaseRepository<GoodsEntity>, IScopedDependency
|
||||
{
|
||||
public GoodsRepository([FromKeyedServices("CloudProductConnection")] IFreeSql fsql) : base(fsql)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Store.Modules.CloudProduct.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Hangfire 定时任务:把数据库中未同步(IndexStatus=0)的商品写入 OpenSearch 分词索引。
|
||||
/// </summary>
|
||||
public class GoodsIndexJob
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<GoodsIndexJob> _logger;
|
||||
|
||||
public GoodsIndexJob(IServiceScopeFactory scopeFactory, IConfiguration configuration,ILogger<GoodsIndexJob> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task ProcessPendingAsync()
|
||||
{
|
||||
var batchSize = _configuration.GetValue<int?>("Indexing:BatchSize") ?? 1000;
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var service = scope.ServiceProvider.GetRequiredService<GoodsService>();
|
||||
var count = await service.IndexPendingGoodsAsync(batchSize);
|
||||
_logger.LogInformation($"[Hangfire] 商品分词定时任务完成,本次同步 {count} 条");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
using MAX.Core.DependencyInjection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Store.Modules.CloudProduct.Dtos;
|
||||
using Store.Modules.CloudProduct.Entities;
|
||||
using Store.Modules.CloudProduct.Repositorys;
|
||||
|
||||
namespace Store.Modules.CloudProduct.Services
|
||||
{
|
||||
public interface IGoodsService : IScopedDependency
|
||||
{
|
||||
Task<int> DeleteGoodsAsync(IEnumerable<long> ids);
|
||||
Task<List<GoodsEntity>> GetGoodsByGoodsIdsAsync(IEnumerable<long> goodsIds);
|
||||
Task<GoodsImportResult> ImportGoodsAsync(IEnumerable<GoodsImportItem> items);
|
||||
Task<int> IndexPendingGoodsAsync(int batchSize);
|
||||
Task<long> RebuildSearchIndexAsync(IEnumerable<long>? ids = null);
|
||||
Task<(List<GoodsEntity> List, long Total)> SearchGoodsAsync(GoodsSearchRequest request);
|
||||
Task<int> UpdateThumbUrlAsync(IEnumerable<GoodsEntity> goodsList);
|
||||
}
|
||||
|
||||
internal class GoodsService : IGoodsService
|
||||
{
|
||||
private readonly GoodsRepository _repository;
|
||||
private readonly IOpenSearchService _searchService;
|
||||
private readonly IRawDataStorage _rawDataStorage;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public GoodsService(GoodsRepository repository, IOpenSearchService searchService, IRawDataStorage rawDataStorage, IConfiguration configuration)
|
||||
{
|
||||
_repository = repository;
|
||||
_searchService = searchService;
|
||||
_rawDataStorage = rawDataStorage;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public async Task<(List<GoodsEntity> List, long Total)> SearchGoodsAsync(GoodsSearchRequest request)
|
||||
{
|
||||
// 1. 从搜索引擎获取标题匹配的商品
|
||||
var searchResult = await _searchService.SearchByTitleAsync(request);
|
||||
|
||||
var goodsList = searchResult.Documents.Select(a => new GoodsEntity
|
||||
{
|
||||
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();
|
||||
|
||||
return (goodsList, searchResult.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 documents = new List<GoodsSearchDocument>();
|
||||
var indexedIds = new List<long>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var goods = item?.Goods;
|
||||
if (goods == null)
|
||||
{
|
||||
result.FailedCount++;
|
||||
result.Errors.Add("商品数据为空");
|
||||
continue;
|
||||
}
|
||||
|
||||
var goodsId = goods.GoodsId;
|
||||
if (goodsId == 0)
|
||||
{
|
||||
result.FailedCount++;
|
||||
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)
|
||||
{
|
||||
documents.Add(new GoodsSearchDocument
|
||||
{
|
||||
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.SuccessCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.FailedCount++;
|
||||
result.Errors.Add($"GoodsId={goodsId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (documents.Count > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _searchService.BulkIndexGoodsAsync(documents);
|
||||
await MarkIndexedAsync(indexedIds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 写入失败时保持“未同步”,由后台定时任务重试
|
||||
result.FailedCount += documents.Count;
|
||||
result.SuccessCount -= documents.Count;
|
||||
result.Errors.Add("写入 OpenSearch 失败: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 后台任务:批量处理未同步(IsSegmented=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 documents = pending
|
||||
.Select(x => new GoodsSearchDocument
|
||||
{
|
||||
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 _searchService.BulkIndexGoodsAsync(documents);
|
||||
|
||||
await MarkIndexedAsync(pending.Select(x => x.GoodsId));
|
||||
return pending.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按平台 + 商品编号查找已存在记录,存在则更新,不存在则新增。
|
||||
/// </summary>
|
||||
private async Task<GoodsEntity> UpsertGoodsAsync(GoodsEntity 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>
|
||||
/// 根据商品编号(GoodsId)列表读取商品。
|
||||
/// </summary>
|
||||
public async Task<List<GoodsEntity>> GetGoodsByGoodsIdsAsync(IEnumerable<long> goodsIds)
|
||||
{
|
||||
var idList = goodsIds?.ToList();
|
||||
if (idList == null || idList.Count == 0)
|
||||
return new List<GoodsEntity>();
|
||||
|
||||
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<GoodsEntity> 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 _searchService.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<GoodsEntity>()
|
||||
.Set(x => x.Status, (byte)1)
|
||||
.Where(x => idList.Contains(x.GoodsId))
|
||||
.ExecuteAffrowsAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
// 硬删除:直接删除数据库记录
|
||||
await _repository.Orm.Delete<GoodsEntity>()
|
||||
.Where(x => idList.Contains(x.GoodsId))
|
||||
.ExecuteAffrowsAsync();
|
||||
}
|
||||
|
||||
// 无论软删除还是硬删除,都要删除 OpenSearch 中的对应文档
|
||||
await _searchService.DeleteDocumentsAsync(idList);
|
||||
return idList.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量更新商品缩略图。
|
||||
/// </summary>
|
||||
public async Task<int> UpdateThumbUrlAsync(IEnumerable<GoodsEntity> goodsList)
|
||||
{
|
||||
//string sql = _repository.Orm.Update<GoodsEntity>().SetSource(goodsList, a => a.GoodsId).UpdateColumns(a => new { a.ThumbUrl }).ToSql();
|
||||
return await _repository.Orm.Update<GoodsEntity>()
|
||||
.SetSource(goodsList, 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<GoodsEntity>()
|
||||
.Set(x => x.IsSegmented, (byte)1)
|
||||
.Where(x => idList.Contains(x.GoodsId))
|
||||
.ExecuteAffrowsAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
using System.Diagnostics;
|
||||
using MAX.Core.DependencyInjection;
|
||||
using MAX.Framework.Commons;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenSearch.Client;
|
||||
using OpenSearch.Net;
|
||||
using Store.Modules.CloudProduct.Dtos;
|
||||
using Store.Modules.CloudProduct.Entities;
|
||||
using Store.Modules.CloudProduct.Enums;
|
||||
|
||||
namespace Store.Modules.CloudProduct.Services
|
||||
{
|
||||
public interface IOpenSearchService : ISingletonDependency
|
||||
{
|
||||
Task AddDocumentAsync(GoodsSearchDocument document);
|
||||
Task BulkIndexGoodsAsync(IEnumerable<GoodsSearchDocument> documents);
|
||||
Task<bool> ClearIndexAsync();
|
||||
Task<long> CountDocumentsAsync();
|
||||
Task<bool> CreateIndexAsync();
|
||||
Task DeleteDocumentsAsync(IEnumerable<long> ids);
|
||||
Task DeleteIndexAsync();
|
||||
Task<OpenSearchStatusDto> GetStatusAsync();
|
||||
Task<bool> IndexExistsAsync();
|
||||
Task<bool> IsIndexMappingHealthyAsync();
|
||||
Task RefreshIndexAsync();
|
||||
Task<long> RebuildIndexAsync(IEnumerable<GoodsEntity> goodsList);
|
||||
Task<(long Total, List<GoodsSearchDocument> Documents)> SearchByTitleAsync(GoodsSearchRequest request, GoodsSearchSortBy sortBy = GoodsSearchSortBy.Relevance);
|
||||
}
|
||||
|
||||
internal class OpenSearchService : IOpenSearchService
|
||||
{
|
||||
private readonly string _nodeUrl;
|
||||
private readonly string _indexName;
|
||||
private readonly IOpenSearchClient _client = null!;
|
||||
|
||||
// 查询类操作(search/count/status/ping)统一超时
|
||||
private static readonly TimeSpan QueryTimeout = TimeSpan.FromSeconds(5);
|
||||
// 批量写入/建索引/删除索引等管理类操作超时(数据量大时需要更长,可挪到配置文件)
|
||||
private static readonly TimeSpan AdminTimeout = TimeSpan.FromSeconds(30);
|
||||
private readonly ILogger<OpenSearchService> _logger;
|
||||
|
||||
public OpenSearchService(IConfiguration configuration,ILogger<OpenSearchService> logger)
|
||||
{
|
||||
var config = configuration.GetSection("Search");
|
||||
_nodeUrl = config.GetValue<string>("Url") ?? "";
|
||||
_indexName = config.GetValue<string>("IndexName") ?? "";
|
||||
_client = CreateClient();
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private IOpenSearchClient CreateClient()
|
||||
{
|
||||
var pool = new SingleNodeConnectionPool(new Uri(_nodeUrl));
|
||||
var settings = new ConnectionSettings(pool)
|
||||
.DefaultIndex(_indexName)
|
||||
.DefaultMappingFor<GoodsSearchDocument>(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);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> IsIndexMappingHealthyAsync()
|
||||
{
|
||||
var mappingStatus = await GetIndexMappingStatusAsync();
|
||||
return mappingStatus.Healthy;
|
||||
}
|
||||
|
||||
private async Task<(bool Healthy, string? IndexAnalyzer, string? SearchAnalyzer)> GetIndexMappingStatusAsync()
|
||||
{
|
||||
var getResponse = await _client.Indices.GetAsync(_indexName, d => d
|
||||
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
|
||||
if (!getResponse.IsValid)
|
||||
{
|
||||
throw new AppException("读取索引映射失败: " + getResponse.DebugInformation);
|
||||
}
|
||||
if (!getResponse.Indices.TryGetValue(_indexName, out var indexState))
|
||||
{
|
||||
return (false, null, null);
|
||||
}
|
||||
|
||||
var mapping = indexState.Mappings;
|
||||
if (mapping?.Properties == null
|
||||
|| !mapping.Properties.TryGetValue(new PropertyName("title"), out var property)
|
||||
|| property is not ITextProperty textProperty)
|
||||
{
|
||||
return (false, null, null);
|
||||
}
|
||||
|
||||
var indexAnalyzer = textProperty.Analyzer;
|
||||
var searchAnalyzer = textProperty.SearchAnalyzer;
|
||||
var usesIk = indexAnalyzer == "ik_max_word" && searchAnalyzer == "ik_smart";
|
||||
var usesStandard = indexAnalyzer == "standard" && searchAnalyzer == "standard";
|
||||
return (usesIk || usesStandard, indexAnalyzer, searchAnalyzer);
|
||||
}
|
||||
|
||||
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<GoodsSearchDocument>(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);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> IndexExistsAsync()
|
||||
{
|
||||
var response = await _client.Indices.ExistsAsync(_indexName, d => d
|
||||
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
|
||||
return response.Exists;
|
||||
}
|
||||
|
||||
public async Task RefreshIndexAsync()
|
||||
{
|
||||
var response = await _client.Indices.RefreshAsync(_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<GoodsSearchDocument> documents)
|
||||
{
|
||||
if (documents == null) throw new ArgumentNullException(nameof(documents));
|
||||
|
||||
var list = documents.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 RefreshIndexAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ============ 3.2 根据数据库商品重建搜索索引 ============
|
||||
/// </summary>
|
||||
public async Task<long> RebuildIndexAsync(IEnumerable<GoodsEntity> goodsList)
|
||||
{
|
||||
if (goodsList == null) throw new ArgumentNullException(nameof(goodsList));
|
||||
|
||||
var documents = goodsList
|
||||
.Where(x => x != null && x.GoodsId > 0)
|
||||
.Select(x => new GoodsSearchDocument
|
||||
{
|
||||
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 (documents.Count == 0) return 0;
|
||||
|
||||
await BulkIndexGoodsAsync(documents);
|
||||
return documents.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<GoodsSearchDocument>(d => d.Index(_indexName).Id(id));
|
||||
}
|
||||
b.RequestConfiguration(r => r.RequestTimeout(AdminTimeout));
|
||||
return b;
|
||||
});
|
||||
|
||||
if (!response.IsValid)
|
||||
{
|
||||
throw new AppException("删除 OpenSearch 文档失败: " + response.DebugInformation);
|
||||
}
|
||||
|
||||
await RefreshIndexAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ============ 4. 手动新增一条搜索文档 ============
|
||||
/// </summary>
|
||||
public async Task AddDocumentAsync(GoodsSearchDocument document)
|
||||
{
|
||||
if (document == null) throw new ArgumentNullException(nameof(document));
|
||||
|
||||
var response = await _client.IndexAsync(document, i => i
|
||||
.Index(_indexName)
|
||||
.Id(document.Id)
|
||||
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout))
|
||||
);
|
||||
|
||||
if (response.IsValid)
|
||||
{
|
||||
await RefreshIndexAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new AppException("新增失败: " + response.DebugInformation);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ============ 5. 分词搜索(分页 + 类目/价格/销量/平台过滤 + 排序) ============
|
||||
/// </summary>
|
||||
/// <param name="request">搜索条件(关键词、类目、价格、销量、平台、分页)</param>
|
||||
/// <param name="sortBy">排序方式</param>
|
||||
public async Task<(long Total, List<GoodsSearchDocument> Documents)> SearchByTitleAsync(GoodsSearchRequest request,
|
||||
GoodsSearchSortBy sortBy = GoodsSearchSortBy.Relevance)
|
||||
{
|
||||
var 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? minSoldQuantity = request.MinSoldQuantity;
|
||||
int? maxSoldQuantity = request.MaxSoldQuantity;
|
||||
int? platform = request.Platform;
|
||||
int pageIndex = request.PageIndex;
|
||||
int pageSize = request.PageSize;
|
||||
|
||||
if (pageIndex < 1) pageIndex = 1;
|
||||
if (pageSize < 1) pageSize = 20;
|
||||
if (pageSize > 2000) pageSize = 2000; // 避免深分页/单页过大拖垮性能,按需调整上限
|
||||
|
||||
var from = (pageIndex - 1) * pageSize;
|
||||
|
||||
ISearchResponse<GoodsSearchDocument> searchResponse;
|
||||
try
|
||||
{
|
||||
searchResponse = await _client.SearchAsync<GoodsSearchDocument>(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<GoodsSearchDocument>, 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 (minSoldQuantity.HasValue || maxSoldQuantity.HasValue)
|
||||
{
|
||||
filters.Add(f => f.Range(r =>
|
||||
{
|
||||
var range = r.Field(x => x.SoldQuantity);
|
||||
if (minSoldQuantity.HasValue) range = range.GreaterThanOrEquals(minSoldQuantity.Value);
|
||||
if (maxSoldQuantity.HasValue) range = range.LessThanOrEquals(maxSoldQuantity.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);
|
||||
}
|
||||
|
||||
return (searchResponse.Total, searchResponse.Documents.ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ============ 6. 统计文档数 ============
|
||||
/// </summary>
|
||||
public async Task<long> CountDocumentsAsync()
|
||||
{
|
||||
var countResponse = await _client.CountAsync<GoodsSearchDocument>(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 stopwatch = Stopwatch.StartNew();
|
||||
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;
|
||||
_logger.LogError(ping.DebugInformation);
|
||||
return status;
|
||||
}
|
||||
_logger.LogInformation("OpenSerach 已连接");
|
||||
var clusterHealth = await _client.Cluster.HealthAsync(Indices.All, h => h
|
||||
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
|
||||
if (!clusterHealth.IsValid)
|
||||
{
|
||||
status.Message = clusterHealth.DebugInformation;
|
||||
return status;
|
||||
}
|
||||
|
||||
status.ClusterName = clusterHealth.ClusterName;
|
||||
status.ClusterStatus = clusterHealth.Status.ToString().ToLowerInvariant();
|
||||
status.NumberOfNodes = clusterHealth.NumberOfNodes;
|
||||
status.NumberOfDataNodes = clusterHealth.NumberOfDataNodes;
|
||||
status.ActivePrimaryShards = clusterHealth.ActivePrimaryShards;
|
||||
status.ActiveShards = clusterHealth.ActiveShards;
|
||||
status.RelocatingShards = clusterHealth.RelocatingShards;
|
||||
status.InitializingShards = clusterHealth.InitializingShards;
|
||||
status.UnassignedShards = clusterHealth.UnassignedShards;
|
||||
status.IndexExists = await IndexExistsAsync();
|
||||
|
||||
if (status.IndexExists)
|
||||
{
|
||||
var mappingStatus = await GetIndexMappingStatusAsync();
|
||||
status.MappingHealthy = mappingStatus.Healthy;
|
||||
status.IndexAnalyzer = mappingStatus.IndexAnalyzer;
|
||||
status.SearchAnalyzer = mappingStatus.SearchAnalyzer;
|
||||
|
||||
var count = await _client.CountAsync<GoodsSearchDocument>(c => c
|
||||
.Index(_indexName)
|
||||
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
|
||||
status.DocumentCount = count.IsValid ? count.Count : -1;
|
||||
if (!count.IsValid)
|
||||
{
|
||||
status.Message = count.DebugInformation;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
status.Message = $"索引 [{_indexName}] 不存在";
|
||||
}
|
||||
|
||||
status.Healthy = status.IndexExists
|
||||
&& status.MappingHealthy
|
||||
&& status.ClusterStatus != "red"
|
||||
&& status.DocumentCount >= 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
status.Healthy = false;
|
||||
status.Message = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopwatch.Stop();
|
||||
status.ElapsedMilliseconds = stopwatch.ElapsedMilliseconds;
|
||||
}
|
||||
|
||||
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,82 @@
|
||||
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 interface IRawDataStorage : IScopedDependency
|
||||
{
|
||||
/// <summary>
|
||||
/// 保存原始数据,返回保存后的完整路径。
|
||||
/// </summary>
|
||||
Task<string> SaveAsync(long goodsId, string rawData);
|
||||
|
||||
/// <summary>
|
||||
/// 读取并解压原始数据;文件不存在时返回 null。
|
||||
/// </summary>
|
||||
Task<string?> ReadAsync(long goodsId);
|
||||
|
||||
/// <summary>
|
||||
/// 判断指定商品的原始数据文件是否存在。
|
||||
/// </summary>
|
||||
bool Exists(long goodsId);
|
||||
}
|
||||
|
||||
internal class RawDataStorage : IRawDataStorage
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using MAX.Core.DependencyInjection;
|
||||
using MAX.Core.Interfaces;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Store.Modules.CloudProduct.Services;
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace Store.Modules.CloudProduct
|
||||
{
|
||||
|
||||
public class Startup : IStartup
|
||||
{
|
||||
public int Priority => IntPriority.Normal;
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
}
|
||||
|
||||
public void Register(IServiceRegistrar registrar)
|
||||
{
|
||||
|
||||
|
||||
|
||||
Func<IServiceProvider, IFreeSql> KJDFsqlFactory = r =>
|
||||
{
|
||||
var config = r.GetRequiredService<IConfiguration>();
|
||||
var connection = config.GetValue<string>("CloudProductConnection");
|
||||
IFreeSql fsql = new FreeSql.FreeSqlBuilder()
|
||||
.UseConnectionString(FreeSql.DataType.MySql, connection)
|
||||
.UseAdoConnectionPool(true)
|
||||
.Build();
|
||||
return fsql;
|
||||
};
|
||||
registrar.RegisterSingleton<IFreeSql>("CloudProductConnection", (s, k) =>
|
||||
{
|
||||
return KJDFsqlFactory(s);
|
||||
});
|
||||
}
|
||||
|
||||
public async void Start(IServiceProvider serviceProvider)
|
||||
{
|
||||
var openSearchService = serviceProvider.GetRequiredService<OpenSearchService>();
|
||||
await openSearchService.GetStatusAsync();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Store.Framework\Store.Framework.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- 本模块自己的配置文件:源码里叫 appsettings.json,构建后复制到宿主输出目录时改名为
|
||||
appsettings.CloudProduct.json,避免和宿主自己的 appsettings.json 互相覆盖。
|
||||
由 builder.AddStoreHub() 自动并入宿主配置树(在 appsettings*.json 之后、环境变量之前)。 -->
|
||||
<None Update="appsettings.json" Link="appsettings.CloudProduct.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.25" />
|
||||
<PackageReference Include="Hangfire.Dashboard.Basic.Authentication" Version="8.0.0" />
|
||||
<PackageReference Include="Hangfire.MySqlStorage" Version="2.0.3" />
|
||||
<PackageReference Include="OpenSearch.Client" Version="2.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"CloudProductConnection": "Server=192.168.0.200;Port=3306;Database=goods_center;Uid=root;Pwd=123456;Allow User Variables=true",
|
||||
"Search": {
|
||||
"Url": "http://192.168.0.200:9200",
|
||||
"IndexName": "goods_search"
|
||||
},
|
||||
"RawData": {
|
||||
"Path": "E:\\GoodsRawData"
|
||||
},
|
||||
"Indexing": {
|
||||
"Mode": "Background",
|
||||
"Cron": "*/5 * * * *",
|
||||
"BatchSize": 20000
|
||||
},
|
||||
"Delete": {
|
||||
"SoftDelete": true
|
||||
},
|
||||
"Hangfire": {
|
||||
"Dashboard" :{
|
||||
"User": "admin",
|
||||
"Pass": "1234560"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace Store.Shops.Contracts
|
||||
{
|
||||
public interface IShopQueryService
|
||||
{
|
||||
DateTime GetExpireTime(string shopId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,144 @@
|
||||
using FreeSql.DataAnnotations;
|
||||
using System;
|
||||
|
||||
namespace YourNamespace
|
||||
{
|
||||
/// <summary>
|
||||
/// 店铺表
|
||||
/// </summary>
|
||||
[Table(Name = "wxxd.shop")]
|
||||
public class ShopEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 表id
|
||||
/// </summary>
|
||||
[Column(Name = "id", IsPrimary = true, IsIdentity = true)]
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// login_name
|
||||
/// </summary>
|
||||
[Column(Name = "login_name")]
|
||||
public string LoginName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// login_pass
|
||||
/// </summary>
|
||||
[Column(Name = "login_pass")]
|
||||
public string LoginPass { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// cookie
|
||||
/// </summary>
|
||||
[Column(Name = "cookie")]
|
||||
public string Cookie { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// cookie2
|
||||
/// </summary>
|
||||
[Column(Name = "cookie2")]
|
||||
public string Cookie2 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// anti_contents
|
||||
/// </summary>
|
||||
[Column(Name = "anti_contents")]
|
||||
public string AntiContents { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// openid
|
||||
/// </summary>
|
||||
[Column(Name = "openid")]
|
||||
public string Openid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// shop_id
|
||||
/// </summary>
|
||||
[Column(Name = "shop_id")]
|
||||
public string ShopId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// shop_account
|
||||
/// </summary>
|
||||
[Column(Name = "shop_account")]
|
||||
public string ShopAccount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// sub_user_name
|
||||
/// </summary>
|
||||
[Column(Name = "sub_user_name")]
|
||||
public string SubUserName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// user_name
|
||||
/// </summary>
|
||||
[Column(Name = "user_name")]
|
||||
public string UserName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 店铺分组名称(非子账号分组)
|
||||
/// </summary>
|
||||
[Column(Name = "group_name")]
|
||||
public string GroupName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// add_time
|
||||
/// </summary>
|
||||
[Column(Name = "add_time")]
|
||||
public DateTime? AddTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// mdy_time
|
||||
/// </summary>
|
||||
[Column(Name = "mdy_time")]
|
||||
public DateTime? MdyTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 店铺所属公司
|
||||
/// </summary>
|
||||
[Column(Name = "company_name")]
|
||||
public string CompanyName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注
|
||||
/// </summary>
|
||||
[Column(Name = "notes")]
|
||||
public string Notes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// login_status
|
||||
/// </summary>
|
||||
[Column(Name = "login_status")]
|
||||
public string LoginStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 店铺每月商品上传限额剩余
|
||||
/// </summary>
|
||||
[Column(Name = "add_product_remaining")]
|
||||
public int AddProductRemaining { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 上传限额所处月份
|
||||
/// </summary>
|
||||
[Column(Name = "add_product_month")]
|
||||
public byte AddProductMonth { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分配的二级超管的user_name
|
||||
/// </summary>
|
||||
[Column(Name = "operate_sub_sa_name")]
|
||||
public string OperateSubSaName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分配二级超管的时间
|
||||
/// </summary>
|
||||
[Column(Name = "add_sub_sa_time")]
|
||||
public DateTime? AddSubSaTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// login_status2
|
||||
/// </summary>
|
||||
[Column(Name = "login_status2")]
|
||||
public string LoginStatus2 { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using FreeSql.DataAnnotations;
|
||||
using System;
|
||||
|
||||
namespace Store.Users.Entities
|
||||
{
|
||||
/// <summary>
|
||||
/// 商店过期
|
||||
/// </summary>
|
||||
[Table(Name = "wxxd.user_points_shop_expire")]
|
||||
public class ShopExpireEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// ID
|
||||
/// </summary>
|
||||
[Column(Name = "id", IsPrimary = true, IsIdentity = true)]
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商店ID
|
||||
/// </summary>
|
||||
[Column(Name = "shop_id")]
|
||||
public string ShopId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期时间
|
||||
/// </summary>
|
||||
// 数据库列名末尾包含 U+200C,不能改成普通 expire_time。
|
||||
[Column(Name = "expire_time")]
|
||||
public int ExpireTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 添加时间
|
||||
/// </summary>
|
||||
[Column(Name = "add_time")]
|
||||
public DateTime AddTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 修改时间
|
||||
/// </summary>
|
||||
[Column(Name = "mdy_time")]
|
||||
public DateTime MdyTime { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using FreeSql;
|
||||
using MAX.Core.DependencyInjection;
|
||||
using MAX.Core.Helpers;
|
||||
using Store.Users.Entities;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using YourNamespace;
|
||||
|
||||
namespace Store.Shops.Repositories
|
||||
{
|
||||
public interface IShopRepository : IBaseRepository<ShopEntity>
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取用户商铺过期时间
|
||||
/// </summary>
|
||||
/// <param name="ShopId"></param>
|
||||
/// <returns></returns>
|
||||
DateTime GetExpireTime(string ShopId);
|
||||
}
|
||||
internal class ShopRepository : BaseRepository<ShopEntity>, IShopRepository, ITransientDependency
|
||||
{
|
||||
private readonly IFreeSql _freeSql;
|
||||
|
||||
public ShopRepository(IFreeSql freeSql) : base(freeSql)
|
||||
{
|
||||
_freeSql = freeSql;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取用户商铺过期时间
|
||||
/// </summary>
|
||||
/// <param name="ShopId"></param>
|
||||
public DateTime GetExpireTime(string ShopId)
|
||||
{
|
||||
ShopExpireEntity currentExpire = _freeSql.Select<ShopExpireEntity>()
|
||||
.Where(a => a.ShopId == ShopId)
|
||||
.ForUpdate()
|
||||
.First();
|
||||
if (currentExpire == null)
|
||||
{
|
||||
return DateTime.MinValue;
|
||||
}
|
||||
return DateHelper.ConvertFromUnixTimestampInSeconds(currentExpire.ExpireTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using MAX.Core.DependencyInjection;
|
||||
using Store.Shops.Contracts;
|
||||
using Store.Shops.Repositories;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace Store.Shops.Services
|
||||
{
|
||||
internal class ShopQueryService: IShopQueryService, ITransientDependency
|
||||
{
|
||||
private readonly IShopRepository _shopRepository;
|
||||
|
||||
public ShopQueryService(IShopRepository shopRepository)
|
||||
{
|
||||
_shopRepository = shopRepository;
|
||||
}
|
||||
public DateTime GetExpireTime(string shopId)
|
||||
{
|
||||
return _shopRepository.GetExpireTime(shopId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using MAX.Core.DependencyInjection;
|
||||
using MAX.Core.Interfaces;
|
||||
|
||||
namespace Store.Shops
|
||||
{
|
||||
public class Startup : IStartup
|
||||
{
|
||||
public int Priority => IntPriority.Normal;
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public void Register(IServiceRegistrar registrar)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public void Start(IServiceProvider serviceProvider)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Store.Framework\Store.Framework.csproj" />
|
||||
<ProjectReference Include="..\Store.Shops.Contracts\Store.Shops.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,147 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text;
|
||||
|
||||
namespace Store.Users.Contracts.Dtos
|
||||
{
|
||||
public class UserInfo
|
||||
{/// <summary>
|
||||
/// ID
|
||||
/// </summary>
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户昵称
|
||||
/// </summary>
|
||||
public string NickName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户名称(登录账户)
|
||||
/// </summary>
|
||||
public string UserName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 登录密码
|
||||
/// </summary>
|
||||
public string Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// card_id
|
||||
/// </summary>
|
||||
public string CardId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户分组 3主号 4客服 5加盟 8登录 9采购 10加盟 11上货
|
||||
/// </summary>
|
||||
public byte GroupId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 父账户用户名
|
||||
/// </summary>
|
||||
public string ParentUserName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// oem编号,仅oem用户有值
|
||||
/// </summary>
|
||||
public string OemId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 手机号
|
||||
/// </summary>
|
||||
public string Mobile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 添加时间
|
||||
/// </summary>
|
||||
public DateTime? AddTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 修改时间
|
||||
/// </summary>
|
||||
public DateTime? MdyTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 公司名称
|
||||
/// </summary>
|
||||
public string CompanyName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注
|
||||
/// </summary>
|
||||
public string Notes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 超管等级 0 普通用户 1 超级管理员
|
||||
/// </summary>
|
||||
public short? SaLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 账户权限
|
||||
/// </summary>
|
||||
public string Authority { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 子账号被禁止展示的功能页面
|
||||
/// </summary>
|
||||
public string ForbiddenPage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 账户积分,1积分=1元
|
||||
/// </summary>
|
||||
public decimal Points { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 积分历史累计充值金额
|
||||
/// </summary>
|
||||
public decimal? RechargePointsTotal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 店铺每续费一个月需要的积分,单位元
|
||||
/// </summary>
|
||||
public int ShopPointsPerMonth { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 可用余额
|
||||
/// </summary>
|
||||
public decimal? Balance { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计充值金额
|
||||
/// </summary>
|
||||
public decimal? RechargeAmountTotal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每单中转仓快递费用,单位元,两位小数
|
||||
/// </summary>
|
||||
public decimal? PostagePerOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每单中转仓退货快递费用,单位元,两位小数
|
||||
/// </summary>
|
||||
public decimal? RefundPostagePerOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 礼物每单额外收取费用,单位元,两位小数
|
||||
/// </summary>
|
||||
public decimal? GiftOemAmountPerOrder { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 同步物流费用,单位元,两位小数
|
||||
/// </summary>
|
||||
public decimal? SyncLogisticsPerOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否主账户
|
||||
/// </summary>
|
||||
public bool IsMainUser
|
||||
{
|
||||
get => string.IsNullOrWhiteSpace(ParentUserName);
|
||||
}
|
||||
/// <summary>
|
||||
/// 是否OEM租户
|
||||
/// </summary>
|
||||
public bool IsOEMTenant => !string.IsNullOrWhiteSpace(OemId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Store.Users.Contracts.Enums;
|
||||
/// <summary>
|
||||
/// 用户付款类型
|
||||
/// </summary>
|
||||
public enum UserPaymentTypes
|
||||
{
|
||||
充值 = 1,
|
||||
//采购花费 = 2,
|
||||
//产品退款到账 = 3,
|
||||
//运费退款到账 = 4,
|
||||
//采购收款 = 5,
|
||||
//产品退款扣除 = 6,
|
||||
//运费退款扣除 = 7,
|
||||
礼物单支出 = 16,
|
||||
中转仓扣费支出 = 20,
|
||||
中转仓作废返还收入 = 23,
|
||||
中转仓超重支出 = 24,
|
||||
退货包裹扣费支出 = 26,
|
||||
中转仓快递超重费用 = 28,
|
||||
支付宝在线充值 = 30,
|
||||
云仓退货支出 = 31,
|
||||
礼物单退费 = 34,
|
||||
同步单号 = 55,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Store.Users.Contracts.Dtos;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace Store.Users.Contracts
|
||||
{
|
||||
public interface IUserQueryService
|
||||
{
|
||||
Task<UserInfo> GetUser(string userName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,81 @@
|
||||
//using MediatR;
|
||||
//using Store.Framework.Models;
|
||||
//using Store.Shops.Contracts;
|
||||
//using Store.Users.Contracts.Dtos;
|
||||
//using Store.Users.Entities;
|
||||
//using Store.Users.Repositories;
|
||||
//using System;
|
||||
//using System.Collections.Generic;
|
||||
//using System.Text;
|
||||
|
||||
//namespace Store.Users.Commands
|
||||
//{
|
||||
// /// <summary>
|
||||
// /// 商铺续费
|
||||
// /// </summary>
|
||||
// /// <param name="UserName"></param>
|
||||
// /// <param name="ShopId"></param>
|
||||
// /// <param name="RenewDuration"></param>
|
||||
// internal record class ShopRenewCommand(string UserName, string ShopId, int RenewDuration) : IRequest<ShopRenewResponse>;
|
||||
// internal class ShopRenewCommandHandler : IRequestHandler<ShopRenewCommand, ShopRenewResponse>
|
||||
// {
|
||||
// private readonly IUserRepository _userRepository;
|
||||
// private readonly IShopQueryService _shopQueryService;
|
||||
|
||||
// public ShopRenewCommandHandler(IUserRepository userRepository, IShopQueryService shopQueryService)
|
||||
// {
|
||||
// _userRepository = userRepository;
|
||||
// _shopQueryService = shopQueryService;
|
||||
// }
|
||||
// async Task<ShopRenewResponse> IRequestHandler<ShopRenewCommand, ShopRenewResponse>.Handle(ShopRenewCommand request, CancellationToken cancellationToken)
|
||||
// {
|
||||
// ShopRenewResponse response = new ShopRenewResponse();
|
||||
// var user = await _userRepository.GetUser(request.UserName);
|
||||
// int shopPointsPerMonth = Math.Max(30, user.ShopPointsPerMonth);
|
||||
// int deltaPoints = shopPointsPerMonth * request.RenewDuration;
|
||||
// int newPoints = (int)user.Points - deltaPoints;
|
||||
// DateTime expireTime = _shopQueryService.GetExpireTime(request.ShopId);
|
||||
|
||||
|
||||
// if (currentExpire != null && currentExpire.ExpireTime > 0)
|
||||
// {
|
||||
// DateTime currentExpireTime = TimeUtils.StampToDateTime(currentExpire.ExpireTime);
|
||||
// if (currentExpireTime > expireBaseTime)
|
||||
// {
|
||||
// expireBaseTime = currentExpireTime;
|
||||
// }
|
||||
// }
|
||||
|
||||
// int newExpireTime = CalculateNewExpireTime(expireBaseTime, request.RenewDuration);
|
||||
|
||||
|
||||
|
||||
// response.ShopId = request.ShopId;
|
||||
// response.UserPoints = newPoints;
|
||||
// response.ExpireTime = newExpireTime;
|
||||
// return response;
|
||||
// }
|
||||
// private int CalculateNewExpireTime(DateTime expireBaseTime, int renewDuration)
|
||||
// {
|
||||
// DateTime newExpireDateTime;
|
||||
// try
|
||||
// {
|
||||
// newExpireDateTime = expireBaseTime.AddMonths(renewDuration);
|
||||
// }
|
||||
// catch (ArgumentOutOfRangeException)
|
||||
// {
|
||||
// throw new InvalidOperationException("续费时长超出允许范围");
|
||||
// }
|
||||
|
||||
// if (newExpireDateTime > TimeUtils.StampToDateTime(int.MaxValue))
|
||||
// {
|
||||
// throw new InvalidOperationException("续费时间超出系统支持范围");
|
||||
// }
|
||||
|
||||
// return TimeUtils.DateTimeToTimestampSeconds(newExpireDateTime);
|
||||
// }
|
||||
|
||||
// }
|
||||
|
||||
|
||||
//}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Store.Users.Contracts;
|
||||
using Store.Users.Contracts.Dtos;
|
||||
using Store.Users.Services;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace Store.Users.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[ApiVersionNeutral]
|
||||
[Route("user/[action]")]
|
||||
public class UserController : ControllerBase
|
||||
{
|
||||
private readonly IUserQueryService _userQuery;
|
||||
|
||||
public UserController(IUserQueryService userQuery)
|
||||
{
|
||||
_userQuery = userQuery;
|
||||
}
|
||||
[HttpGet]
|
||||
public async Task<UserInfo> GetUser(string userName)
|
||||
{
|
||||
return await _userQuery.GetUser(userName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace Store.Users.Dtos
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户续费商铺
|
||||
/// </summary>
|
||||
public class ShopRenewRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 店铺ID
|
||||
/// </summary>
|
||||
public string ShopId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 续费时长数值,例如 3;范围 1-120 个月
|
||||
/// </summary>
|
||||
public int RenewDuration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注
|
||||
/// </summary>
|
||||
public string Remark { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace Store.Users.Contracts.Dtos
|
||||
{
|
||||
public class ShopRenewResponse
|
||||
{
|
||||
public string ShopId { get; set; }
|
||||
public int ExpireTime { get; set; }
|
||||
public int UserPoints { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using FreeSql.DataAnnotations;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Web;
|
||||
|
||||
namespace Store.Users.Entities;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// OEM贴牌表
|
||||
/// </summary>
|
||||
[Table(Name = "wxxd.oem")]
|
||||
public class OEMEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// id
|
||||
/// </summary>
|
||||
[Column(Name = "id", IsPrimary = true, IsIdentity = true)]
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// oem_id
|
||||
/// </summary>
|
||||
[Column(Name = "oem_id")]
|
||||
public string OemId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 贴牌名称
|
||||
/// </summary>
|
||||
[Column(Name = "oem_name")]
|
||||
public string OemName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 贴牌管理人主账户
|
||||
/// </summary>
|
||||
[Column(Name = "oem_owner")]
|
||||
public string OemOwner { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 图标
|
||||
/// </summary>
|
||||
[Column(Name = "oem_icon")]
|
||||
public string OemIcon { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题图
|
||||
/// </summary>
|
||||
[Column(Name = "oem_img")]
|
||||
public string OemImg { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// add_time
|
||||
/// </summary>
|
||||
[Column(Name = "add_time")]
|
||||
public DateTime AddTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// mdy_time
|
||||
/// </summary>
|
||||
[Column(Name = "mdy_time")]
|
||||
public DateTime MdyTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// is_del
|
||||
/// </summary>
|
||||
[Column(Name = "is_del")]
|
||||
public byte IsDel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 可提现余额,单位分
|
||||
/// </summary>
|
||||
[Column(Name = "available_balance")]
|
||||
public long AvailableBalance { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 冻结余额,单位分
|
||||
/// </summary>
|
||||
[Column(Name = "frozen_balance")]
|
||||
public long FrozenBalance { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计返利金额,单位分
|
||||
/// </summary>
|
||||
[Column(Name = "total_rebate_amount")]
|
||||
public long TotalRebateAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计提现金额,单位分
|
||||
/// </summary>
|
||||
[Column(Name = "total_withdraw_amount")]
|
||||
public long TotalWithdrawAmount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using FreeSql.DataAnnotations;
|
||||
using System;
|
||||
|
||||
namespace Store.Users.Entities
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户表
|
||||
/// </summary>
|
||||
[Table(Name = "wxxd.user")]
|
||||
public class UserEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// ID
|
||||
/// </summary>
|
||||
[Column(Name = "id", IsPrimary = true, IsIdentity = true)]
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户昵称
|
||||
/// </summary>
|
||||
[Column(Name = "nick_name")]
|
||||
public string NickName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户名称(登录账户)
|
||||
/// </summary>
|
||||
[Column(Name = "user_name")]
|
||||
public string UserName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 登录密码
|
||||
/// </summary>
|
||||
[Column(Name = "password")]
|
||||
public string Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// card_id
|
||||
/// </summary>
|
||||
[Column(Name = "card_id")]
|
||||
public string CardId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户分组 3主号 4客服 5加盟 8登录 9采购 10加盟 11上货
|
||||
/// </summary>
|
||||
[Column(Name = "group_id")]
|
||||
public byte GroupId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 父账户用户名
|
||||
/// </summary>
|
||||
[Column(Name = "parent_user_name")]
|
||||
public string ParentUserName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// oem编号,仅oem用户有值
|
||||
/// </summary>
|
||||
[Column(Name = "oem_id")]
|
||||
public string OemId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 手机号
|
||||
/// </summary>
|
||||
[Column(Name = "mobile")]
|
||||
public string Mobile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 添加时间
|
||||
/// </summary>
|
||||
[Column(Name = "add_time")]
|
||||
public DateTime? AddTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 修改时间
|
||||
/// </summary>
|
||||
[Column(Name = "mdy_time")]
|
||||
public DateTime? MdyTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 公司名称
|
||||
/// </summary>
|
||||
[Column(Name = "company_name")]
|
||||
public string CompanyName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注
|
||||
/// </summary>
|
||||
[Column(Name = "notes")]
|
||||
public string Notes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 超管等级 0 普通用户 1 超级管理员
|
||||
/// </summary>
|
||||
[Column(Name = "sa_level")]
|
||||
public short? SaLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 账户权限
|
||||
/// </summary>
|
||||
[Column(Name = "authority")]
|
||||
public string Authority { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 子账号被禁止展示的功能页面
|
||||
/// </summary>
|
||||
[Column(Name = "forbidden_page")]
|
||||
public string ForbiddenPage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 账户积分,1积分=1元
|
||||
/// </summary>
|
||||
[Column(Name = "points")]
|
||||
public decimal Points { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 积分历史累计充值金额
|
||||
/// </summary>
|
||||
[Column(Name = "recharge_points_total")]
|
||||
public decimal? RechargePointsTotal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 店铺每续费一个月需要的积分,单位元
|
||||
/// </summary>
|
||||
[Column(Name = "shop_points_per_month")]
|
||||
public int ShopPointsPerMonth { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 可用余额
|
||||
/// </summary>
|
||||
[Column(Name = "balance")]
|
||||
public decimal? Balance { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计充值金额
|
||||
/// </summary>
|
||||
[Column(Name = "recharge_amount_total")]
|
||||
public decimal? RechargeAmountTotal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每单中转仓快递费用,单位元,两位小数
|
||||
/// </summary>
|
||||
[Column(Name = "postage_per_order")]
|
||||
public decimal? PostagePerOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每单中转仓退货快递费用,单位元,两位小数
|
||||
/// </summary>
|
||||
[Column(Name = "refund_postage_per_order")]
|
||||
public decimal? RefundPostagePerOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 礼物每单额外收取费用,单位元,两位小数
|
||||
/// </summary>
|
||||
[Column(Name = "gift_oem_amount_per_order")]
|
||||
public decimal? GiftOemAmountPerOrder { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 同步物流费用,单位元,两位小数
|
||||
/// </summary>
|
||||
[Column(Name = "sync_logistics_per_order")]
|
||||
public decimal? SyncLogisticsPerOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否主账户
|
||||
/// </summary>
|
||||
public bool IsMainUser
|
||||
{
|
||||
get => string.IsNullOrWhiteSpace(ParentUserName);
|
||||
}
|
||||
/// <summary>
|
||||
/// 是否OEM租户
|
||||
/// </summary>
|
||||
public bool IsOEMTenant => !string.IsNullOrWhiteSpace(OemId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using FreeSql.DataAnnotations;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Web;
|
||||
|
||||
namespace Store.Users.Entities;
|
||||
|
||||
[Table(Name = "wxxd.user_payment_record")]
|
||||
public class UserPaymentRecordEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// id
|
||||
/// </summary>
|
||||
[Column(Name = "id", IsPrimary = true, IsIdentity = true)]
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 变动类型,1:充值,2:采购花费,3:产品退款到账,4:运费退款到账,5:采购收款,6:产品退款扣除,7:运费退款扣除, 16:礼物单话费,20:中转仓扣费支出,23:中转仓退款收入
|
||||
/// </summary>
|
||||
[Column(Name = "type")]
|
||||
public int Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 金额(元)
|
||||
/// </summary>
|
||||
[Column(Name = "amount")]
|
||||
public decimal? Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 归属oem用户的金额
|
||||
/// </summary>
|
||||
[Column(Name = "oem_amount")]
|
||||
public decimal? OemAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 余额(元)
|
||||
/// </summary>
|
||||
[Column(Name = "balance")]
|
||||
public decimal? Balance { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 账户名
|
||||
/// </summary>
|
||||
[Column(Name = "user_name")]
|
||||
public string UserName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 平台编码
|
||||
/// </summary>
|
||||
[Column(Name = "platform_code")]
|
||||
public string PlatformCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单id
|
||||
/// </summary>
|
||||
[Column(Name = "order_id")]
|
||||
public string OrderId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 售后单id
|
||||
/// </summary>
|
||||
[Column(Name = "after_sale_id")]
|
||||
public long? AfterSaleId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建人
|
||||
/// </summary>
|
||||
[Column(Name = "create_by")]
|
||||
public string CreateBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间
|
||||
/// </summary>
|
||||
[Column(Name = "create_time")]
|
||||
public DateTime CreateTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 修改人
|
||||
/// </summary>
|
||||
[Column(Name = "update_by")]
|
||||
public string UpdateBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 修改时间
|
||||
/// </summary>
|
||||
[Column(Name = "update_time")]
|
||||
public DateTime? UpdateTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否已删除
|
||||
/// </summary>
|
||||
[Column(Name = "is_deleted")]
|
||||
public int IsDeleted { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注
|
||||
/// </summary>
|
||||
[Column(Name = "remark")]
|
||||
public string Remark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扣款的对方账户名称
|
||||
/// </summary>
|
||||
[Column(Name = "counterparty_account_name")]
|
||||
public string CounterpartyAccountName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// OEM是否结算 0未结算 1已结算
|
||||
/// </summary>
|
||||
[Column(Name = "settlement")]
|
||||
public byte Settlement { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 退款流水号 外采&订单号
|
||||
/// </summary>
|
||||
[Column(Name = "refund_flow_no")]
|
||||
public string RefundFlowNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结算时间
|
||||
/// </summary>
|
||||
[Column(Name = "settlement_time")]
|
||||
public DateTime? SettlementTime { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using FreeSql.DataAnnotations;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Web;
|
||||
|
||||
namespace Store.Users.Entities
|
||||
{
|
||||
|
||||
[Table(Name = "wxxd.user_points_record")]
|
||||
public class UserPointsRecordEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 主键(自增)
|
||||
/// </summary>
|
||||
[Column(Name = "id", IsPrimary = true, IsIdentity = true)]
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户名(NOT NULL)
|
||||
/// </summary>
|
||||
[Column(Name = "user_name")]
|
||||
public string UserName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户昵称(NOT NULL)
|
||||
/// </summary>
|
||||
[Column(Name = "user_nick")]
|
||||
public string UserNick { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 操作记录金额,单位元,最小1元(NOT NULL)
|
||||
/// </summary>
|
||||
[Column(Name = "delta_points")]
|
||||
public decimal DeltaPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 充值或消费后的当前金额(允许 NULL)
|
||||
/// </summary>
|
||||
[Column(Name = "new_points")]
|
||||
public decimal? NewPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 操作类型 消费 充值 转入 转出(NOT NULL)
|
||||
/// </summary>
|
||||
[Column(Name = "option_type")]
|
||||
public string OptionType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 操作者类型 管理员 用户(NOT NULL)
|
||||
/// </summary>
|
||||
[Column(Name = "operator_type")]
|
||||
public string OperatorType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实际操作者用户名(NOT NULL)
|
||||
/// </summary>
|
||||
[Column(Name = "operator_name")]
|
||||
public string OperatorName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 消费店铺编号(消费才有值,充值为空,允许 NULL)
|
||||
/// </summary>
|
||||
[Column(Name = "shop_id")]
|
||||
public string? ShopId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 消费店铺名称(消费才有值,充值为空,允许 NULL)
|
||||
/// </summary>
|
||||
[Column(Name = "shop_account")]
|
||||
public string? ShopAccount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注(允许 NULL)
|
||||
/// </summary>
|
||||
[Column(Name = "remark")]
|
||||
public string? Remark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// (点数转出时的)对方账户(允许 NULL)
|
||||
/// </summary>
|
||||
[Column(Name = "counterparty_account_name")]
|
||||
public string? CounterpartyAccountName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付宝订单的外部订单号(允许 NULL)
|
||||
/// </summary>
|
||||
[Column(Name = "out_trade_no")]
|
||||
public string? OutTradeNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 添加时间(NOT NULL,默认 CURRENT_TIMESTAMP)
|
||||
/// </summary>
|
||||
[Column(Name = "add_time")]
|
||||
public DateTime AddTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// OEM是否结算 0未结算 1已结算
|
||||
/// </summary>
|
||||
[Column(Name = "settlement")]
|
||||
public bool Settlement { get; set; }
|
||||
/// <summary>
|
||||
/// 结算时间
|
||||
/// </summary>
|
||||
[Column(Name = "settlement_time")]
|
||||
public DateTime? SettlementTime { get; set; }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using Riok.Mapperly.Abstractions;
|
||||
using Store.Users.Contracts.Dtos;
|
||||
using Store.Users.Entities;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace Store.Users.Mappers
|
||||
{
|
||||
[Mapper]
|
||||
internal partial class UserMapper
|
||||
{
|
||||
public partial UserInfo ToInfo(UserEntity entity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using FreeSql;
|
||||
using MAX.Core.DependencyInjection;
|
||||
using Store.Users.Contracts.Dtos;
|
||||
using Store.Users.Entities;
|
||||
using Store.Users.Mappers;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace Store.Users.Repositories
|
||||
{
|
||||
public interface IUserRepository : IBaseRepository<UserEntity>
|
||||
{
|
||||
Task<UserEntity> GetUser(string userName);
|
||||
}
|
||||
internal class UserRepository : BaseRepository<UserEntity>, IUserRepository, ITransientDependency
|
||||
{
|
||||
private readonly IFreeSql _freeSql;
|
||||
|
||||
public UserRepository(IFreeSql freeSql) : base(freeSql)
|
||||
{
|
||||
_freeSql = freeSql;
|
||||
}
|
||||
|
||||
public async Task<UserEntity> GetUser(string userName)
|
||||
{
|
||||
var sql = Where(a => a.UserName == userName).ToSql();
|
||||
var user = await Where(a => a.UserName == userName).FirstAsync();
|
||||
if (user != null)
|
||||
{
|
||||
if (user.IsMainUser == false)
|
||||
{
|
||||
var mainUser = await Where(a => a.UserName == user.ParentUserName).FirstAsync();
|
||||
if (mainUser != null)
|
||||
{
|
||||
user.OemId = mainUser.OemId;
|
||||
user.PostagePerOrder = mainUser.PostagePerOrder;
|
||||
user.RefundPostagePerOrder = mainUser.RefundPostagePerOrder;
|
||||
user.GiftOemAmountPerOrder = mainUser.GiftOemAmountPerOrder;
|
||||
user.SyncLogisticsPerOrder = mainUser.SyncLogisticsPerOrder;
|
||||
}
|
||||
}
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using MAX.Core.DependencyInjection;
|
||||
using Store.Users.Contracts;
|
||||
using Store.Users.Contracts.Dtos;
|
||||
using Store.Users.Entities;
|
||||
using Store.Users.Mappers;
|
||||
using Store.Users.Repositories;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace Store.Users.Services
|
||||
{
|
||||
internal class UserQueryService : IUserQueryService, ITransientDependency
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public UserQueryService(IUserRepository userRepository)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
public async Task<UserInfo> GetUser(string userName)
|
||||
{
|
||||
var sql = _userRepository.Where(a => a.UserName == userName).ToSql();
|
||||
var user = await _userRepository.Where(a => a.UserName == userName).FirstAsync();
|
||||
if (user != null)
|
||||
{
|
||||
if (user.IsMainUser == false)
|
||||
{
|
||||
var mainUser = await _userRepository.Where(a => a.UserName == user.ParentUserName).FirstAsync();
|
||||
if (mainUser != null)
|
||||
{
|
||||
user.OemId = mainUser.OemId;
|
||||
user.PostagePerOrder = mainUser.PostagePerOrder;
|
||||
user.RefundPostagePerOrder = mainUser.RefundPostagePerOrder;
|
||||
user.GiftOemAmountPerOrder = mainUser.GiftOemAmountPerOrder;
|
||||
user.SyncLogisticsPerOrder = mainUser.SyncLogisticsPerOrder;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new UserMapper().ToInfo(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using MAX.Core.DependencyInjection;
|
||||
using MAX.Core.Interfaces;
|
||||
|
||||
namespace Store.Users
|
||||
{
|
||||
public class Startup : IStartup
|
||||
{
|
||||
public int Priority => IntPriority.Normal;
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
}
|
||||
|
||||
public void Register(IServiceRegistrar registrar)
|
||||
{
|
||||
}
|
||||
|
||||
public void Start(IServiceProvider serviceProvider)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Store.Framework\Store.Framework.csproj" />
|
||||
<ProjectReference Include="..\Store.Shops.Contracts\Store.Shops.Contracts.csproj" />
|
||||
<ProjectReference Include="..\Store.Users.Contracts\Store.Users.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in new issue
Block a user