All checks were successful
Build and Deploy / deploy (push) Successful in 1m0s
分区明细独立版本化并缓存加载,计费包缺失严格失败;同步态接口不再拉全文。 Co-authored-by: Cursor <cursoragent@cursor.com>
454 lines
16 KiB
C#
454 lines
16 KiB
C#
using System.Linq.Expressions;
|
||
using Fake.Application;
|
||
using Microsoft.AspNetCore.Http;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using Oms.Application.Dtos.Channels;
|
||
using Oms.Application.Dtos.Shared;
|
||
using Oms.Application.Shared;
|
||
using Oms.Domain.ChannelsAggregate.Entities;
|
||
using Oms.Domain.ChannelsAggregate.Repositories;
|
||
using Oms.Domain.Shared;
|
||
using Oms.Domain.SystemAggregate.Repositories;
|
||
|
||
namespace Oms.Application.Services.Channels;
|
||
|
||
/// <summary>
|
||
/// KPI 分区 API 服务。
|
||
/// </summary>
|
||
[ApiExplorerSettings(GroupName = "OMS-Channel")]
|
||
public class KpiAreaService : ApplicationService
|
||
{
|
||
private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService<IKpiAreaRepository>();
|
||
|
||
private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService<ICountryRepository>();
|
||
|
||
/// <summary>
|
||
/// 查询KPI分区列表。
|
||
/// </summary>
|
||
/// <param name="input">入参:KPI分区列表查询条件。</param>
|
||
/// <returns>KPI分区分页结果,包含列表数据和总记录数。</returns>
|
||
[HttpPost("/channel/kpi-area/list")]
|
||
public async Task<OmsPageResult<KpiAreaOutput>> GetListAsync(KpiAreaListInput input)
|
||
{
|
||
var predicate = BuildAreaPredicate(input);
|
||
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
|
||
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
|
||
var entities = await KpiAreaRepository.GetPagedListAsync(
|
||
predicate,
|
||
pageIndex,
|
||
pageSize,
|
||
new Dictionary<string, bool> { [nameof(KpiArea.UpdateTime)] = false, [nameof(KpiArea.Id)] = false });
|
||
var total = await KpiAreaRepository.CountAsync(predicate);
|
||
return new OmsPageResult<KpiAreaOutput>
|
||
{
|
||
Items = entities.Select(MapAreaOutput).ToList(),
|
||
Total = total
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 新增KPI分区。
|
||
/// </summary>
|
||
/// <param name="input">入参:KPI分区保存参数。</param>
|
||
/// <returns>无返回体,仅完成新增KPI分区。</returns>
|
||
[HttpPost("/channel/kpi-area/create")]
|
||
public async Task CreateAsync(KpiAreaSaveInput input)
|
||
{
|
||
await ValidateAreaNameAsync(input.AppKey, input.AreaType, input.Name, null);
|
||
var entity = new KpiArea
|
||
{
|
||
AppKey = input.AppKey,
|
||
AreaType = input.AreaType,
|
||
Name = input.Name,
|
||
SystemName = input.SystemName
|
||
};
|
||
await KpiAreaRepository.InsertAsync(entity);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 更新KPI分区。
|
||
/// </summary>
|
||
/// <param name="input">入参:KPI分区保存参数。</param>
|
||
/// <returns>无返回体,仅完成更新KPI分区。</returns>
|
||
[HttpPost("/channel/kpi-area/update")]
|
||
public async Task UpdateAsync(KpiAreaSaveInput input)
|
||
{
|
||
var entity = await GetAreaOrThrowAsync(input.Id);
|
||
await ValidateAreaNameAsync(input.AppKey, input.AreaType, input.Name, entity.Id);
|
||
entity.AppKey = input.AppKey;
|
||
entity.AreaType = input.AreaType;
|
||
entity.Name = input.Name;
|
||
entity.SystemName = input.SystemName;
|
||
await KpiAreaRepository.UpdateAsync(entity);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 删除KPI分区。
|
||
/// </summary>
|
||
/// <param name="input">入参:目标主键。</param>
|
||
/// <returns>无返回体,仅完成删除KPI分区。</returns>
|
||
[HttpPost("/channel/kpi-area/delete")]
|
||
public async Task DeleteAsync(IdInput input)
|
||
{
|
||
var entity = await GetAreaOrThrowAsync(input.Id);
|
||
await KpiAreaRepository.DeleteAsync(entity);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 查询KPI分区明细列表。
|
||
/// </summary>
|
||
/// <param name="input">入参:KPI分区明细列表查询条件。</param>
|
||
/// <returns>KPI分区明细分页结果,包含列表数据和总记录数。</returns>
|
||
[HttpPost("/channel/kpi-area/info/list")]
|
||
public async Task<OmsPageResult<KpiAreaInfoOutput>> GetInfoListAsync(KpiAreaInfoListInput input)
|
||
{
|
||
await GetAreaOrThrowAsync(input.AreaId);
|
||
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
|
||
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
|
||
var entities = await KpiAreaRepository.GetInfosAsync(input.AreaId, pageIndex, pageSize);
|
||
var total = await KpiAreaRepository.CountInfosAsync(input.AreaId);
|
||
return new OmsPageResult<KpiAreaInfoOutput>
|
||
{
|
||
Items = entities.Select(MapInfoOutput).ToList(),
|
||
Total = total
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 新增KPI分区明细。
|
||
/// </summary>
|
||
/// <param name="input">入参:KPI分区明细保存参数。</param>
|
||
/// <returns>无返回体,仅完成新增KPI分区明细。</returns>
|
||
[HttpPost("/channel/kpi-area/info/add")]
|
||
public async Task AddInfoAsync(KpiAreaInfoSaveInput input)
|
||
{
|
||
await GetAreaOrThrowAsync(input.AreaId);
|
||
var entity = new KpiAreaInfo
|
||
{
|
||
AreaId = input.AreaId,
|
||
CountryCode = input.CountryCode,
|
||
CountryName = input.CountryName,
|
||
Province = input.Province,
|
||
City = input.City,
|
||
LeftPostcode = input.LeftPostcode,
|
||
RightPostcode = input.RightPostcode,
|
||
MatchDim = input.MatchDim,
|
||
MatchMode = input.MatchMode,
|
||
PrefixLen = input.PrefixLen ?? 0
|
||
};
|
||
PrepareAreaInfo(entity);
|
||
await EnsureInfoNotDuplicateAsync(entity, excludeId: null);
|
||
await KpiAreaRepository.InsertInfoAsync(entity);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 更新KPI分区明细。
|
||
/// </summary>
|
||
/// <param name="input">入参:KPI分区明细保存参数。</param>
|
||
/// <returns>无返回体,仅完成更新KPI分区明细。</returns>
|
||
[HttpPost("/channel/kpi-area/info/update")]
|
||
public async Task UpdateInfoAsync(KpiAreaInfoSaveInput input)
|
||
{
|
||
var entity = await GetAreaInfoOrThrowAsync(input.Id);
|
||
await GetAreaOrThrowAsync(input.AreaId);
|
||
entity.AreaId = input.AreaId;
|
||
entity.CountryCode = input.CountryCode;
|
||
entity.CountryName = input.CountryName;
|
||
entity.Province = input.Province;
|
||
entity.City = input.City;
|
||
entity.LeftPostcode = input.LeftPostcode;
|
||
entity.RightPostcode = input.RightPostcode;
|
||
entity.MatchDim = input.MatchDim;
|
||
entity.MatchMode = input.MatchMode;
|
||
entity.PrefixLen = input.PrefixLen ?? 0;
|
||
PrepareAreaInfo(entity);
|
||
await EnsureInfoNotDuplicateAsync(entity, excludeId: entity.Id);
|
||
await KpiAreaRepository.UpdateInfoAsync(entity);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 删除KPI分区明细。
|
||
/// </summary>
|
||
/// <param name="input">入参:目标主键。</param>
|
||
/// <returns>无返回体,仅完成删除KPI分区明细。</returns>
|
||
[HttpPost("/channel/kpi-area/info/delete")]
|
||
public async Task DeleteInfoAsync(IdInput input)
|
||
{
|
||
await GetAreaInfoOrThrowAsync(input.Id);
|
||
await KpiAreaRepository.DeleteInfoAsync(input.Id);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 下载 KPI 分区明细导入模板。
|
||
/// </summary>
|
||
[HttpGet("/channel/kpi-area/info/import-template")]
|
||
public async Task<FileContentResult> DownloadInfoImportTemplateAsync([FromQuery] string? areaType)
|
||
{
|
||
var bytes = await KpiAreaInfoImportHelper.BuildTemplateBytesAsync();
|
||
return new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||
{
|
||
FileDownloadName = KpiAreaInfoImportHelper.ResolveTemplateFileName(areaType)
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 导入 KPI 分区明细(Excel)。
|
||
/// </summary>
|
||
[HttpPost("/channel/kpi-area/info/import")]
|
||
public async Task<KpiAreaInfoImportOutput> ImportInfoAsync([FromForm] KpiAreaInfoImportInput input)
|
||
{
|
||
if (input.File == null || input.File.Length == 0)
|
||
{
|
||
throw new BusinessException("请上传 Excel 文件");
|
||
}
|
||
|
||
await GetAreaOrThrowAsync(input.AreaId);
|
||
|
||
await using var stream = input.File.OpenReadStream();
|
||
var parsedRows = await KpiAreaInfoImportHelper.ParseImportRowsAsync(stream);
|
||
if (parsedRows.Count == 0)
|
||
{
|
||
throw new BusinessException("Excel 中无有效数据行");
|
||
}
|
||
|
||
var countries = await CountryRepository.GetListAsync();
|
||
var countriesByCode = countries
|
||
.Where(x => !string.IsNullOrWhiteSpace(x.CountryCode))
|
||
.GroupBy(x => x.CountryCode.Trim(), StringComparer.OrdinalIgnoreCase)
|
||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||
|
||
var existingKeys = new HashSet<string>(StringComparer.Ordinal);
|
||
foreach (var item in await KpiAreaRepository.GetAllInfosByAreaIdAsync(input.AreaId))
|
||
{
|
||
TryAddDuplicateKey(existingKeys, item);
|
||
}
|
||
|
||
var output = new KpiAreaInfoImportOutput();
|
||
foreach (var (rowNumber, entity) in parsedRows)
|
||
{
|
||
try
|
||
{
|
||
KpiAreaInfoImportHelper.PrepareEntityForImport(entity, countriesByCode);
|
||
entity.AreaId = input.AreaId;
|
||
if (!TryAddDuplicateKey(existingKeys, entity))
|
||
{
|
||
output.SkipCount++;
|
||
output.Failures.Add(new KpiAreaInfoImportFailRow
|
||
{
|
||
RowNumber = rowNumber,
|
||
Reason = "与已有规则重复,已跳过"
|
||
});
|
||
continue;
|
||
}
|
||
|
||
await KpiAreaRepository.InsertInfoAsync(entity);
|
||
output.SuccessCount++;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
output.FailCount++;
|
||
output.Failures.Add(new KpiAreaInfoImportFailRow
|
||
{
|
||
RowNumber = rowNumber,
|
||
Reason = ex.Message
|
||
});
|
||
}
|
||
}
|
||
|
||
return output;
|
||
}
|
||
|
||
private static Expression<Func<KpiArea, bool>> BuildAreaPredicate(KpiAreaListInput input)
|
||
{
|
||
var appKey = input.AppKey?.Trim();
|
||
var areaType = input.AreaType?.Trim();
|
||
return entity =>
|
||
(string.IsNullOrWhiteSpace(appKey) || entity.AppKey == appKey) &&
|
||
(string.IsNullOrWhiteSpace(areaType) || entity.AreaType == areaType);
|
||
}
|
||
|
||
private async Task ValidateAreaNameAsync(string appKey, string areaType, string name, int? id)
|
||
{
|
||
var existed = await KpiAreaRepository.GetByAppKeyTypeAndNameAsync(appKey, areaType, name);
|
||
if (existed != null && existed.Id != id)
|
||
{
|
||
throw new BusinessException("分区名称已存在");
|
||
}
|
||
}
|
||
|
||
private async Task<KpiArea> GetAreaOrThrowAsync(int? id)
|
||
{
|
||
if (!id.HasValue || id.Value <= 0)
|
||
{
|
||
throw new BusinessException("分区不存在");
|
||
}
|
||
|
||
var entity = await KpiAreaRepository.FirstOrDefaultAsync(x => x.Id == id.Value);
|
||
if (entity == null)
|
||
{
|
||
throw new BusinessException("分区不存在");
|
||
}
|
||
|
||
return entity;
|
||
}
|
||
|
||
private async Task<KpiAreaInfo> GetAreaInfoOrThrowAsync(int? id)
|
||
{
|
||
if (!id.HasValue || id.Value <= 0)
|
||
{
|
||
throw new BusinessException("分区明细不存在");
|
||
}
|
||
|
||
var entity = await KpiAreaRepository.GetInfoByIdAsync(id.Value);
|
||
if (entity == null)
|
||
{
|
||
throw new BusinessException("分区明细不存在");
|
||
}
|
||
|
||
return entity;
|
||
}
|
||
|
||
private static KpiAreaOutput MapAreaOutput(KpiArea entity)
|
||
{
|
||
return new KpiAreaOutput
|
||
{
|
||
Id = entity.Id,
|
||
AppKey = entity.AppKey,
|
||
AreaType = entity.AreaType,
|
||
Name = entity.Name,
|
||
SystemName = entity.SystemName,
|
||
CreateTime = entity.CreateTime,
|
||
UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime)
|
||
};
|
||
}
|
||
|
||
private static void PrepareAreaInfo(KpiAreaInfo entity)
|
||
{
|
||
try
|
||
{
|
||
GeoPartitionMatcher.PrepareEntity(entity);
|
||
}
|
||
catch (InvalidOperationException ex)
|
||
{
|
||
throw new BusinessException(ex.Message);
|
||
}
|
||
}
|
||
|
||
private async Task EnsureInfoNotDuplicateAsync(KpiAreaInfo entity, int? excludeId)
|
||
{
|
||
var key = GeoPartitionMatcher.BuildDuplicateKey(entity);
|
||
var existing = await KpiAreaRepository.GetAllInfosByAreaIdAsync(entity.AreaId);
|
||
foreach (var item in existing)
|
||
{
|
||
if (excludeId.HasValue && item.Id == excludeId.Value)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (TryBuildDuplicateKey(item, out var existingKey)
|
||
&& string.Equals(existingKey, key, StringComparison.Ordinal))
|
||
{
|
||
throw new BusinessException("分区明细已存在相同规则");
|
||
}
|
||
}
|
||
}
|
||
|
||
private static bool TryAddDuplicateKey(HashSet<string> keys, KpiAreaInfo entity) =>
|
||
TryBuildDuplicateKey(entity, out var key) && keys.Add(key);
|
||
|
||
private static bool TryBuildDuplicateKey(KpiAreaInfo entity, out string key)
|
||
{
|
||
try
|
||
{
|
||
GeoPartitionMatcher.PrepareEntity(entity);
|
||
key = GeoPartitionMatcher.BuildDuplicateKey(entity);
|
||
return true;
|
||
}
|
||
catch (InvalidOperationException)
|
||
{
|
||
key = string.Empty;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private static KpiAreaInfoOutput MapInfoOutput(KpiAreaInfo entity)
|
||
{
|
||
var (matchDim, matchMode, prefixLen) = GeoPartitionMatcher.Describe(entity);
|
||
return new KpiAreaInfoOutput
|
||
{
|
||
Id = entity.Id,
|
||
AreaId = entity.AreaId,
|
||
CountryCode = entity.CountryCode,
|
||
CountryName = entity.CountryName,
|
||
Province = entity.Province,
|
||
City = entity.City,
|
||
LeftPostcode = entity.LeftPostcode,
|
||
RightPostcode = entity.RightPostcode,
|
||
MatchDim = matchDim,
|
||
MatchMode = matchMode,
|
||
PrefixLen = prefixLen,
|
||
CreateTime = entity.CreateTime,
|
||
UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime)
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 分区包版本列表(不含正文)。
|
||
/// </summary>
|
||
[HttpGet("/channel/kpi-area/publish/list")]
|
||
public async Task<List<KpiAreaPublishRecordOutput>> GetPublishListAsync([FromQuery] int areaId)
|
||
{
|
||
if (areaId <= 0)
|
||
{
|
||
throw new Fake.Application.BusinessException("分区表 Id 无效");
|
||
}
|
||
|
||
await GetAreaOrThrowAsync(areaId);
|
||
var headers = await KpiAreaRepository.GetPublishHeadersByAreaIdAsync(areaId);
|
||
return headers.Select(MapPublishHeader).ToList();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 单条分区包(含正文)。
|
||
/// </summary>
|
||
[HttpGet("/channel/kpi-area/publish/get")]
|
||
public async Task<KpiAreaPublishRecordOutput> GetPublishRecordAsync([FromQuery] int id)
|
||
{
|
||
var entity = await KpiAreaRepository.GetPublishByIdAsync(id)
|
||
?? throw new Fake.Application.BusinessException("分区包不存在");
|
||
return MapPublishDetail(entity);
|
||
}
|
||
|
||
private async Task<KpiArea> GetAreaOrThrowAsync(int areaId)
|
||
{
|
||
var area = await KpiAreaRepository.FirstOrDefaultAsync(x => x.Id == areaId);
|
||
if (area == null)
|
||
{
|
||
throw new Fake.Application.BusinessException("分区表不存在");
|
||
}
|
||
|
||
return area;
|
||
}
|
||
|
||
private static KpiAreaPublishRecordOutput MapPublishHeader(KpiAreaPublishRecord entity) =>
|
||
new()
|
||
{
|
||
Id = entity.Id,
|
||
AreaId = entity.AreaId,
|
||
AreaType = entity.AreaType,
|
||
Version = entity.Version,
|
||
Status = EnumDisplayNames.Of(entity.Status),
|
||
ContentHash = entity.ContentHash,
|
||
PublishUser = entity.PublishUser,
|
||
PublishTime = entity.PublishTime,
|
||
Description = entity.Description
|
||
};
|
||
|
||
private static KpiAreaPublishRecordOutput MapPublishDetail(KpiAreaPublishRecord entity)
|
||
{
|
||
var output = MapPublishHeader(entity);
|
||
output.Content = OmsDocHelper.DeserializeJsonObject(entity.Content);
|
||
return output;
|
||
}
|
||
}
|