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; /// /// 市民运动会平台报名数据对接服务。 /// public sealed class CitizenSportsPlatformService : ITransient { private readonly ISqlSugarRepository _eventRepository; private readonly ISqlSugarRepository _registrationRepository; private readonly ISqlSugarRepository _itemRepository; private readonly ISqlSugarRepository _personRepository; private readonly ISqlSugarRepository _userRepository; private readonly IHttpClientFactory _httpClientFactory; private readonly IOptionsMonitor _optionsMonitor; private readonly ILogger _logger; public CitizenSportsPlatformService( ISqlSugarRepository eventRepository, ISqlSugarRepository registrationRepository, ISqlSugarRepository itemRepository, ISqlSugarRepository personRepository, ISqlSugarRepository userRepository, IHttpClientFactory httpClientFactory, IOptionsMonitor optionsMonitor, ILogger logger) { _eventRepository = eventRepository; _registrationRepository = registrationRepository; _itemRepository = itemRepository; _personRepository = personRepository; _userRepository = userRepository; _httpClientFactory = httpClientFactory; _optionsMonitor = optionsMonitor; _logger = logger; } public async Task TestConnectionAsync(CancellationToken cancellationToken) { var response = await PostAsync("match-upload/test", new { }, cancellationToken); return new CitizenSportsConnectionTestOutput { Success = response.IsSuccess, Message = response.EffectiveMessage, PlatformTime = response.SystemTime }; } public async Task PreviewAsync(CitizenSportsSyncInput input) { var prepared = await PrepareAsync(input); return ToPreview(prepared); } public async Task 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( "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( "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 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 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>(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(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> PostAsync( 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>(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, DeleteStatus = player.DeleteStatus })).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 Groups { get; set; } = []; public List Errors { get; set; } = []; } private sealed class PreparedCitizenSportsGroup { public string TypeName { get; set; } = string.Empty; public List Players { get; set; } = []; } }