版本260406

This commit is contained in:
2026-04-06 22:04:05 +08:00
parent 7dc5e73af7
commit 0b150470be
216 changed files with 98993 additions and 33 deletions

View File

@@ -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);
}