486 lines
15 KiB
C#
486 lines
15 KiB
C#
using Asp.Versioning;
|
|
using MAX.Framework.Commons;
|
|
using MAX.Framework.Dtos;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
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
|
|
{
|
|
/// <summary>云商品管理:商品查询、导入、违规上报及搜索索引维护。</summary>
|
|
[Tags("云商品")]
|
|
[Route("CloudGoods/[action]")]
|
|
public class GoodsController : AppControllerBase
|
|
{
|
|
private readonly IGoodsService _goodsService;
|
|
private readonly IOpenSearchService _searchService;
|
|
private readonly IRawDataStorage _rawDataStorage;
|
|
private readonly GoodsIndexJob _goodsIndexJob;
|
|
private readonly GoodsViolationService _violations;
|
|
|
|
public GoodsController(IGoodsService goodsService, IOpenSearchService searchService, IRawDataStorage rawDataStorage, GoodsIndexJob goodsIndexJob, GoodsViolationService violations)
|
|
{
|
|
_violations = violations;
|
|
_goodsService = goodsService;
|
|
_searchService = searchService;
|
|
_rawDataStorage = rawDataStorage;
|
|
_goodsIndexJob = goodsIndexJob;
|
|
}
|
|
|
|
/// <summary>获取全部违规原因字典,包含未启用违规判定的原因,按ID升序返回。</summary>
|
|
[HttpGet]
|
|
[EndpointSummary("获取违规原因字典")]
|
|
public Task<List<ViolationReasonDto>> GetViolationReasons()
|
|
=> _violations.GetReasonsAsync();
|
|
|
|
/// <summary>批量上报违规商品;重复上报合并原因。</summary>
|
|
[HttpPost]
|
|
[EndpointSummary("批量上报违规商品")]
|
|
public Task<List<ReportGoodsViolationsResult>> ReportViolations([FromBody] ReportGoodsViolationItem[] items)
|
|
=> _violations.ReportAsync(items);
|
|
|
|
/// <summary>记录已完成的违规上报(1)、自动上货剔除(2)或店铺自动下架(3)。</summary>
|
|
[HttpPost]
|
|
[EndpointSummary("批量上报违规日志,每次最多 1000 条")]
|
|
public async Task<ApiResult> RecordViolationLog([FromBody] RecordViolationLogRequest[] items)
|
|
{
|
|
await _violations.RecordLogsAsync(items);
|
|
return ApiResult.Ok();
|
|
}
|
|
|
|
/// <summary>按店铺编号数组查询违规日志,可筛选商品,按日志编号倒序翻页。</summary>
|
|
[HttpPost]
|
|
[EndpointSummary("按商铺批量查询违规日志,每页最多 200 条")]
|
|
public Task<List<GoodsViolationLogEntity>> GetViolationLogs([FromBody] GetViolationLogsRequest request)
|
|
=> _violations.GetLogsAsync(request.ShopIds, request.GoodsId, request.BeforeId, request.Size);
|
|
|
|
/// <summary>批量解除商品违规,恢复正常商品索引;失败后由后台索引任务重试。</summary>
|
|
[HttpPost]
|
|
[EndpointSummary("解除商品违规并恢复索引")]
|
|
public Task<RemoveGoodsViolationsResult> RemoveViolations([FromBody] long[] ids)
|
|
=> _violations.RemoveAsync(ids);
|
|
|
|
/// <summary>按商品编号批量查询是否违规。</summary>
|
|
[HttpPost]
|
|
[EndpointSummary("批量查询商品违规状态")]
|
|
public Task<List<GoodsViolationStatus>> GetViolationStatus([FromBody] long[] ids)
|
|
=> _violations.GetStatusAsync(ids);
|
|
|
|
/// <summary>获取服务器当前日期(长日期格式)。</summary>
|
|
[HttpGet]
|
|
[EndpointSummary("获取服务器当前日期")]
|
|
public async Task<string> Time()
|
|
{
|
|
return DateTime.Now.ToLongDateString();
|
|
}
|
|
/// <summary>手动执行待处理商品的索引任务。</summary>
|
|
[HttpGet]
|
|
[EndpointSummary("处理待建立索引的商品")]
|
|
public async Task Job()
|
|
{
|
|
await _goodsIndexJob.ProcessPendingAsync();
|
|
}
|
|
/// <summary>
|
|
/// 分页搜索商品,可按标题、价格、类目、销量和平台筛选。
|
|
/// </summary>
|
|
/// <param name="request">商品筛选条件及分页参数。</param>
|
|
/// <returns>符合条件的商品列表及总数。</returns>
|
|
[HttpPost]
|
|
[EndpointSummary("分页搜索商品")]
|
|
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")]
|
|
[EndpointSummary("按关键词搜索商品摘要")]
|
|
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]
|
|
[EndpointSummary("导入测试商品数据")]
|
|
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]
|
|
[EndpointSummary("批量导入商品 JSON 文件")]
|
|
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]
|
|
[EndpointSummary("查询 OpenSearch 服务状态")]
|
|
public async Task<OpenSearchStatusDto> GetOpenSearchStatus()
|
|
{
|
|
return await _searchService.GetStatusAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 检查搜索索引是否存在。
|
|
/// </summary>
|
|
[HttpGet]
|
|
[EndpointSummary("检查商品搜索索引是否存在")]
|
|
public async Task<bool> IndexExists()
|
|
{
|
|
return await _searchService.IndexExistsAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 检查索引映射(分析器)是否符合当前服务要求。
|
|
/// </summary>
|
|
[HttpGet]
|
|
[EndpointSummary("检查商品索引映射是否正常")]
|
|
public async Task<bool> IndexMappingHealthy()
|
|
{
|
|
return await _searchService.IsIndexMappingHealthyAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 创建搜索索引(含 IK 分词映射,已存在且映射正常时跳过)。
|
|
/// </summary>
|
|
[HttpPost]
|
|
[EndpointSummary("创建商品搜索索引")]
|
|
public async Task<bool> CreateIndex()
|
|
{
|
|
return await _searchService.CreateIndexAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 删除搜索索引。
|
|
/// </summary>
|
|
[HttpPost]
|
|
[EndpointSummary("删除商品搜索索引")]
|
|
public async Task DeleteIndex()
|
|
{
|
|
await _searchService.DeleteIndexAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 手动刷新搜索索引,使最新写入立即可搜索。
|
|
/// </summary>
|
|
[HttpPost]
|
|
[EndpointSummary("刷新商品搜索索引")]
|
|
public async Task RefreshIndex()
|
|
{
|
|
await _searchService.RefreshIndexAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 手动新增或覆盖一条搜索文档。
|
|
/// </summary>
|
|
[HttpPost]
|
|
[EndpointSummary("新增或覆盖商品搜索文档")]
|
|
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>
|
|
[HttpPost]
|
|
[EndpointSummary("查询商品详情")]
|
|
public async Task<JsonNode> GetGoodsDetail([FromQuery] long goodsId)
|
|
{
|
|
try
|
|
{
|
|
var raw = await GetRawDataOrThrowAsync(goodsId);
|
|
var node = JsonNode.Parse(raw);
|
|
return node ?? throw new AppException("商品详情解析失败", ResultCode.BusinessError);
|
|
}
|
|
catch(Exception ex)
|
|
{
|
|
throw new AppException(ex.Message);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 读取指定商品的原始数据(解压缩后原样返回)。
|
|
/// </summary>
|
|
[HttpPost]
|
|
[EndpointSummary("读取商品原始数据")]
|
|
public async Task<string> GetGoodsRawData([FromQuery] long goodsId)
|
|
{
|
|
try
|
|
{
|
|
return await GetRawDataOrThrowAsync(goodsId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
throw new AppException(ex.Message);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 重新从数据库读取指定商品,覆盖 OpenSearch 已有分词并重新分词。
|
|
/// ids 为空时重建全部商品。
|
|
/// </summary>
|
|
[HttpPost]
|
|
[EndpointSummary("重建商品搜索文档")]
|
|
public async Task<long> RebuildIndex([FromBody] long[]? ids)
|
|
{
|
|
return await _goodsService.RebuildSearchIndexAsync(ids);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 删除商品(按商品编号)。是否软删除由配置 Delete:SoftDelete 决定,
|
|
/// 软删除将 Status 置 1,硬删除物理删除记录;两种方式都会删除 OpenSearch 中对应文档。
|
|
/// </summary>
|
|
[HttpPost]
|
|
[EndpointSummary("批量删除商品")]
|
|
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")]
|
|
[EndpointSummary("清空并重建商品搜索索引")]
|
|
public async Task<bool> ClearSearchIndex()
|
|
{
|
|
return await _searchService.ClearIndexAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 统计索引中的文档数(路由保持 GetSearchCount 不变)。
|
|
/// </summary>
|
|
[HttpGet]
|
|
[ActionName("GetSearchCount")]
|
|
[EndpointSummary("统计商品搜索文档数量")]
|
|
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)
|
|
{
|
|
System.IO.File.WriteAllText("./NoGoods.txt", goodsId.ToString());
|
|
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;
|
|
}
|
|
}
|
|
}
|