版本260406
This commit is contained in:
@@ -3,6 +3,10 @@ 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;
|
||||
|
||||
@@ -16,54 +20,133 @@ namespace OrpaonVision.ConfigApp.Annotation.Services;
|
||||
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)
|
||||
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 Task<Result> SyncProjectAsync(SyncAnnotationProjectCommand command, CancellationToken cancellationToken = default)
|
||||
public async Task<Result> SyncProjectAsync(SyncAnnotationProjectCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (command.Platform != AnnotationPlatformEnum.Cvat)
|
||||
{
|
||||
return Task.FromResult(Result.Fail("ANNOTATION_PLATFORM_NOT_SUPPORTED", "当前版本仅支持 CVAT 平台同步。"));
|
||||
return Result.Fail("ANNOTATION_PLATFORM_NOT_SUPPORTED", "当前版本仅支持 CVAT 平台同步。");
|
||||
}
|
||||
|
||||
if (command.ProjectId == Guid.Empty || command.AnnotationTaskId == Guid.Empty)
|
||||
{
|
||||
return Task.FromResult(Result.Fail("ANNOTATION_ARGUMENT_INVALID", "项目标识或标注任务标识无效。"));
|
||||
return Result.Fail("ANNOTATION_ARGUMENT_INVALID", "项目标识或标注任务标识无效。");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(command.CvatServerEndpoint))
|
||||
{
|
||||
return Task.FromResult(Result.Fail("ANNOTATION_ENDPOINT_REQUIRED", "CVAT 服务地址不能为空。"));
|
||||
return Result.Fail("ANNOTATION_ENDPOINT_REQUIRED", "CVAT 服务地址不能为空。");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(command.CvatServerEndpoint, UriKind.Absolute, out _))
|
||||
if (!TryBuildEndpointUri(command.CvatServerEndpoint, out var endpointUri))
|
||||
{
|
||||
return Task.FromResult(Result.Fail("ANNOTATION_ENDPOINT_INVALID", "CVAT 服务地址格式不合法。"));
|
||||
return Result.Fail("ANNOTATION_ENDPOINT_INVALID", "CVAT 服务地址格式不合法。");
|
||||
}
|
||||
|
||||
if (command.CvatTaskId <= 0 || command.CvatProjectId <= 0)
|
||||
{
|
||||
return Task.FromResult(Result.Fail("ANNOTATION_CVAT_ID_INVALID", "CVAT 项目 ID 或任务 ID 必须大于 0。"));
|
||||
return Result.Fail("ANNOTATION_CVAT_ID_INVALID", "CVAT 项目 ID 或任务 ID 必须大于 0。");
|
||||
}
|
||||
|
||||
// 当前为骨架实现:预留后续与 CVAT API 的双向同步逻辑。
|
||||
return Task.FromResult(Result.Success(message: "CVAT 同步请求已受理。"));
|
||||
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 Task<Result<AnnotationSyncStatusDto>> GetSyncStatusAsync(Guid projectId, CancellationToken cancellationToken = default)
|
||||
public async Task<Result<AnnotationSyncStatusDto>> GetSyncStatusAsync(Guid projectId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (projectId == Guid.Empty)
|
||||
{
|
||||
return Task.FromResult(Result<AnnotationSyncStatusDto>.Fail("ANNOTATION_PROJECT_ID_INVALID", "项目标识不能为空。"));
|
||||
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
|
||||
@@ -77,6 +160,279 @@ public sealed class CvatAnnotationSyncAppService : IAnnotationSyncAppService
|
||||
LastErrorMessage = null
|
||||
};
|
||||
|
||||
return Task.FromResult(Result<AnnotationSyncStatusDto>.Success(status, message: $"CVAT 状态查询完成({_options.ServerEndpoint})。"));
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user