把一些项目移动到插件中

This commit is contained in:
chfei committed 2026-10-22 15:12:56 +08:00
1 parent d0f9f00b76
commit 5ff52dea92
60 files changed
+410 -27

No files matched your search

@@ -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>
[HttpPost]
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>
[HttpPost]
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,269 @@
using Asp.Versioning;
using MAX.Framework.Commons;
using MAX.Framework.Dtos;
using MAX.Framework.Security;
using Microsoft.AspNetCore.Mvc;
using Store.Framework.Base;
using Store.Modules.CloudProduct.Dtos;
using Store.Modules.CloudProduct.Services;
namespace Store.Modules.CloudProduct.Controllers
{
/// <summary>
/// 用户收藏商品:收藏/取消收藏(数据库 + 收藏索引同步)、收藏分组、收藏状态查询、收藏检索与索引重建。
/// 请求中的 userName 允许为空,为空时取当前登录用户。
/// </summary>
[ApiVersionNeutral]
[Route("api/CloudGoods/[action]")]
public class UserFavoritesController : AppControllerBase
{
private readonly IUserFavoritesService _favoritesService;
private readonly ICurrentUser _currentUser;
public UserFavoritesController(IUserFavoritesService favoritesService, ICurrentUser currentUser)
{
_favoritesService = favoritesService;
_currentUser = currentUser;
}
/// <summary>
/// 批量收藏商品:写入 user_goods_favorites 并同步写入收藏索引,返回本次成功收藏的商品数量。
/// 商品已收藏时视为修改收藏分组;商品不存在/已删除的编号会被跳过。
/// </summary>
[HttpPost]
public async Task<int> AddFavorite(AddUserFavoriteRequest request)
{
if (request == null)
{
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
}
request.UserName = ResolveUserName(request.UserName);
if (string.IsNullOrWhiteSpace(request.UserName))
{
throw new AppException("用户名不能为空", ResultCode.ValidationError);
}
if (request.GoodsIds == null || request.GoodsIds.Count == 0)
{
throw new AppException("请提供要收藏的商品编号", ResultCode.ValidationError);
}
if (request.GroupId < 0)
{
throw new AppException("收藏分组无效", ResultCode.ValidationError);
}
return await _favoritesService.AddFavoritesAsync(request);
}
/// <summary>
/// 批量取消收藏:删除 user_goods_favorites 记录并同步删除收藏索引文档,返回实际取消的条数。
/// </summary>
[HttpPost]
public async Task<int> RemoveFavorites(RemoveUserFavoritesRequest request)
{
if (request == null)
{
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
}
request.UserName = ResolveUserName(request.UserName);
if (string.IsNullOrWhiteSpace(request.UserName))
{
throw new AppException("用户名不能为空", ResultCode.ValidationError);
}
if (request.GoodsIds == null || request.GoodsIds.Count == 0)
{
throw new AppException("请提供要取消收藏的商品编号", ResultCode.ValidationError);
}
return await _favoritesService.RemoveFavoritesAsync(request);
}
/// <summary>
/// 创建收藏分组:创建成功返回 true,同名分组已存在返回 false。
/// </summary>
[HttpPost]
public async Task<bool> CreateGroup(CreateUserFavoriteGroupRequest request)
{
if (request == null)
{
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
}
request.UserName = ResolveUserName(request.UserName);
if (string.IsNullOrWhiteSpace(request.UserName))
{
throw new AppException("用户名不能为空", ResultCode.ValidationError);
}
if (string.IsNullOrWhiteSpace(request.GroupName))
{
throw new AppException("分组名不能为空", ResultCode.ValidationError);
}
return await _favoritesService.CreateGroupAsync(request);
}
/// <summary>
/// 查询用户的收藏分组(含默认分组)及分组下收藏数量。
/// </summary>
[HttpGet]
public async Task<List<UserFavoriteGroupDto>> GetGroups(string? userName = null)
{
var resolved = ResolveUserName(userName);
if (string.IsNullOrWhiteSpace(resolved))
{
throw new AppException("用户名不能为空", ResultCode.ValidationError);
}
return await _favoritesService.GetGroupsAsync(resolved);
}
/// <summary>
/// 按 GoodsIds 顺序返回每个商品是否已被收藏(如 [true,false,...]),前台可按顺序直接渲染。
/// </summary>
[HttpPost]
public async Task<List<bool>> GetFavoriteStatus(UserFavoriteStatusRequest request)
{
if (request == null)
{
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
}
request.UserName = ResolveUserName(request.UserName);
if (string.IsNullOrWhiteSpace(request.UserName))
{
throw new AppException("用户名不能为空", ResultCode.ValidationError);
}
return await _favoritesService.GetFavoriteStatusAsync(request);
}
/// <summary>
/// 检索用户收藏的商品(标题、销量、价格、类目、分组)。
/// </summary>
[HttpPost]
public async Task<PageData<UserFavoriteItem>> SearchFavorites(UserFavoritesSearchRequest request)
{
// 1. 参数基础校验
if (request == null)
{
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
}
request.UserName = ResolveUserName(request.UserName);
if (string.IsNullOrWhiteSpace(request.UserName))
{
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.GroupId.HasValue && request.GroupId < 0)
{
throw new AppException("收藏分组无效", ResultCode.ValidationError);
}
// 7. 平台校验
if (request.Platform.HasValue && request.Platform < 0)
{
throw new AppException("平台类型无效", ResultCode.ValidationError);
}
var result = await _favoritesService.SearchFavoritesAsync(request);
return new PageData<UserFavoriteItem>
{
List = result.List,
Total = result.Total,
PageSize = request.PageSize,
PageIndex = request.PageIndex,
};
}
/// <summary>
/// 移除所有用户收藏索引并重新创建,随后按 user_goods_favorites 关联 goods 全量回填。
/// </summary>
[HttpPost]
public async Task<long> RebuildSearchIndex()
{
return await _favoritesService.RebuildSearchIndexAsync();
}
/// <summary>
/// 取用户名:显式传入优先,否则取当前登录用户。
/// </summary>
private string ResolveUserName(string? userName)
{
return string.IsNullOrWhiteSpace(userName)
? (_currentUser.UserName ?? string.Empty)
: userName.Trim();
}
}
}