合并
This commit is contained in:
commit
8ea2d52c5f
@ -0,0 +1,411 @@
|
|||||||
|
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(),
|
||||||
|
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; } = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
25
YD_Event.Application/CitizenSports/CitizenSportsSigner.cs
Normal file
25
YD_Event.Application/CitizenSports/CitizenSportsSigner.cs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace YD_Event.Application.CitizenSports;
|
||||||
|
|
||||||
|
internal static class CitizenSportsSigner
|
||||||
|
{
|
||||||
|
internal static string Create(string clientId, string secretKey, long unixTimestamp)
|
||||||
|
{
|
||||||
|
var key = Encoding.UTF8.GetBytes(secretKey);
|
||||||
|
if (key.Length != 32)
|
||||||
|
throw new ArgumentException("SecretKey 必须是 32 字节", nameof(secretKey));
|
||||||
|
|
||||||
|
var plainText = Encoding.UTF8.GetBytes($"{clientId}_{unixTimestamp}");
|
||||||
|
using var aes = Aes.Create();
|
||||||
|
aes.Key = key;
|
||||||
|
aes.Mode = CipherMode.ECB;
|
||||||
|
aes.Padding = PaddingMode.PKCS7;
|
||||||
|
|
||||||
|
using var encryptor = aes.CreateEncryptor();
|
||||||
|
var encrypted = encryptor.TransformFinalBlock(plainText, 0, plainText.Length);
|
||||||
|
var base64 = Convert.ToBase64String(encrypted);
|
||||||
|
return Convert.ToHexString(Encoding.UTF8.GetBytes(base64)).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
using YD_Event.Application.Auth;
|
||||||
|
using YD_Event.Application.CitizenSports.Dtos;
|
||||||
|
|
||||||
|
namespace YD_Event.Application.CitizenSports;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 市民运动会报名数据同步。
|
||||||
|
/// </summary>
|
||||||
|
[ApiDescriptionSettings("报名管理")]
|
||||||
|
[Route("api/citizen-sports-sync")]
|
||||||
|
[Authorize]
|
||||||
|
public sealed class CitizenSportsSyncController : IDynamicApiController
|
||||||
|
{
|
||||||
|
private readonly CitizenSportsPlatformService _service;
|
||||||
|
private readonly AuthManager _authManager;
|
||||||
|
|
||||||
|
public CitizenSportsSyncController(
|
||||||
|
CitizenSportsPlatformService service,
|
||||||
|
AuthManager authManager)
|
||||||
|
{
|
||||||
|
_service = service;
|
||||||
|
_authManager = authManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 使用当前环境配置验证平台地址和签名。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("connection-test")]
|
||||||
|
[DisplayName("测试市民运动会平台连接")]
|
||||||
|
public async Task<CitizenSportsConnectionTestOutput> TestConnection(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
EnsureSuperAdministrator();
|
||||||
|
return await _service.TestConnectionAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 只读取本地报名数据,返回不含敏感信息的上传预览。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("preview")]
|
||||||
|
[DisplayName("预览市民运动会报名同步")]
|
||||||
|
public async Task<CitizenSportsPreviewOutput> Preview([FromBody] CitizenSportsSyncInput input)
|
||||||
|
{
|
||||||
|
EnsureSuperAdministrator();
|
||||||
|
return await _service.PreviewAsync(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将预检通过的报名人员上传到市民运动会平台。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("execute")]
|
||||||
|
[DisplayName("执行市民运动会报名同步")]
|
||||||
|
public async Task<CitizenSportsExecuteOutput> Execute(
|
||||||
|
[FromBody] CitizenSportsExecuteInput input,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
EnsureSuperAdministrator();
|
||||||
|
return await _service.ExecuteAsync(input, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureSuperAdministrator()
|
||||||
|
{
|
||||||
|
if (_authManager.AuthPlatformType != AuthPlatformType.Manager || !_authManager.IsSuperAdmin)
|
||||||
|
throw Oops.Bah("仅超级管理员可以同步市民运动会报名数据");
|
||||||
|
}
|
||||||
|
}
|
||||||
195
YD_Event.Application/CitizenSports/Dtos/CitizenSportsSyncDtos.cs
Normal file
195
YD_Event.Application/CitizenSports/Dtos/CitizenSportsSyncDtos.cs
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace YD_Event.Application.CitizenSports.Dtos;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 市民运动会报名同步请求。
|
||||||
|
/// </summary>
|
||||||
|
public class CitizenSportsSyncInput
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 悦动系统赛事 ID。
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public long EventId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 市民运动会平台中的赛事名称,必须与一网通办申报名称一致。
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public string PlatformEventName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 系列赛场次名称,没有时不传。
|
||||||
|
/// </summary>
|
||||||
|
public string? SeriesName { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 市民运动会报名执行同步请求。
|
||||||
|
/// </summary>
|
||||||
|
public class CitizenSportsExecuteInput : CitizenSportsSyncInput
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 必须明确确认后才允许向外部平台写入。
|
||||||
|
/// </summary>
|
||||||
|
public bool ConfirmUpload { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 同步预览结果,不返回证件号、手机号等敏感信息。
|
||||||
|
/// </summary>
|
||||||
|
public class CitizenSportsPreviewOutput
|
||||||
|
{
|
||||||
|
public long EventId { get; set; }
|
||||||
|
public string EventName { get; set; } = string.Empty;
|
||||||
|
public string PlatformEventName { get; set; } = string.Empty;
|
||||||
|
public int RegistrationCount { get; set; }
|
||||||
|
public int UploadRecordCount { get; set; }
|
||||||
|
public int TypeCount { get; set; }
|
||||||
|
public bool CanUpload => Errors.Count == 0 && UploadRecordCount > 0;
|
||||||
|
public List<CitizenSportsTypePreview> Types { get; set; } = [];
|
||||||
|
public List<CitizenSportsValidationError> Errors { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CitizenSportsTypePreview
|
||||||
|
{
|
||||||
|
public string TypeName { get; set; } = string.Empty;
|
||||||
|
public int PlayerCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CitizenSportsValidationError
|
||||||
|
{
|
||||||
|
public long RegistrationPersonId { get; set; }
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CitizenSportsConnectionTestOutput
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
public long? PlatformTime { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CitizenSportsExecuteOutput
|
||||||
|
{
|
||||||
|
public string PlatformEventCode { get; set; } = string.Empty;
|
||||||
|
public int RequestedCount { get; set; }
|
||||||
|
public int SuccessCount { get; set; }
|
||||||
|
public int FailureCount { get; set; }
|
||||||
|
public int UnattemptedCount { get; set; }
|
||||||
|
public bool Aborted { get; set; }
|
||||||
|
public List<CitizenSportsBatchResult> Batches { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CitizenSportsBatchResult
|
||||||
|
{
|
||||||
|
public string TypeName { get; set; } = string.Empty;
|
||||||
|
public int RequestedCount { get; set; }
|
||||||
|
public int SuccessCount { get; set; }
|
||||||
|
public int FailureCount { get; set; }
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class CitizenSportsGetCodeRequest
|
||||||
|
{
|
||||||
|
[JsonProperty("mname")]
|
||||||
|
public string EventName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonProperty("sname", NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
public string? SeriesName { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class CitizenSportsUploadRequest
|
||||||
|
{
|
||||||
|
[JsonProperty("mcode")]
|
||||||
|
public string EventCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonProperty("typename")]
|
||||||
|
public string TypeName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonProperty("players")]
|
||||||
|
public List<CitizenSportsPlayer> Players { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class CitizenSportsPlayer
|
||||||
|
{
|
||||||
|
[JsonProperty("playerId")]
|
||||||
|
public string PlayerId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonProperty("CardNumber")]
|
||||||
|
public string CardNumber { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonProperty("CardType")]
|
||||||
|
public string CardType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonProperty("playerName")]
|
||||||
|
public string PlayerName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonProperty("playerSex", NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
public string? PlayerSex { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("playerPhone", NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
public string? PlayerPhone { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("playerBirth", NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
public string? PlayerBirth { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("groupName", NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
public string? GroupName { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("deleteStatus")]
|
||||||
|
public int DeleteStatus { get; set; } = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class CitizenSportsPlatformResponse<T>
|
||||||
|
{
|
||||||
|
[JsonProperty("code")]
|
||||||
|
public string? Code { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("status")]
|
||||||
|
public string? Status { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("message")]
|
||||||
|
public string? Message { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("msg")]
|
||||||
|
public string? Msg { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("data")]
|
||||||
|
public T? Data { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("sys_time")]
|
||||||
|
public long? SystemTime { get; set; }
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public bool IsSuccess => Code == "200" && string.Equals(Status, "success", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public string EffectiveMessage => Message ?? Msg ?? "平台未返回说明";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class CitizenSportsTestData
|
||||||
|
{
|
||||||
|
[JsonProperty("Test")]
|
||||||
|
public string? Test { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class CitizenSportsEventCodeData
|
||||||
|
{
|
||||||
|
[JsonProperty("mcode")]
|
||||||
|
public string? EventCode { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("mname")]
|
||||||
|
public string? EventName { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class CitizenSportsUploadData
|
||||||
|
{
|
||||||
|
[JsonProperty("num")]
|
||||||
|
public int SuccessCount { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("errnum")]
|
||||||
|
public int FailureCount { get; set; }
|
||||||
|
}
|
||||||
@ -175,8 +175,8 @@ public class CheckinScheduleService : ITransient
|
|||||||
x => GetImportedSessionOrder(x.SessionName, GetSessionOrder(x.SessionNo)));
|
x => GetImportedSessionOrder(x.SessionName, GetSessionOrder(x.SessionNo)));
|
||||||
var sessionMap = sessions.ToDictionary(x => x.Id);
|
var sessionMap = sessions.ToDictionary(x => x.Id);
|
||||||
|
|
||||||
// 与成绩管理列表保持一致:导入记录的 ID 按秩序册预览行顺序生成。
|
// 秩序册允许不同比赛日重复从“第1场”开始,不能再按场次数字排序。
|
||||||
// SessionName 中的“第 N 场”仅用于展示场次,不能用它重新排列选手。
|
// 导入时出场记录 ID 按 Excel 行顺序生成,因此按 ID 即为原始导入顺序。
|
||||||
var units = entries
|
var units = entries
|
||||||
.Where(x => sessionMap.ContainsKey(x.SessionId))
|
.Where(x => sessionMap.ContainsKey(x.SessionId))
|
||||||
.OrderBy(x => x.Id)
|
.OrderBy(x => x.Id)
|
||||||
|
|||||||
@ -173,5 +173,14 @@
|
|||||||
"UnitUserRoleId": 1001,
|
"UnitUserRoleId": 1001,
|
||||||
"ScoringUnitRoleId": 1003,
|
"ScoringUnitRoleId": 1003,
|
||||||
"HiddenMenuIds": [2000, 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009]
|
"HiddenMenuIds": [2000, 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009]
|
||||||
|
},
|
||||||
|
// 市民运动会测试平台配置。上线前必须替换为运营方提供的生产地址和正式凭证。
|
||||||
|
"CitizenSportsPlatform": {
|
||||||
|
"Enabled": true,
|
||||||
|
"BaseUrl": "https://smydh.dev.moveclub.cn/paas",
|
||||||
|
"ClientId": "movd170af8674405d64",
|
||||||
|
"SecretKey": "2378451e40129ed4c5ca779f37e6a01f",
|
||||||
|
"TimeoutSeconds": 30,
|
||||||
|
"BatchSize": 100
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ public static class ServiceCollectionExtension
|
|||||||
service.AddConfigurableOptions<WeChatOptions>();
|
service.AddConfigurableOptions<WeChatOptions>();
|
||||||
service.AddConfigurableOptions<WechatTenpayClientOptions>();
|
service.AddConfigurableOptions<WechatTenpayClientOptions>();
|
||||||
service.AddConfigurableOptions<RoleOptions>();
|
service.AddConfigurableOptions<RoleOptions>();
|
||||||
|
service.AddConfigurableOptions<CitizenSportsPlatformOptions>();
|
||||||
return service;
|
return service;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
YD_Event.Core/Options/CitizenSportsPlatformOptions.cs
Normal file
40
YD_Event.Core/Options/CitizenSportsPlatformOptions.cs
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
using Furion.ConfigurableOptions;
|
||||||
|
|
||||||
|
namespace YD_Event.Core.Options;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 市民运动会平台对接配置。
|
||||||
|
/// </summary>
|
||||||
|
[OptionsSettings("CitizenSportsPlatform")]
|
||||||
|
public class CitizenSportsPlatformOptions : IConfigurableOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 是否允许调用市民运动会平台。
|
||||||
|
/// </summary>
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 平台完整地址,例如 https://test.example.com。
|
||||||
|
/// </summary>
|
||||||
|
public string BaseUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 平台分配的客户端标识。
|
||||||
|
/// </summary>
|
||||||
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 平台分配的密钥,只能保存在服务端配置中。
|
||||||
|
/// </summary>
|
||||||
|
public string SecretKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 请求超时秒数。
|
||||||
|
/// </summary>
|
||||||
|
public int TimeoutSeconds { get; set; } = 30;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 单次上传人员数量;需按平台给出的限制配置。
|
||||||
|
/// </summary>
|
||||||
|
public int BatchSize { get; set; } = 100;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user