TrainingAppService.cs
29.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
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;
/// <summary>
/// 培训 / 资料中心(管理端)
/// </summary>
public class TrainingAppService : ApplicationService, ITrainingAppService
{
private const long MaxFileSizeBytes = 20 * 1024 * 1024;
private static readonly HashSet<string> ImageExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"
};
private static readonly HashSet<string> DocExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt", ".csv"
};
private static readonly HashSet<string> 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;
}
/// <summary>
/// 获取培训分类树(可选含文件;支持 keyword、locationId 筛选)
/// </summary>
/// <remarks>
/// 一级分类 ParentId 为空;二级分类 ParentId 指向一级。文件仅挂在二级分类下。
///
/// 示例请求:
/// ```json
/// {
/// "keyword": "安全",
/// "locationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
/// "includeFiles": true
/// }
/// ```
///
/// 参数说明:
/// - keyword: 匹配分类名或文件名
/// - locationId: 按门店权限过滤可见文件
/// - includeFiles: 是否返回文件列表
/// </remarks>
/// <param name="input">查询条件</param>
/// <returns>分类树</returns>
/// <response code="200">成功返回分类树</response>
/// <response code="400">参数无效</response>
/// <response code="500">服务器错误</response>
public async Task<List<TrainingCategoryTreeNodeDto>> 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<FlTrainingCategoryDbEntity>()
.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<string, List<FlTrainingFileDbEntity>>(StringComparer.Ordinal);
if (input.IncludeFiles && level2Ids.Count > 0)
{
var fileQuery = _dbContext.SqlSugarClient.Queryable<FlTrainingFileDbEntity>()
.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<TrainingCategoryTreeNodeDto>();
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<FlTrainingCategoryDbEntity>();
var childNodes = new List<TrainingCategoryTreeNodeDto>();
foreach (var l2 in children)
{
filesByCategory.TryGetValue(l2.Id, out var fileRows);
fileRows ??= new List<FlTrainingFileDbEntity>();
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<TrainingFileDto>()
});
}
return result;
}
/// <summary>
/// 新增培训分类(一级或二级)
/// </summary>
/// <remarks>
/// 示例请求:
/// ```json
/// {
/// "categoryName": "食品安全",
/// "parentId": null,
/// "orderNum": 100
/// }
/// ```
/// </remarks>
/// <param name="input">分类信息</param>
/// <returns>新建分类</returns>
/// <response code="200">创建成功</response>
/// <response code="400">参数无效或父级不存在</response>
/// <response code="500">服务器错误</response>
public async Task<TrainingCategoryGetOutputDto> 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<FlTrainingCategoryDbEntity>()
.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<FlTrainingCategoryDbEntity>()
.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);
}
/// <summary>
/// 编辑培训分类
/// </summary>
/// <remarks>
/// 示例请求:
/// ```json
/// {
/// "categoryName": "食品安全(更新)",
/// "orderNum": 90
/// }
/// ```
/// </remarks>
/// <param name="id">分类Id</param>
/// <param name="input">分类信息</param>
/// <returns>更新后的分类</returns>
/// <response code="200">更新成功</response>
/// <response code="400">分类不存在或名称重复</response>
/// <response code="500">服务器错误</response>
public async Task<TrainingCategoryGetOutputDto> 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<FlTrainingCategoryDbEntity>()
.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);
}
/// <summary>
/// 删除培训分类(软删)
/// </summary>
/// <remarks>
/// 一级分类存在二级子分类时不可删除;二级分类存在文件时不可删除。
/// </remarks>
/// <param name="id">分类Id</param>
/// <response code="200">删除成功</response>
/// <response code="400">存在子分类或文件</response>
/// <response code="500">服务器错误</response>
public async Task DeleteCategoryAsync(string id)
{
var entity = await GetCategoryOrThrowAsync(id);
if (string.IsNullOrWhiteSpace(entity.ParentId))
{
var hasChild = await _dbContext.SqlSugarClient.Queryable<FlTrainingCategoryDbEntity>()
.AnyAsync(x => !x.IsDeleted && x.ParentId == id);
if (hasChild)
{
throw new UserFriendlyException("该一级分类下存在二级分类,无法删除");
}
}
else
{
var hasFile = await _dbContext.SqlSugarClient.Queryable<FlTrainingFileDbEntity>()
.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();
}
/// <summary>
/// 上传培训文件到二级分类(可同时提交 Company / Region / Location 适用范围)
/// </summary>
/// <remarks>
/// 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
/// </remarks>
/// <param name="input">上传表单</param>
/// <returns>文件信息</returns>
/// <response code="200">上传成功</response>
/// <response code="400">文件无效或分类不是二级</response>
/// <response code="500">服务器错误</response>
[HttpPost]
[Consumes("multipart/form-data")]
[Route("/api/app/training/file/upload")]
public async Task<TrainingFileDto> 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);
}
/// <summary>
/// 编辑培训文件元数据及适用范围
/// </summary>
/// <remarks>
/// 示例请求:
/// ```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
/// </remarks>
/// <param name="id">文件Id</param>
/// <param name="input">文件元数据</param>
/// <returns>更新后的文件</returns>
/// <response code="200">更新成功</response>
/// <response code="400">文件不存在</response>
/// <response code="500">服务器错误</response>
public async Task<TrainingFileDto> 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);
}
/// <summary>
/// 删除培训文件(软删)
/// </summary>
/// <param name="id">文件Id</param>
/// <response code="200">删除成功</response>
/// <response code="400">文件不存在</response>
/// <response code="500">服务器错误</response>
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();
}
/// <summary>
/// 批量更新培训文件排序
/// </summary>
/// <remarks>
/// 示例请求:
/// ```json
/// {
/// "items": [
/// { "id": "123", "orderNum": 100 },
/// { "id": "456", "orderNum": 90 }
/// ]
/// }
/// ```
/// </remarks>
/// <param name="input">排序项</param>
/// <response code="200">排序成功</response>
/// <response code="400">存在无效文件Id</response>
/// <response code="500">服务器错误</response>
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<string>().ToList();
if (ids.Count == 0)
{
return;
}
var existing = await _dbContext.SqlSugarClient.Queryable<FlTrainingFileDbEntity>()
.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();
}
}
/// <summary>
/// 获取培训文件权限范围(兼容独立查询;主路径为 create/update 携带 scope)
/// </summary>
/// <param name="id">文件Id</param>
/// <returns>权限范围</returns>
/// <response code="200">成功</response>
/// <response code="400">文件不存在</response>
/// <response code="500">服务器错误</response>
[HttpGet]
[Route("/api/app/training/file-scope/{id}")]
public async Task<TrainingFileScopeOutputDto> GetFileScopeAsync(string id)
{
var entity = await GetFileOrThrowAsync(id);
var display = await TrainingFileScopeHelper.BuildScopeDisplayAsync(_dbContext.SqlSugarClient, entity);
return MapScopeOutput(display);
}
/// <summary>
/// 设置培训文件权限范围(兼容独立编辑;主路径为 create/update 携带 scope)
/// </summary>
/// <remarks>
/// 示例请求:
/// ```json
/// {
/// "appliedPartnerType": "SPECIFIED",
/// "partnerIds": ["p1"],
/// "appliedRegionType": "ALL",
/// "availabilityType": "SPECIFIED",
/// "locationIds": ["loc1"]
/// }
/// ```
/// </remarks>
/// <param name="id">文件Id</param>
/// <param name="input">权限范围</param>
/// <returns>更新后的权限范围</returns>
/// <response code="200">设置成功</response>
/// <response code="400">参数无效</response>
/// <response code="500">服务器错误</response>
[HttpPut]
[Route("/api/app/training/file-scope/{id}")]
public async Task<TrainingFileScopeOutputDto> 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<FlTrainingFileDbEntity> 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<FlTrainingFileDbEntity> GetFileOrThrowAsync(string id)
{
var entity = await _dbContext.SqlSugarClient.Queryable<FlTrainingFileDbEntity>()
.FirstAsync(x => x.Id == id && !x.IsDeleted);
if (entity is null)
{
throw new UserFriendlyException("培训文件不存在");
}
return entity;
}
private async Task<FlTrainingCategoryDbEntity> GetCategoryOrThrowAsync(string id)
{
var entity = await _dbContext.SqlSugarClient.Queryable<FlTrainingCategoryDbEntity>()
.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<FlTrainingFileDbEntity> files,
IReadOnlyDictionary<string, TrainingFileScopeHelper.TrainingFileScopeDisplay> scopeDisplayMap) =>
new()
{
Id = entity.Id,
CategoryName = entity.CategoryName,
ParentId = entity.ParentId,
OrderNum = entity.OrderNum,
Children = new List<TrainingCategoryTreeNodeDto>(),
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<string>(),
CompanyIds = display?.PartnerIds ?? new List<string>(),
AppliedRegionType = display?.AppliedRegionType ?? entity.AppliedRegionType,
Region = display?.Region ?? string.Empty,
RegionIds = display?.RegionIds ?? new List<string>(),
GroupIds = display?.RegionIds ?? new List<string>(),
AvailabilityType = display?.AvailabilityType ?? entity.AvailabilityType,
Location = display?.Location ?? string.Empty,
LocationIds = display?.LocationIds ?? new List<string>(),
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();
}
}