439 lines
16 KiB
C#
439 lines
16 KiB
C#
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);
|
||
}
|