using FoodLabeling.Application.Contracts.Dtos.Common; using FoodLabeling.Application.Contracts.Dtos.Training; using FoodLabeling.Application.Contracts.IServices; using FoodLabeling.Application.Helpers; using FoodLabeling.Application.Services.DbModels; using FoodLabeling.Domain.Shared.Enums; using FoodLabeling.Domain.Shared.Helpers; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Hosting; using SqlSugar; using Volo.Abp; using Volo.Abp.Application.Services; using Yi.Framework.SqlSugarCore.Abstractions; namespace FoodLabeling.Application.Services; /// /// 培训 / 资料中心(管理端) /// public class TrainingAppService : ApplicationService, ITrainingAppService { private const long MaxFileSizeBytes = 20 * 1024 * 1024; private static readonly HashSet ImageExtensions = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp" }; private static readonly HashSet DocExtensions = new(StringComparer.OrdinalIgnoreCase) { ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt", ".csv" }; private static readonly HashSet AllowedExtensions = new(StringComparer.OrdinalIgnoreCase); private readonly ISqlSugarDbContext _dbContext; private readonly IHostEnvironment _hostEnvironment; static TrainingAppService() { foreach (var ext in ImageExtensions) { AllowedExtensions.Add(ext); } foreach (var ext in DocExtensions) { AllowedExtensions.Add(ext); } } public TrainingAppService(ISqlSugarDbContext dbContext, IHostEnvironment hostEnvironment) { _dbContext = dbContext; _hostEnvironment = hostEnvironment; } /// /// 获取培训分类树(可选含文件;支持 keyword、locationId 筛选) /// /// /// 一级分类 ParentId 为空;二级分类 ParentId 指向一级。文件仅挂在二级分类下。 /// /// 示例请求: /// ```json /// { /// "keyword": "安全", /// "locationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", /// "includeFiles": true /// } /// ``` /// /// 参数说明: /// - keyword: 匹配分类名或文件名 /// - locationId: 按门店权限过滤可见文件 /// - includeFiles: 是否返回文件列表 /// /// 查询条件 /// 分类树 /// 成功返回分类树 /// 参数无效 /// 服务器错误 public async Task> GetCategoryTreeAsync([FromQuery] TrainingCategoryTreeInputVo input) { var keyword = input.Keyword?.Trim(); TrainingFileScopeHelper.LocationScopeContext? scopeContext = null; if (!string.IsNullOrWhiteSpace(input.LocationId)) { scopeContext = await TrainingFileScopeHelper.ResolveLocationScopeContextAsync( _dbContext.SqlSugarClient, input.LocationId); } var categories = await _dbContext.SqlSugarClient.Queryable() .Where(x => !x.IsDeleted) .OrderByDescending(x => x.OrderNum) .OrderByDescending(x => x.CreationTime) .ToListAsync(); var level2Ids = categories .Where(x => !string.IsNullOrWhiteSpace(x.ParentId)) .Select(x => x.Id) .ToList(); var filesByCategory = new Dictionary>(StringComparer.Ordinal); if (input.IncludeFiles && level2Ids.Count > 0) { var fileQuery = _dbContext.SqlSugarClient.Queryable() .Where(x => !x.IsDeleted && level2Ids.Contains(x.CategoryId)); fileQuery = TrainingFileScopeHelper.ApplyLocationVisibilityFilter(fileQuery, scopeContext); if (!string.IsNullOrWhiteSpace(keyword)) { fileQuery = fileQuery.Where(x => x.FileName.Contains(keyword!)); } var files = await fileQuery .OrderByDescending(x => x.OrderNum) .OrderByDescending(x => x.CreationTime) .ToListAsync(); foreach (var group in files.GroupBy(x => x.CategoryId)) { filesByCategory[group.Key] = group.ToList(); } } var allFileEntities = filesByCategory.Values.SelectMany(x => x).ToList(); var scopeDisplayMap = await TrainingFileScopeHelper.BuildScopeDisplayMapAsync( _dbContext.SqlSugarClient, allFileEntities); var level1 = categories.Where(x => string.IsNullOrWhiteSpace(x.ParentId)).ToList(); var level2Map = categories .Where(x => !string.IsNullOrWhiteSpace(x.ParentId)) .GroupBy(x => x.ParentId!.Trim(), StringComparer.Ordinal) .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal); var result = new List(); foreach (var l1 in level1) { var children = level2Map.TryGetValue(l1.Id, out var l2List) ? l2List.OrderByDescending(x => x.OrderNum).ThenByDescending(x => x.CreationTime).ToList() : new List(); var childNodes = new List(); foreach (var l2 in children) { filesByCategory.TryGetValue(l2.Id, out var fileRows); fileRows ??= new List(); var nameMatch = string.IsNullOrWhiteSpace(keyword) || l2.CategoryName.Contains(keyword!, StringComparison.OrdinalIgnoreCase); var fileMatch = fileRows.Count > 0; if (!string.IsNullOrWhiteSpace(keyword) && !nameMatch && !fileMatch) { continue; } if (scopeContext is not null && fileRows.Count == 0 && !nameMatch) { continue; } childNodes.Add(MapCategoryNode(l2, fileRows, scopeDisplayMap)); } var l1NameMatch = string.IsNullOrWhiteSpace(keyword) || l1.CategoryName.Contains(keyword!, StringComparison.OrdinalIgnoreCase); if (!string.IsNullOrWhiteSpace(keyword) && !l1NameMatch && childNodes.Count == 0) { continue; } if (scopeContext is not null && childNodes.Count == 0 && !l1NameMatch) { continue; } result.Add(new TrainingCategoryTreeNodeDto { Id = l1.Id, CategoryName = l1.CategoryName, ParentId = null, OrderNum = l1.OrderNum, Children = childNodes, Files = new List() }); } return result; } /// /// 新增培训分类(一级或二级) /// /// /// 示例请求: /// ```json /// { /// "categoryName": "食品安全", /// "parentId": null, /// "orderNum": 100 /// } /// ``` /// /// 分类信息 /// 新建分类 /// 创建成功 /// 参数无效或父级不存在 /// 服务器错误 public async Task CreateCategoryAsync(TrainingCategoryCreateInputVo input) { var name = input.CategoryName?.Trim(); if (string.IsNullOrWhiteSpace(name)) { throw new UserFriendlyException("分类名称不能为空"); } var parentId = string.IsNullOrWhiteSpace(input.ParentId) ? null : input.ParentId.Trim(); if (parentId is not null) { var parent = await _dbContext.SqlSugarClient.Queryable() .FirstAsync(x => x.Id == parentId && !x.IsDeleted); if (parent is null) { throw new UserFriendlyException("父级分类不存在"); } if (!string.IsNullOrWhiteSpace(parent.ParentId)) { throw new UserFriendlyException("仅支持两级分类,不能在二级分类下再建子级"); } } var duplicated = await _dbContext.SqlSugarClient.Queryable() .AnyAsync(x => !x.IsDeleted && x.CategoryName == name && x.ParentId == parentId); if (duplicated) { throw new UserFriendlyException("同级分类名称已存在"); } var now = DateTime.Now; var currentUserId = CurrentUser?.Id?.ToString(); var entity = new FlTrainingCategoryDbEntity { Id = YitIdHelper.NextId().ToString(), CategoryName = name, ParentId = parentId, OrderNum = input.OrderNum, IsDeleted = false, CreationTime = now, CreatorId = currentUserId, LastModificationTime = now, LastModifierId = currentUserId, ConcurrencyStamp = YitIdHelper.NextId().ToString() }; await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync(); return MapCategoryOutput(entity); } /// /// 编辑培训分类 /// /// /// 示例请求: /// ```json /// { /// "categoryName": "食品安全(更新)", /// "orderNum": 90 /// } /// ``` /// /// 分类Id /// 分类信息 /// 更新后的分类 /// 更新成功 /// 分类不存在或名称重复 /// 服务器错误 public async Task UpdateCategoryAsync(string id, TrainingCategoryUpdateInputVo input) { var entity = await GetCategoryOrThrowAsync(id); var name = input.CategoryName?.Trim(); if (string.IsNullOrWhiteSpace(name)) { throw new UserFriendlyException("分类名称不能为空"); } var duplicated = await _dbContext.SqlSugarClient.Queryable() .AnyAsync(x => !x.IsDeleted && x.Id != id && x.CategoryName == name && x.ParentId == entity.ParentId); if (duplicated) { throw new UserFriendlyException("同级分类名称已存在"); } entity.CategoryName = name; entity.OrderNum = input.OrderNum; entity.LastModificationTime = DateTime.Now; entity.LastModifierId = CurrentUser?.Id?.ToString(); await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync(); return MapCategoryOutput(entity); } /// /// 删除培训分类(软删) /// /// /// 一级分类存在二级子分类时不可删除;二级分类存在文件时不可删除。 /// /// 分类Id /// 删除成功 /// 存在子分类或文件 /// 服务器错误 public async Task DeleteCategoryAsync(string id) { var entity = await GetCategoryOrThrowAsync(id); if (string.IsNullOrWhiteSpace(entity.ParentId)) { var hasChild = await _dbContext.SqlSugarClient.Queryable() .AnyAsync(x => !x.IsDeleted && x.ParentId == id); if (hasChild) { throw new UserFriendlyException("该一级分类下存在二级分类,无法删除"); } } else { var hasFile = await _dbContext.SqlSugarClient.Queryable() .AnyAsync(x => !x.IsDeleted && x.CategoryId == id); if (hasFile) { throw new UserFriendlyException("该二级分类下存在培训文件,无法删除"); } } entity.IsDeleted = true; entity.LastModificationTime = DateTime.Now; entity.LastModifierId = CurrentUser?.Id?.ToString(); await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync(); } /// /// 上传培训文件到二级分类(可同时提交 Company / Region / Location 适用范围) /// /// /// multipart/form-data:file、categoryId、orderNum,及可选 scope 字段。 /// 支持常见图片与 pdf/doc/docx/xlsx 等,单文件最大 20MB。 /// /// 示例 form 字段: /// - file: 文件 /// - categoryId: 二级分类 Id /// - appliedPartnerType: ALL / SPECIFIED /// - partnerIds: 可重复传值或含 ALL /// - appliedRegionType: ALL / SPECIFIED /// - regionIds / groupIds: 可含 ALL /// - availabilityType 或 appliedLocationType: ALL / SPECIFIED /// - locationIds: 可含 ALL /// /// 上传表单 /// 文件信息 /// 上传成功 /// 文件无效或分类不是二级 /// 服务器错误 [HttpPost] [Consumes("multipart/form-data")] [Route("/api/app/training/file/upload")] public async Task UploadFileAsync([FromForm] TrainingFileUploadInputVo input) { if (input.File is null || input.File.Length <= 0) { throw new UserFriendlyException("请选择要上传的文件"); } if (input.File.Length > MaxFileSizeBytes) { throw new UserFriendlyException("文件大小不能超过20MB"); } var categoryId = input.CategoryId?.Trim(); if (string.IsNullOrWhiteSpace(categoryId)) { throw new UserFriendlyException("二级分类Id不能为空"); } var category = await GetCategoryOrThrowAsync(categoryId); if (string.IsNullOrWhiteSpace(category.ParentId)) { throw new UserFriendlyException("文件只能上传到二级分类"); } var ext = Path.GetExtension(input.File.FileName ?? string.Empty); if (string.IsNullOrWhiteSpace(ext) || !AllowedExtensions.Contains(ext)) { throw new UserFriendlyException("不支持的文件格式"); } var saveRoot = ResolveTrainingRoot(); Directory.CreateDirectory(saveRoot); var storedName = $"{DateTime.Now:yyyyMMddHHmmss}_{YitIdHelper.NextId()}{ext.ToLowerInvariant()}"; var savePath = Path.Combine(saveRoot, storedName); await using (var stream = new FileStream(savePath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { await input.File.CopyToAsync(stream); } var now = DateTime.Now; var currentUserId = CurrentUser?.Id?.ToString(); var hasScopeInput = HasScopeInput(input); var scope = hasScopeInput ? await TrainingFileScopeHelper.ResolveScopeForSaveAsync( _dbContext.SqlSugarClient, input.AppliedPartnerType, input.PartnerIds, input.CompanyIds, input.AppliedRegionType, input.RegionIds, input.GroupIds, ResolveLocationTypeInput(input), input.LocationIds) : null; var entity = new FlTrainingFileDbEntity { Id = YitIdHelper.NextId().ToString(), CategoryId = categoryId, FileName = Path.GetFileName(input.File.FileName ?? storedName), FileUrl = BuildTrainingUrl(storedName), FileType = ResolveFileType(ext), FileSize = input.File.Length, OrderNum = input.OrderNum, AppliedPartnerType = scope?.AppliedPartnerType ?? AllScopeBindingHelper.ScopeAll, AppliedRegionType = scope?.AppliedRegionType ?? AllScopeBindingHelper.ScopeAll, AvailabilityType = scope?.AvailabilityType ?? AllScopeBindingHelper.ScopeAll, IsDeleted = false, CreationTime = now, CreatorId = currentUserId, LastModificationTime = now, LastModifierId = currentUserId, ConcurrencyStamp = YitIdHelper.NextId().ToString() }; await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync(); if (scope is not null) { await TrainingFileScopeHelper.SaveScopeAsync( _dbContext.SqlSugarClient, entity.Id, scope, currentUserId, now); } var display = await TrainingFileScopeHelper.BuildScopeDisplayAsync(_dbContext.SqlSugarClient, entity); return MapFileDto(entity, display); } /// /// 编辑培训文件元数据及适用范围 /// /// /// 示例请求: /// ```json /// { /// "fileName": "操作手册.pdf", /// "orderNum": 100, /// "appliedPartnerType": "SPECIFIED", /// "partnerIds": ["p1"], /// "appliedRegionType": "ALL", /// "availabilityType": "SPECIFIED", /// "locationIds": ["loc1"] /// } /// ``` /// /// 参数说明: /// - fileName / orderNum: 文件元数据 /// - appliedPartnerType / partnerIds / companyIds: Company 范围,Id 可含 ALL /// - appliedRegionType / regionIds / groupIds: Region 范围,Id 可含 ALL /// - availabilityType 或 appliedLocationType / locationIds: Location 范围,Id 可含 ALL /// /// 文件Id /// 文件元数据 /// 更新后的文件 /// 更新成功 /// 文件不存在 /// 服务器错误 public async Task UpdateFileAsync(string id, TrainingFileUpdateInputVo input) { var entity = await GetFileOrThrowAsync(id); var fileName = input.FileName?.Trim(); if (string.IsNullOrWhiteSpace(fileName)) { throw new UserFriendlyException("文件名称不能为空"); } entity.FileName = fileName; entity.OrderNum = input.OrderNum; entity.LastModificationTime = DateTime.Now; entity.LastModifierId = CurrentUser?.Id?.ToString(); await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync(); if (HasScopeInput(input)) { entity = await ApplyFileScopeAsync(entity, input); } var display = await TrainingFileScopeHelper.BuildScopeDisplayAsync(_dbContext.SqlSugarClient, entity); return MapFileDto(entity, display); } /// /// 删除培训文件(软删) /// /// 文件Id /// 删除成功 /// 文件不存在 /// 服务器错误 public async Task DeleteFileAsync(string id) { var entity = await GetFileOrThrowAsync(id); await TrainingFileScopeHelper.DeleteScopeRowsAsync(_dbContext.SqlSugarClient, entity.Id); entity.IsDeleted = true; entity.LastModificationTime = DateTime.Now; entity.LastModifierId = CurrentUser?.Id?.ToString(); await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync(); } /// /// 批量更新培训文件排序 /// /// /// 示例请求: /// ```json /// { /// "items": [ /// { "id": "123", "orderNum": 100 }, /// { "id": "456", "orderNum": 90 } /// ] /// } /// ``` /// /// 排序项 /// 排序成功 /// 存在无效文件Id /// 服务器错误 public async Task SortFilesAsync(TrainingFileSortInputVo input) { if (input.Items is null || input.Items.Count == 0) { return; } var ids = input.Items.Select(x => x.Id?.Trim()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast().ToList(); if (ids.Count == 0) { return; } var existing = await _dbContext.SqlSugarClient.Queryable() .Where(x => !x.IsDeleted && ids.Contains(x.Id)) .ToListAsync(); var map = existing.ToDictionary(x => x.Id, StringComparer.Ordinal); var now = DateTime.Now; var userId = CurrentUser?.Id?.ToString(); foreach (var item in input.Items) { if (string.IsNullOrWhiteSpace(item.Id) || !map.TryGetValue(item.Id.Trim(), out var entity)) { continue; } entity.OrderNum = item.OrderNum; entity.LastModificationTime = now; entity.LastModifierId = userId; } if (existing.Count > 0) { await _dbContext.SqlSugarClient.Updateable(existing).ExecuteCommandAsync(); } } /// /// 获取培训文件权限范围(兼容独立查询;主路径为 create/update 携带 scope) /// /// 文件Id /// 权限范围 /// 成功 /// 文件不存在 /// 服务器错误 [HttpGet] [Route("/api/app/training/file-scope/{id}")] public async Task GetFileScopeAsync(string id) { var entity = await GetFileOrThrowAsync(id); var display = await TrainingFileScopeHelper.BuildScopeDisplayAsync(_dbContext.SqlSugarClient, entity); return MapScopeOutput(display); } /// /// 设置培训文件权限范围(兼容独立编辑;主路径为 create/update 携带 scope) /// /// /// 示例请求: /// ```json /// { /// "appliedPartnerType": "SPECIFIED", /// "partnerIds": ["p1"], /// "appliedRegionType": "ALL", /// "availabilityType": "SPECIFIED", /// "locationIds": ["loc1"] /// } /// ``` /// /// 文件Id /// 权限范围 /// 更新后的权限范围 /// 设置成功 /// 参数无效 /// 服务器错误 [HttpPut] [Route("/api/app/training/file-scope/{id}")] public async Task SetFileScopeAsync(string id, TrainingFileScopeInputVo input) { var entity = await GetFileOrThrowAsync(id); entity = await ApplyFileScopeAsync(entity, input); var display = await TrainingFileScopeHelper.BuildScopeDisplayAsync(_dbContext.SqlSugarClient, entity); return MapScopeOutput(display); } private async Task ApplyFileScopeAsync( FlTrainingFileDbEntity entity, ITrainingFileScopeInput input) { var scope = await TrainingFileScopeHelper.ResolveScopeForSaveAsync( _dbContext.SqlSugarClient, input.AppliedPartnerType, input.PartnerIds, input.CompanyIds, input.AppliedRegionType, input.RegionIds, input.GroupIds, ResolveLocationTypeInput(input), input.LocationIds); entity.AppliedPartnerType = scope.AppliedPartnerType; entity.AppliedRegionType = scope.AppliedRegionType; entity.AvailabilityType = scope.AvailabilityType; entity.LastModificationTime = DateTime.Now; entity.LastModifierId = CurrentUser?.Id?.ToString(); await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync(); await TrainingFileScopeHelper.SaveScopeAsync( _dbContext.SqlSugarClient, entity.Id, scope, entity.LastModifierId, entity.LastModificationTime ?? DateTime.Now); return entity; } private static bool HasScopeInput(ITrainingFileScopeInput input) => !string.IsNullOrWhiteSpace(input.AppliedPartnerType) || input.PartnerIds is not null || input.CompanyIds is not null || !string.IsNullOrWhiteSpace(input.AppliedRegionType) || input.RegionIds is not null || input.GroupIds is not null || !string.IsNullOrWhiteSpace(input.AvailabilityType) || !string.IsNullOrWhiteSpace(input.AppliedLocationType) || input.LocationIds is not null; private static string? ResolveLocationTypeInput(ITrainingFileScopeInput input) => string.IsNullOrWhiteSpace(input.AvailabilityType) ? input.AppliedLocationType : input.AvailabilityType; private async Task GetFileOrThrowAsync(string id) { var entity = await _dbContext.SqlSugarClient.Queryable() .FirstAsync(x => x.Id == id && !x.IsDeleted); if (entity is null) { throw new UserFriendlyException("培训文件不存在"); } return entity; } private async Task GetCategoryOrThrowAsync(string id) { var entity = await _dbContext.SqlSugarClient.Queryable() .FirstAsync(x => x.Id == id && !x.IsDeleted); if (entity is null) { throw new UserFriendlyException("分类不存在"); } return entity; } private static TrainingCategoryGetOutputDto MapCategoryOutput(FlTrainingCategoryDbEntity entity) => new() { Id = entity.Id, CategoryName = entity.CategoryName, ParentId = entity.ParentId, OrderNum = entity.OrderNum, CreationTime = entity.CreationTime, LastModificationTime = entity.LastModificationTime }; private static TrainingCategoryTreeNodeDto MapCategoryNode( FlTrainingCategoryDbEntity entity, List files, IReadOnlyDictionary scopeDisplayMap) => new() { Id = entity.Id, CategoryName = entity.CategoryName, ParentId = entity.ParentId, OrderNum = entity.OrderNum, Children = new List(), Files = files.Select(file => { scopeDisplayMap.TryGetValue(file.Id, out var display); return MapFileDto(file, display); }).ToList() }; private static TrainingFileDto MapFileDto( FlTrainingFileDbEntity entity, TrainingFileScopeHelper.TrainingFileScopeDisplay? display = null) => new() { Id = entity.Id, CategoryId = entity.CategoryId, FileName = entity.FileName, FileUrl = entity.FileUrl, FileType = entity.FileType, FileSize = entity.FileSize, OrderNum = entity.OrderNum, AppliedPartnerType = display?.AppliedPartnerType ?? entity.AppliedPartnerType, Company = display?.Company ?? string.Empty, PartnerIds = display?.PartnerIds ?? new List(), CompanyIds = display?.PartnerIds ?? new List(), AppliedRegionType = display?.AppliedRegionType ?? entity.AppliedRegionType, Region = display?.Region ?? string.Empty, RegionIds = display?.RegionIds ?? new List(), GroupIds = display?.RegionIds ?? new List(), AvailabilityType = display?.AvailabilityType ?? entity.AvailabilityType, Location = display?.Location ?? string.Empty, LocationIds = display?.LocationIds ?? new List(), CreationTime = entity.CreationTime, LastModificationTime = entity.LastModificationTime }; private static TrainingFileScopeOutputDto MapScopeOutput(TrainingFileScopeHelper.TrainingFileScopeDisplay display) => new() { AppliedPartnerType = display.AppliedPartnerType, Company = display.Company, PartnerIds = display.PartnerIds, CompanyIds = display.PartnerIds, AppliedRegionType = display.AppliedRegionType, Region = display.Region, RegionIds = display.RegionIds, GroupIds = display.RegionIds, AvailabilityType = display.AvailabilityType, Location = display.Location, LocationIds = display.LocationIds }; private string ResolveTrainingRoot() { var linuxRoot = "/www/wwwroot/FoodLabelingManagementSAAS/training"; var webRoot = Path.Combine(_hostEnvironment.ContentRootPath, "wwwroot", "FoodLabelingManagementSAAS", "training"); return Directory.Exists(linuxRoot) ? linuxRoot : webRoot; } private static string BuildTrainingUrl(string fileName) => $"/training/{fileName}"; private static string ResolveFileType(string ext) { if (ImageExtensions.Contains(ext)) { return TrainingFileType.Image.ToString().ToLowerInvariant(); } if (DocExtensions.Contains(ext)) { return TrainingFileType.Doc.ToString().ToLowerInvariant(); } return TrainingFileType.Other.ToString().ToLowerInvariant(); } }