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/Client/CheckinScheduleService.cs b/YD_Event.Application/Client/CheckinScheduleService.cs
index 97a59ae..45053ff 100644
--- a/YD_Event.Application/Client/CheckinScheduleService.cs
+++ b/YD_Event.Application/Client/CheckinScheduleService.cs
@@ -175,8 +175,8 @@ public class CheckinScheduleService : ITransient
x => GetImportedSessionOrder(x.SessionName, GetSessionOrder(x.SessionNo)));
var sessionMap = sessions.ToDictionary(x => x.Id);
- // 与成绩管理列表保持一致:导入记录的 ID 按秩序册预览行顺序生成。
- // SessionName 中的“第 N 场”仅用于展示场次,不能用它重新排列选手。
+ // 秩序册允许不同比赛日重复从“第1场”开始,不能再按场次数字排序。
+ // 导入时出场记录 ID 按 Excel 行顺序生成,因此按 ID 即为原始导入顺序。
var units = entries
.Where(x => sessionMap.ContainsKey(x.SessionId))
.OrderBy(x => x.Id)
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;
+}