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; } = [];
}
}