using OrpaonVision.ConfigApp.Annotation.Options; using OrpaonVision.Core.Annotation; using OrpaonVision.Core.Annotation.Contracts; using OrpaonVision.Core.Enums; using OrpaonVision.Core.Results; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Text.Json; namespace OrpaonVision.ConfigApp.Annotation.Services; /// /// CVAT 标注同步应用服务(骨架实现)。 /// /// 说明: /// - 当前阶段先提供参数校验与返回契约,便于前后链路先跑通; /// - 实际 HTTP 调用、认证、重试策略将在后续迭代补齐。 /// public sealed class CvatAnnotationSyncAppService : IAnnotationSyncAppService { private readonly CvatOptions _options; private readonly HttpClient _httpClient; private readonly IAnnotationTaskStore _annotationTaskStore; /// /// 构造函数。 /// /// CVAT 配置。 public CvatAnnotationSyncAppService(CvatOptions options, IAnnotationTaskStore annotationTaskStore) { _options = options; _annotationTaskStore = annotationTaskStore; _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(options.SyncTimeoutSeconds > 0 ? options.SyncTimeoutSeconds : 60) }; if (!string.IsNullOrWhiteSpace(options.ApiToken)) { _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Token", options.ApiToken); } } /// public async Task SyncProjectAsync(SyncAnnotationProjectCommand command, CancellationToken cancellationToken = default) { if (command.Platform != AnnotationPlatformEnum.Cvat) { return Result.Fail("ANNOTATION_PLATFORM_NOT_SUPPORTED", "当前版本仅支持 CVAT 平台同步。"); } if (command.ProjectId == Guid.Empty || command.AnnotationTaskId == Guid.Empty) { return Result.Fail("ANNOTATION_ARGUMENT_INVALID", "项目标识或标注任务标识无效。"); } if (string.IsNullOrWhiteSpace(command.CvatServerEndpoint)) { return Result.Fail("ANNOTATION_ENDPOINT_REQUIRED", "CVAT 服务地址不能为空。"); } if (!TryBuildEndpointUri(command.CvatServerEndpoint, out var endpointUri)) { return Result.Fail("ANNOTATION_ENDPOINT_INVALID", "CVAT 服务地址格式不合法。"); } if (command.CvatTaskId <= 0 || command.CvatProjectId <= 0) { return Result.Fail("ANNOTATION_CVAT_ID_INVALID", "CVAT 项目 ID 或任务 ID 必须大于 0。"); } var cvatTaskUrl = BuildTaskApiUrl(endpointUri!, command.CvatTaskId); HttpResponseMessage response; try { response = await _httpClient.GetAsync(cvatTaskUrl, cancellationToken); } catch (OperationCanceledException) { return Result.Fail("ANNOTATION_SYNC_TIMEOUT", "CVAT 同步请求超时或已取消。"); } catch (HttpRequestException ex) { return Result.Fail("ANNOTATION_CVAT_UNREACHABLE", "无法连接 CVAT 服务。", ex.Message); } if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden) { return Result.Fail("ANNOTATION_CVAT_AUTH_FAILED", "CVAT 认证失败,请检查 Token 配置。"); } if (response.StatusCode == HttpStatusCode.NotFound) { return Result.Fail("ANNOTATION_CVAT_TASK_NOT_FOUND", $"CVAT 任务不存在:{command.CvatTaskId}。"); } if (!response.IsSuccessStatusCode) { return Result.Fail("ANNOTATION_CVAT_REQUEST_FAILED", $"CVAT 请求失败,状态码:{(int)response.StatusCode}。"); } string payload; try { payload = await response.Content.ReadAsStringAsync(cancellationToken); } catch (OperationCanceledException) { return Result.Fail("ANNOTATION_SYNC_TIMEOUT", "读取 CVAT 响应超时或已取消。"); } if (!TryParseTaskInfo(payload, out var cvatTaskInfo)) { return Result.Fail("ANNOTATION_CVAT_PAYLOAD_INVALID", "CVAT 任务响应格式不符合预期。"); } if (cvatTaskInfo.TaskId != command.CvatTaskId) { return Result.Fail("ANNOTATION_CVAT_TASK_MISMATCH", "CVAT 任务 ID 校验失败。"); } if (cvatTaskInfo.ProjectId is null) { return Result.Fail("ANNOTATION_CVAT_PROJECT_EMPTY", "CVAT 任务未绑定项目,无法执行项目级同步。"); } if (cvatTaskInfo.ProjectId.Value != command.CvatProjectId) { return Result.Fail("ANNOTATION_CVAT_PROJECT_MISMATCH", "CVAT 项目 ID 与本地请求不一致。"); } return Result.Success(message: $"CVAT 任务校验成功,已受理同步请求(TaskId={command.CvatTaskId})。"); } /// public async Task> GetSyncStatusAsync(Guid projectId, CancellationToken cancellationToken = default) { if (projectId == Guid.Empty) { return Result.Fail("ANNOTATION_PROJECT_ID_INVALID", "项目标识不能为空。"); } if (!TryBuildEndpointUri(_options.ServerEndpoint, out var endpointUri)) { return Result.Fail("ANNOTATION_ENDPOINT_INVALID", "CVAT 服务地址配置无效。"); } var status = new AnnotationSyncStatusDto { ProjectId = projectId, AnnotationTaskId = Guid.Empty, Platform = AnnotationPlatformEnum.Cvat, SyncStatus = AnnotationSyncStatusEnum.None, ProgressPercent = 0, LastSyncedAtUtc = null, LastErrorMessage = null }; var aboutUrl = BuildAboutApiUrl(endpointUri!); try { var response = await _httpClient.GetAsync(aboutUrl, cancellationToken); if (response.IsSuccessStatusCode) { var payload = await response.Content.ReadAsStringAsync(cancellationToken); var version = TryReadServerVersion(payload); status = BuildStatusDto( projectId, syncStatus: AnnotationSyncStatusEnum.Succeeded, progressPercent: 100, lastSyncedAtUtc: DateTime.UtcNow, lastErrorMessage: null); return Result.Success(status, message: string.IsNullOrWhiteSpace(version) ? "CVAT 服务连通性正常。" : $"CVAT 服务连通性正常(Version={version})。"); } status = BuildStatusDto( projectId, syncStatus: AnnotationSyncStatusEnum.Failed, progressPercent: 0, lastSyncedAtUtc: null, lastErrorMessage: $"CVAT 状态码:{(int)response.StatusCode}"); return Result.Success(status, message: "CVAT 服务不可用。"); } catch (OperationCanceledException) { status = BuildStatusDto( projectId, syncStatus: AnnotationSyncStatusEnum.Failed, progressPercent: 0, lastSyncedAtUtc: null, lastErrorMessage: "请求超时或已取消。"); return Result.Success(status, message: "CVAT 状态查询超时或已取消。"); } catch (HttpRequestException ex) { status = BuildStatusDto( projectId, syncStatus: AnnotationSyncStatusEnum.Failed, progressPercent: 0, lastSyncedAtUtc: null, lastErrorMessage: ex.Message); return Result.Success(status, message: "CVAT 服务连接失败。"); } } /// public async Task> GetTaskDetailAsync(long cvatTaskId, CancellationToken cancellationToken = default) { if (cvatTaskId <= 0) { return Result.Fail("ANNOTATION_CVAT_TASK_ID_INVALID", "CVAT 任务 ID 必须大于 0。"); } if (!TryBuildEndpointUri(_options.ServerEndpoint, out var endpointUri)) { return Result.Fail("ANNOTATION_ENDPOINT_INVALID", "CVAT 服务地址配置无效。"); } var cvatTaskUrl = BuildTaskApiUrl(endpointUri!, cvatTaskId); HttpResponseMessage response; try { response = await _httpClient.GetAsync(cvatTaskUrl, cancellationToken); } catch (OperationCanceledException) { return Result.Fail("ANNOTATION_SYNC_TIMEOUT", "CVAT 任务详情请求超时或已取消。"); } catch (HttpRequestException ex) { return Result.Fail("ANNOTATION_CVAT_UNREACHABLE", "无法连接 CVAT 服务。", ex.Message); } if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden) { return Result.Fail("ANNOTATION_CVAT_AUTH_FAILED", "CVAT 认证失败,请检查 Token 配置。"); } if (response.StatusCode == HttpStatusCode.NotFound) { return Result.Fail("ANNOTATION_CVAT_TASK_NOT_FOUND", $"CVAT 任务不存在:{cvatTaskId}。"); } if (!response.IsSuccessStatusCode) { return Result.Fail("ANNOTATION_CVAT_REQUEST_FAILED", $"CVAT 请求失败,状态码:{(int)response.StatusCode}。"); } string payload; try { payload = await response.Content.ReadAsStringAsync(cancellationToken); } catch (OperationCanceledException) { return Result.Fail("ANNOTATION_SYNC_TIMEOUT", "读取 CVAT 任务详情响应超时或已取消。"); } if (!TryParseTaskInfo(payload, out var cvatTaskInfo)) { return Result.Fail("ANNOTATION_CVAT_PAYLOAD_INVALID", "CVAT 任务详情响应格式不符合预期。"); } var detailDto = new AnnotationTaskDetailDto { Platform = AnnotationPlatformEnum.Cvat, CvatTaskId = cvatTaskInfo.TaskId, TaskName = cvatTaskInfo.TaskName ?? string.Empty, CvatProjectId = cvatTaskInfo.ProjectId, TaskStatus = cvatTaskInfo.TaskStatus ?? string.Empty, ItemCount = cvatTaskInfo.ItemCount ?? 0, UpdatedAtUtc = cvatTaskInfo.UpdatedAtUtc }; var storeResult = _annotationTaskStore.SaveOrUpdate(detailDto); if (!storeResult.Succeeded) { return Result.FailWithTrace( "ANNOTATION_TASK_STORE_FAILED", "CVAT 任务详情已获取,但本地快照保存失败。", storeResult.TraceId ?? Guid.NewGuid().ToString("N"), [.. storeResult.Errors]); } return Result.Success(detailDto, message: "CVAT 任务详情查询成功。"); } private static bool TryBuildEndpointUri(string endpoint, out Uri? endpointUri) { endpointUri = null; if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var rawUri)) { return false; } if (rawUri.Scheme is not ("http" or "https")) { return false; } endpointUri = new Uri(rawUri.GetLeftPart(UriPartial.Authority)); return true; } private static AnnotationSyncStatusDto BuildStatusDto( Guid projectId, AnnotationSyncStatusEnum syncStatus, decimal progressPercent, DateTime? lastSyncedAtUtc, string? lastErrorMessage) { return new AnnotationSyncStatusDto { ProjectId = projectId, AnnotationTaskId = Guid.Empty, Platform = AnnotationPlatformEnum.Cvat, SyncStatus = syncStatus, ProgressPercent = progressPercent, LastSyncedAtUtc = lastSyncedAtUtc, LastErrorMessage = lastErrorMessage }; } private static string BuildTaskApiUrl(Uri endpointUri, long cvatTaskId) { return $"{endpointUri.AbsoluteUri.TrimEnd('/')}/api/tasks/{cvatTaskId}"; } private static string BuildAboutApiUrl(Uri endpointUri) { return $"{endpointUri.AbsoluteUri.TrimEnd('/')}/api/server/about"; } private static bool TryParseTaskInfo(string payload, out CvatTaskInfo taskInfo) { taskInfo = default; try { using var json = JsonDocument.Parse(payload); var root = json.RootElement; if (!root.TryGetProperty("id", out var idElement) || !idElement.TryGetInt64(out var taskId)) { return false; } string? taskName = null; if (root.TryGetProperty("name", out var nameElement) && nameElement.ValueKind == JsonValueKind.String) { taskName = nameElement.GetString(); } string? taskStatus = null; if (root.TryGetProperty("status", out var statusElement) && statusElement.ValueKind == JsonValueKind.String) { taskStatus = statusElement.GetString(); } long? projectId = null; if (root.TryGetProperty("project", out var projectElement) && projectElement.ValueKind is not JsonValueKind.Null) { if (!projectElement.TryGetInt64(out var parsedProjectId)) { return false; } projectId = parsedProjectId; } int? itemCount = null; if (root.TryGetProperty("size", out var sizeElement) && sizeElement.TryGetInt32(out var parsedSize)) { itemCount = parsedSize; } DateTime? updatedAtUtc = null; if (root.TryGetProperty("updated_date", out var updatedDateElement) && updatedDateElement.ValueKind == JsonValueKind.String && DateTime.TryParse(updatedDateElement.GetString(), out var parsedUpdatedAt)) { updatedAtUtc = parsedUpdatedAt.ToUniversalTime(); } taskInfo = new CvatTaskInfo(taskId, projectId, taskName, taskStatus, itemCount, updatedAtUtc); return true; } catch (JsonException) { return false; } } private static string? TryReadServerVersion(string payload) { try { using var json = JsonDocument.Parse(payload); var root = json.RootElement; if (root.TryGetProperty("version", out var versionElement)) { return versionElement.GetString(); } return null; } catch (JsonException) { return null; } } private readonly record struct CvatTaskInfo( long TaskId, long? ProjectId, string? TaskName, string? TaskStatus, int? ItemCount, DateTime? UpdatedAtUtc); }