admin/src/Oms.Application/Services/Channels/KpiAreaService.cs
zzh bb8c0a906c
All checks were successful
Build and Deploy / deploy (push) Successful in 1m0s
feat: 卖价/规则发布钉 KPI 分区包,快照只存引用
分区明细独立版本化并缓存加载,计费包缺失严格失败;同步态接口不再拉全文。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 16:42:42 +08:00

454 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}