YD_Event.Admin/YD_Event.Application/CitizenSports/CitizenSportsPlatformService.cs

424 lines
18 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.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using YD_Event.Application.CitizenSports.Dtos;
using YD_Event.Core.Options;
namespace YD_Event.Application.CitizenSports;
/// <summary>
/// 市民运动会平台报名数据对接服务。
/// </summary>
public sealed class CitizenSportsPlatformService : ITransient
{
private readonly ISqlSugarRepository<EventInfo> _eventRepository;
private readonly ISqlSugarRepository<EventRegistration> _registrationRepository;
private readonly ISqlSugarRepository<EventRegistrationItem> _itemRepository;
private readonly ISqlSugarRepository<EventRegistrationPerson> _personRepository;
private readonly ISqlSugarRepository<YD_Event.Core.Entities.AppUser> _userRepository;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IOptionsMonitor<CitizenSportsPlatformOptions> _optionsMonitor;
private readonly ILogger<CitizenSportsPlatformService> _logger;
public CitizenSportsPlatformService(
ISqlSugarRepository<EventInfo> eventRepository,
ISqlSugarRepository<EventRegistration> registrationRepository,
ISqlSugarRepository<EventRegistrationItem> itemRepository,
ISqlSugarRepository<EventRegistrationPerson> personRepository,
ISqlSugarRepository<YD_Event.Core.Entities.AppUser> userRepository,
IHttpClientFactory httpClientFactory,
IOptionsMonitor<CitizenSportsPlatformOptions> optionsMonitor,
ILogger<CitizenSportsPlatformService> logger)
{
_eventRepository = eventRepository;
_registrationRepository = registrationRepository;
_itemRepository = itemRepository;
_personRepository = personRepository;
_userRepository = userRepository;
_httpClientFactory = httpClientFactory;
_optionsMonitor = optionsMonitor;
_logger = logger;
}
public async Task<CitizenSportsConnectionTestOutput> TestConnectionAsync(CancellationToken cancellationToken)
{
var response = await PostAsync<CitizenSportsTestData>("match-upload/test", new { }, cancellationToken);
return new CitizenSportsConnectionTestOutput
{
Success = response.IsSuccess,
Message = response.EffectiveMessage,
PlatformTime = response.SystemTime
};
}
public async Task<CitizenSportsPreviewOutput> PreviewAsync(CitizenSportsSyncInput input)
{
var prepared = await PrepareAsync(input);
return ToPreview(prepared);
}
public async Task<CitizenSportsExecuteOutput> ExecuteAsync(
CitizenSportsExecuteInput input,
CancellationToken cancellationToken)
{
if (!input.ConfirmUpload)
throw Oops.Bah("请先预览数据,并明确确认上传");
var prepared = await PrepareAsync(input);
if (prepared.Errors.Count > 0)
throw Oops.Bah($"同步预检发现 {prepared.Errors.Count} 条错误,请先修正报名资料");
if (prepared.Groups.Count == 0)
throw Oops.Bah("当前赛事没有可上传的已生效运动员报名数据");
var getCodeResponse = await PostAsync<CitizenSportsEventCodeData>(
"match-upload/getcode",
new CitizenSportsGetCodeRequest
{
EventName = prepared.PlatformEventName,
SeriesName = prepared.SeriesName
},
cancellationToken);
if (!getCodeResponse.IsSuccess || string.IsNullOrWhiteSpace(getCodeResponse.Data?.EventCode))
throw Oops.Bah($"获取市民运动会赛事编码失败:{getCodeResponse.EffectiveMessage}");
var options = GetValidatedOptions();
var output = new CitizenSportsExecuteOutput
{
PlatformEventCode = getCodeResponse.Data.EventCode,
RequestedCount = prepared.Groups.Sum(x => x.Players.Count)
};
foreach (var group in prepared.Groups)
{
foreach (var players in group.Players.Chunk(options.BatchSize))
{
var batchPlayers = players.ToList();
CitizenSportsBatchResult batchResult;
try
{
var response = await PostAsync<CitizenSportsUploadData>(
"match-upload/player",
new CitizenSportsUploadRequest
{
EventCode = output.PlatformEventCode,
TypeName = group.TypeName,
Players = batchPlayers
},
cancellationToken);
var countsMatch = response.Data != null
&& response.Data.SuccessCount + response.Data.FailureCount == batchPlayers.Count;
batchResult = new CitizenSportsBatchResult
{
TypeName = group.TypeName,
RequestedCount = batchPlayers.Count,
SuccessCount = response.Data?.SuccessCount ?? 0,
FailureCount = response.Data?.FailureCount ?? batchPlayers.Count,
Success = response.IsSuccess && countsMatch && response.Data!.FailureCount == 0,
Message = response.IsSuccess && !countsMatch
? "平台返回的导入统计与请求数量不一致"
: response.EffectiveMessage
};
}
catch (Exception exception) when (
exception is HttpRequestException
|| (exception is TaskCanceledException && !cancellationToken.IsCancellationRequested))
{
_logger.LogWarning(exception, "市民运动会平台上传中断,组别 {TypeName},批次数量 {Count}",
group.TypeName, batchPlayers.Count);
batchResult = new CitizenSportsBatchResult
{
TypeName = group.TypeName,
RequestedCount = batchPlayers.Count,
FailureCount = batchPlayers.Count,
Success = false,
Message = exception is TaskCanceledException ? "平台响应超时" : "平台连接失败"
};
output.Batches.Add(batchResult);
output.Aborted = true;
UpdateTotals(output);
return output;
}
output.Batches.Add(batchResult);
if (!batchResult.Success)
{
output.Aborted = true;
UpdateTotals(output);
return output;
}
}
}
UpdateTotals(output);
return output;
}
private async Task<PreparedCitizenSportsData> PrepareAsync(CitizenSportsSyncInput input)
{
if (input.EventId <= 0)
throw Oops.Bah("赛事 ID 无效");
if (string.IsNullOrWhiteSpace(input.PlatformEventName))
throw Oops.Bah("市民运动会平台赛事名称不能为空");
var eventInfo = await _eventRepository.GetFirstAsync(x => x.Id == input.EventId && !x.DeleteMark);
if (eventInfo == null)
throw Oops.Bah("赛事不存在");
var registrations = await _registrationRepository.AsQueryable()
.Where(x => x.EventId == input.EventId && x.PaymentStatus == 1 && !x.DeleteMark)
.ToListAsync();
var prepared = new PreparedCitizenSportsData
{
EventId = eventInfo.Id,
EventName = eventInfo.EventName,
PlatformEventName = input.PlatformEventName.Trim(),
SeriesName = string.IsNullOrWhiteSpace(input.SeriesName) ? null : input.SeriesName.Trim(),
RegistrationCount = registrations.Count
};
if (registrations.Count == 0)
return prepared;
var registrationIds = registrations.Select(x => x.Id).ToList();
var registrationMap = registrations.ToDictionary(x => x.Id);
var items = await _itemRepository.AsQueryable()
.Where(x => registrationIds.Contains(x.RegistrationId) && !x.DeleteMark)
.ToListAsync();
var itemMap = items.ToDictionary(x => x.Id);
var persons = await _personRepository.AsQueryable()
.Where(x => registrationIds.Contains(x.RegistrationId)
&& x.PersonType == PersonType.Athlete
&& !x.DeleteMark)
.ToListAsync();
var userIds = persons.Select(x => x.UserId).Distinct().ToList();
List<YD_Event.Core.Entities.AppUser> users = userIds.Count == 0
? []
: await _userRepository.AsQueryable()
.Where(x => userIds.Contains(x.Id) && !x.DeleteMark)
.ToListAsync();
var userMap = users.ToDictionary(x => x.Id);
var playersByType = new Dictionary<string, Dictionary<string, CitizenSportsPlayer>>(StringComparer.Ordinal);
foreach (var person in persons)
{
if (!registrationMap.TryGetValue(person.RegistrationId, out var registration))
{
AddError(prepared, person.Id, "找不到人员所属报名记录");
continue;
}
if (!itemMap.TryGetValue(person.RegistrationItemId, out var item))
{
AddError(prepared, person.Id, "找不到人员所属报名项目");
continue;
}
if (!userMap.TryGetValue(person.UserId, out var user))
{
AddError(prepared, person.Id, "找不到人员实名账号");
continue;
}
var typeName = item.GroupName?.Trim();
if (string.IsNullOrWhiteSpace(typeName))
{
AddError(prepared, person.Id, "报名项目缺少赛事组别名称");
continue;
}
if (string.IsNullOrWhiteSpace(person.Name))
{
AddError(prepared, person.Id, "运动员姓名为空");
continue;
}
var cardType = MapCardType(user.AuthType);
if (cardType == null)
{
AddError(prepared, person.Id, "运动员认证类型不受支持");
continue;
}
var cardNumber = GetCardNumber(person, user);
if (string.IsNullOrWhiteSpace(cardNumber))
{
AddError(prepared, person.Id, "运动员证件号码为空");
continue;
}
var player = new CitizenSportsPlayer
{
PlayerId = person.UserId.ToString(),
CardNumber = cardNumber.Trim(),
CardType = cardType,
PlayerName = person.Name.Trim(),
PlayerSex = MapGender(person.Gender),
PlayerPhone = string.IsNullOrWhiteSpace(person.Phone) ? null : person.Phone.Trim(),
PlayerBirth = person.BirthDate == default ? null : person.BirthDate.ToString("yyyy-MM-dd"),
GroupName = string.IsNullOrWhiteSpace(item.TeamName)
? registration.TeamName?.Trim()
: item.TeamName.Trim()
};
if (!playersByType.TryGetValue(typeName, out var typePlayers))
{
typePlayers = new Dictionary<string, CitizenSportsPlayer>(StringComparer.Ordinal);
playersByType[typeName] = typePlayers;
}
var uniqueKey = $"{player.PlayerId}|{player.CardNumber}";
typePlayers.TryAdd(uniqueKey, player);
}
prepared.Groups = playersByType
.OrderBy(x => x.Key)
.Select(x => new PreparedCitizenSportsGroup
{
TypeName = x.Key,
Players = x.Value.Values.ToList()
})
.ToList();
return prepared;
}
private async Task<CitizenSportsPlatformResponse<T>> PostAsync<T>(
string relativePath,
object body,
CancellationToken cancellationToken)
{
var options = GetValidatedOptions();
var baseUri = new Uri(options.BaseUrl.TrimEnd('/') + "/", UriKind.Absolute);
var requestUri = new Uri(baseUri, relativePath.TrimStart('/'));
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
using var request = new HttpRequestMessage(HttpMethod.Post, requestUri);
request.Headers.TryAddWithoutValidation("clientid", options.ClientId);
request.Headers.TryAddWithoutValidation("sign", CitizenSportsSigner.Create(options.ClientId, options.SecretKey, timestamp));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Content = new StringContent(
JsonConvert.SerializeObject(body, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }),
Encoding.UTF8,
"application/json");
var client = _httpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds);
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"市民运动会平台返回 HTTP {(int)response.StatusCode}");
try
{
return JsonConvert.DeserializeObject<CitizenSportsPlatformResponse<T>>(responseText)
?? throw new JsonException("响应为空");
}
catch (JsonException exception)
{
_logger.LogWarning(exception, "市民运动会平台返回了无法解析的响应,HTTP {StatusCode}", (int)response.StatusCode);
throw Oops.Bah("市民运动会平台响应格式不正确");
}
}
private CitizenSportsPlatformOptions GetValidatedOptions()
{
var options = _optionsMonitor.CurrentValue;
if (!options.Enabled)
throw Oops.Bah("市民运动会平台调用开关未开启");
if (!Uri.TryCreate(options.BaseUrl, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp))
throw Oops.Bah("市民运动会平台 BaseUrl 配置无效");
if (string.IsNullOrWhiteSpace(options.ClientId))
throw Oops.Bah("市民运动会平台 ClientId 未配置");
if (string.IsNullOrWhiteSpace(options.SecretKey))
throw Oops.Bah("市民运动会平台 SecretKey 未配置");
if (Encoding.UTF8.GetByteCount(options.SecretKey) != 32)
throw Oops.Bah("市民运动会平台 SecretKey 必须是 32 字节");
if (options.TimeoutSeconds is < 1 or > 120)
throw Oops.Bah("市民运动会平台 TimeoutSeconds 必须在 1-120 之间");
if (options.BatchSize is < 1 or > 1000)
throw Oops.Bah("市民运动会平台 BatchSize 必须在 1-1000 之间");
return options;
}
private static CitizenSportsPreviewOutput ToPreview(PreparedCitizenSportsData prepared) => new()
{
EventId = prepared.EventId,
EventName = prepared.EventName,
PlatformEventName = prepared.PlatformEventName,
RegistrationCount = prepared.RegistrationCount,
UploadRecordCount = prepared.Groups.Sum(x => x.Players.Count),
TypeCount = prepared.Groups.Count,
Types = prepared.Groups.Select(x => new CitizenSportsTypePreview
{
TypeName = x.TypeName,
PlayerCount = x.Players.Count
}).ToList(),
Players = prepared.Groups.SelectMany(group => group.Players.Select(player => new CitizenSportsPlayerPreview
{
TypeName = group.TypeName,
PlayerId = player.PlayerId,
CardNumber = player.CardNumber,
CardType = player.CardType,
PlayerName = player.PlayerName,
PlayerSex = player.PlayerSex,
PlayerPhone = player.PlayerPhone,
PlayerBirth = player.PlayerBirth,
GroupName = player.GroupName
})).ToList(),
Errors = prepared.Errors
};
private static void UpdateTotals(CitizenSportsExecuteOutput output)
{
output.SuccessCount = output.Batches.Sum(x => x.SuccessCount);
output.FailureCount = output.Batches.Sum(x => x.FailureCount);
output.UnattemptedCount = Math.Max(0, output.RequestedCount - output.SuccessCount - output.FailureCount);
}
private static void AddError(PreparedCitizenSportsData prepared, long personId, string message) =>
prepared.Errors.Add(new CitizenSportsValidationError
{
RegistrationPersonId = personId,
Message = message
});
private static string? MapCardType(int authType) => authType switch
{
1 => "1",
2 => "2",
3 => "5",
_ => null
};
private static string? MapGender(Gender gender) => gender switch
{
Gender.Male => "1",
Gender.Female => "2",
_ => null
};
private static string GetCardNumber(EventRegistrationPerson person, YD_Event.Core.Entities.AppUser user)
{
if (!string.IsNullOrWhiteSpace(person.IdCard))
return person.IdCard;
return user.AuthType == 2 ? user.PassportNumber ?? string.Empty : user.IdCardNumber ?? string.Empty;
}
private sealed class PreparedCitizenSportsData
{
public long EventId { get; set; }
public string EventName { get; set; } = string.Empty;
public string PlatformEventName { get; set; } = string.Empty;
public string? SeriesName { get; set; }
public int RegistrationCount { get; set; }
public List<PreparedCitizenSportsGroup> Groups { get; set; } = [];
public List<CitizenSportsValidationError> Errors { get; set; } = [];
}
private sealed class PreparedCitizenSportsGroup
{
public string TypeName { get; set; } = string.Empty;
public List<CitizenSportsPlayer> Players { get; set; } = [];
}
}