Files
OrpaonVision/OrpaonVision.ConfigApp/Annotation/Services/CvatAnnotationSyncAppService.cs
2026-04-06 22:04:05 +08:00

439 lines
16 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// CVAT 标注同步应用服务(骨架实现)。
///
/// 说明:
/// - 当前阶段先提供参数校验与返回契约,便于前后链路先跑通;
/// - 实际 HTTP 调用、认证、重试策略将在后续迭代补齐。
/// </summary>
public sealed class CvatAnnotationSyncAppService : IAnnotationSyncAppService
{
private readonly CvatOptions _options;
private readonly HttpClient _httpClient;
private readonly IAnnotationTaskStore _annotationTaskStore;
/// <summary>
/// 构造函数。
/// </summary>
/// <param name="options">CVAT 配置。</param>
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);
}
}
/// <inheritdoc />
public async Task<Result> 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})。");
}
/// <inheritdoc />
public async Task<Result<AnnotationSyncStatusDto>> GetSyncStatusAsync(Guid projectId, CancellationToken cancellationToken = default)
{
if (projectId == Guid.Empty)
{
return Result<AnnotationSyncStatusDto>.Fail("ANNOTATION_PROJECT_ID_INVALID", "项目标识不能为空。");
}
if (!TryBuildEndpointUri(_options.ServerEndpoint, out var endpointUri))
{
return Result<AnnotationSyncStatusDto>.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<AnnotationSyncStatusDto>.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<AnnotationSyncStatusDto>.Success(status, message: "CVAT 服务不可用。");
}
catch (OperationCanceledException)
{
status = BuildStatusDto(
projectId,
syncStatus: AnnotationSyncStatusEnum.Failed,
progressPercent: 0,
lastSyncedAtUtc: null,
lastErrorMessage: "请求超时或已取消。");
return Result<AnnotationSyncStatusDto>.Success(status, message: "CVAT 状态查询超时或已取消。");
}
catch (HttpRequestException ex)
{
status = BuildStatusDto(
projectId,
syncStatus: AnnotationSyncStatusEnum.Failed,
progressPercent: 0,
lastSyncedAtUtc: null,
lastErrorMessage: ex.Message);
return Result<AnnotationSyncStatusDto>.Success(status, message: "CVAT 服务连接失败。");
}
}
/// <inheritdoc />
public async Task<Result<AnnotationTaskDetailDto>> GetTaskDetailAsync(long cvatTaskId, CancellationToken cancellationToken = default)
{
if (cvatTaskId <= 0)
{
return Result<AnnotationTaskDetailDto>.Fail("ANNOTATION_CVAT_TASK_ID_INVALID", "CVAT 任务 ID 必须大于 0。");
}
if (!TryBuildEndpointUri(_options.ServerEndpoint, out var endpointUri))
{
return Result<AnnotationTaskDetailDto>.Fail("ANNOTATION_ENDPOINT_INVALID", "CVAT 服务地址配置无效。");
}
var cvatTaskUrl = BuildTaskApiUrl(endpointUri!, cvatTaskId);
HttpResponseMessage response;
try
{
response = await _httpClient.GetAsync(cvatTaskUrl, cancellationToken);
}
catch (OperationCanceledException)
{
return Result<AnnotationTaskDetailDto>.Fail("ANNOTATION_SYNC_TIMEOUT", "CVAT 任务详情请求超时或已取消。");
}
catch (HttpRequestException ex)
{
return Result<AnnotationTaskDetailDto>.Fail("ANNOTATION_CVAT_UNREACHABLE", "无法连接 CVAT 服务。", ex.Message);
}
if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden)
{
return Result<AnnotationTaskDetailDto>.Fail("ANNOTATION_CVAT_AUTH_FAILED", "CVAT 认证失败,请检查 Token 配置。");
}
if (response.StatusCode == HttpStatusCode.NotFound)
{
return Result<AnnotationTaskDetailDto>.Fail("ANNOTATION_CVAT_TASK_NOT_FOUND", $"CVAT 任务不存在:{cvatTaskId}。");
}
if (!response.IsSuccessStatusCode)
{
return Result<AnnotationTaskDetailDto>.Fail("ANNOTATION_CVAT_REQUEST_FAILED", $"CVAT 请求失败,状态码:{(int)response.StatusCode}。");
}
string payload;
try
{
payload = await response.Content.ReadAsStringAsync(cancellationToken);
}
catch (OperationCanceledException)
{
return Result<AnnotationTaskDetailDto>.Fail("ANNOTATION_SYNC_TIMEOUT", "读取 CVAT 任务详情响应超时或已取消。");
}
if (!TryParseTaskInfo(payload, out var cvatTaskInfo))
{
return Result<AnnotationTaskDetailDto>.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<AnnotationTaskDetailDto>.FailWithTrace(
"ANNOTATION_TASK_STORE_FAILED",
"CVAT 任务详情已获取,但本地快照保存失败。",
storeResult.TraceId ?? Guid.NewGuid().ToString("N"),
[.. storeResult.Errors]);
}
return Result<AnnotationTaskDetailDto>.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);
}