From b475fd6b08a336374df7bac37aeef81cf96fcd61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A9=AC=E6=A2=93=E5=BD=AD?= Date: Thu, 22 Oct 2026 08:43:25 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E5=B0=8F=E7=A8=8B=E5=BA=8F=E6=A3=80?= =?UTF-8?q?=E5=BD=95=E6=8C=89=E7=85=A7=E7=A7=A9=E5=BA=8F=E5=86=8C=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E9=A1=BA=E5=BA=8F=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YD_Event.Application/Client/CheckinScheduleService.cs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/YD_Event.Application/Client/CheckinScheduleService.cs b/YD_Event.Application/Client/CheckinScheduleService.cs index 82134e4..45053ff 100644 --- a/YD_Event.Application/Client/CheckinScheduleService.cs +++ b/YD_Event.Application/Client/CheckinScheduleService.cs @@ -173,17 +173,13 @@ public class CheckinScheduleService : ITransient var sessionOrderMap = sessions.ToDictionary( x => x.Id, x => GetImportedSessionOrder(x.SessionName, GetSessionOrder(x.SessionNo))); - var orderedSessions = sessions - .OrderBy(x => sessionOrderMap[x.Id]) - .ThenBy(x => venueMap.GetValueOrDefault(x.VenueId)?.Sort ?? int.MaxValue) - .ThenBy(x => x.SessionNo) - .ToList(); var sessionMap = sessions.ToDictionary(x => x.Id); + // 秩序册允许不同比赛日重复从“第1场”开始,不能再按场次数字排序。 + // 导入时出场记录 ID 按 Excel 行顺序生成,因此按 ID 即为原始导入顺序。 var units = entries .Where(x => sessionMap.ContainsKey(x.SessionId)) - .OrderBy(x => sessionOrderMap[x.SessionId]) - .ThenBy(x => x.PlayOrder) + .OrderBy(x => x.Id) .Select((entry, index) => { var session = sessionMap[entry.SessionId]; From c66d026ec76c4f3de89ba3c6cf6da8a7ac73d59d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A9=AC=E6=A2=93=E5=BD=AD?= Date: Wed, 28 Oct 2026 10:00:12 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E5=B8=82=E6=B0=91=E8=BF=90=E5=8A=A8?= =?UTF-8?q?=E4=BC=9A=E6=8A=A5=E5=90=8D=E6=95=B0=E6=8D=AE=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CitizenSportsPlatformService.cs | 411 ++++++++++++++++++ .../CitizenSports/CitizenSportsSigner.cs | 25 ++ .../CitizenSportsSyncController.cs | 65 +++ .../Dtos/CitizenSportsSyncDtos.cs | 195 +++++++++ YD_Event.Application/applicationsettings.json | 9 + .../Extensions/ServiceCollectionExtension.cs | 3 +- .../Options/CitizenSportsPlatformOptions.cs | 40 ++ 7 files changed, 747 insertions(+), 1 deletion(-) create mode 100644 YD_Event.Application/CitizenSports/CitizenSportsPlatformService.cs create mode 100644 YD_Event.Application/CitizenSports/CitizenSportsSigner.cs create mode 100644 YD_Event.Application/CitizenSports/CitizenSportsSyncController.cs create mode 100644 YD_Event.Application/CitizenSports/Dtos/CitizenSportsSyncDtos.cs create mode 100644 YD_Event.Core/Options/CitizenSportsPlatformOptions.cs diff --git a/YD_Event.Application/CitizenSports/CitizenSportsPlatformService.cs b/YD_Event.Application/CitizenSports/CitizenSportsPlatformService.cs new file mode 100644 index 0000000..149d7b1 --- /dev/null +++ b/YD_Event.Application/CitizenSports/CitizenSportsPlatformService.cs @@ -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; + +/// +/// 市民运动会平台报名数据对接服务。 +/// +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(), + 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; } = []; + } +} diff --git a/YD_Event.Application/CitizenSports/CitizenSportsSigner.cs b/YD_Event.Application/CitizenSports/CitizenSportsSigner.cs new file mode 100644 index 0000000..14e6993 --- /dev/null +++ b/YD_Event.Application/CitizenSports/CitizenSportsSigner.cs @@ -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(); + } +} diff --git a/YD_Event.Application/CitizenSports/CitizenSportsSyncController.cs b/YD_Event.Application/CitizenSports/CitizenSportsSyncController.cs new file mode 100644 index 0000000..2645396 --- /dev/null +++ b/YD_Event.Application/CitizenSports/CitizenSportsSyncController.cs @@ -0,0 +1,65 @@ +using YD_Event.Application.Auth; +using YD_Event.Application.CitizenSports.Dtos; + +namespace YD_Event.Application.CitizenSports; + +/// +/// 市民运动会报名数据同步。 +/// +[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; + } + + /// + /// 使用当前环境配置验证平台地址和签名。 + /// + [HttpPost("connection-test")] + [DisplayName("测试市民运动会平台连接")] + public async Task TestConnection(CancellationToken cancellationToken) + { + EnsureSuperAdministrator(); + return await _service.TestConnectionAsync(cancellationToken); + } + + /// + /// 只读取本地报名数据,返回不含敏感信息的上传预览。 + /// + [HttpPost("preview")] + [DisplayName("预览市民运动会报名同步")] + public async Task Preview([FromBody] CitizenSportsSyncInput input) + { + EnsureSuperAdministrator(); + return await _service.PreviewAsync(input); + } + + /// + /// 将预检通过的报名人员上传到市民运动会平台。 + /// + [HttpPost("execute")] + [DisplayName("执行市民运动会报名同步")] + public async Task 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("仅超级管理员可以同步市民运动会报名数据"); + } +} diff --git a/YD_Event.Application/CitizenSports/Dtos/CitizenSportsSyncDtos.cs b/YD_Event.Application/CitizenSports/Dtos/CitizenSportsSyncDtos.cs new file mode 100644 index 0000000..1bfb7d5 --- /dev/null +++ b/YD_Event.Application/CitizenSports/Dtos/CitizenSportsSyncDtos.cs @@ -0,0 +1,195 @@ +using Newtonsoft.Json; + +namespace YD_Event.Application.CitizenSports.Dtos; + +/// +/// 市民运动会报名同步请求。 +/// +public class CitizenSportsSyncInput +{ + /// + /// 悦动系统赛事 ID。 + /// + [Required] + public long EventId { get; set; } + + /// + /// 市民运动会平台中的赛事名称,必须与一网通办申报名称一致。 + /// + [Required] + public string PlatformEventName { get; set; } = string.Empty; + + /// + /// 系列赛场次名称,没有时不传。 + /// + public string? SeriesName { get; set; } +} + +/// +/// 市民运动会报名执行同步请求。 +/// +public class CitizenSportsExecuteInput : CitizenSportsSyncInput +{ + /// + /// 必须明确确认后才允许向外部平台写入。 + /// + public bool ConfirmUpload { get; set; } +} + +/// +/// 同步预览结果,不返回证件号、手机号等敏感信息。 +/// +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 Types { get; set; } = []; + public List 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 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 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 +{ + [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; } +} diff --git a/YD_Event.Application/applicationsettings.json b/YD_Event.Application/applicationsettings.json index 19a219b..a503210 100644 --- a/YD_Event.Application/applicationsettings.json +++ b/YD_Event.Application/applicationsettings.json @@ -173,5 +173,14 @@ "UnitUserRoleId": 1001, "ScoringUnitRoleId": 1003, "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 } } diff --git a/YD_Event.Core/Extensions/ServiceCollectionExtension.cs b/YD_Event.Core/Extensions/ServiceCollectionExtension.cs index 29dda0a..de588e4 100644 --- a/YD_Event.Core/Extensions/ServiceCollectionExtension.cs +++ b/YD_Event.Core/Extensions/ServiceCollectionExtension.cs @@ -19,6 +19,7 @@ public static class ServiceCollectionExtension service.AddConfigurableOptions(); service.AddConfigurableOptions(); service.AddConfigurableOptions(); + service.AddConfigurableOptions(); return service; } -} \ No newline at end of file +} diff --git a/YD_Event.Core/Options/CitizenSportsPlatformOptions.cs b/YD_Event.Core/Options/CitizenSportsPlatformOptions.cs new file mode 100644 index 0000000..f7e8e82 --- /dev/null +++ b/YD_Event.Core/Options/CitizenSportsPlatformOptions.cs @@ -0,0 +1,40 @@ +using Furion.ConfigurableOptions; + +namespace YD_Event.Core.Options; + +/// +/// 市民运动会平台对接配置。 +/// +[OptionsSettings("CitizenSportsPlatform")] +public class CitizenSportsPlatformOptions : IConfigurableOptions +{ + /// + /// 是否允许调用市民运动会平台。 + /// + public bool Enabled { get; set; } + + /// + /// 平台完整地址,例如 https://test.example.com。 + /// + public string BaseUrl { get; set; } = string.Empty; + + /// + /// 平台分配的客户端标识。 + /// + public string ClientId { get; set; } = string.Empty; + + /// + /// 平台分配的密钥,只能保存在服务端配置中。 + /// + public string SecretKey { get; set; } = string.Empty; + + /// + /// 请求超时秒数。 + /// + public int TimeoutSeconds { get; set; } = 30; + + /// + /// 单次上传人员数量;需按平台给出的限制配置。 + /// + public int BatchSize { get; set; } = 100; +}