package webui import ( "context" "encoding/json" "errors" "strings" "cmroubao/backend-api/internal/domain" "cmroubao/backend-api/internal/transport/authcommon" "cmroubao/backend-api/internal/usecase" ) const localAdminSubject = "local-admin" type UsecaseAdapter struct { tasks *usecase.TaskService assets *usecase.AssetService } func NewUsecaseAdapter( tasks *usecase.TaskService, assets *usecase.AssetService, ) (*UsecaseAdapter, error) { if tasks == nil || assets == nil { return nil, errors.New("admin web use cases are required") } return &UsecaseAdapter{ tasks: tasks, assets: assets, }, nil } func (adapter *UsecaseAdapter) ListTasks( ctx context.Context, input ListTasksInput, ) (TaskList, error) { var status *string if input.Status != "" { value := input.Status status = &value } page, err := adapter.tasks.List(ctx, usecase.ListTasksQuery{ CreatorSubject: localAdminSubject, Status: status, Query: input.Query, Limit: input.Limit, Cursor: input.Cursor, }) if err != nil { return TaskList{}, mapUsecaseError(err) } items := make([]TaskSummary, 0, len(page.Items)) for _, item := range page.Items { items = append(items, taskSummaryFrom(item)) } return TaskList{ Items: items, NextCursor: page.NextCursor, }, nil } func (adapter *UsecaseAdapter) GetTask( ctx context.Context, taskID string, ) (Task, error) { detail, err := adapter.tasks.Get(ctx, localAdminSubject, taskID) if err != nil { return Task{}, mapUsecaseError(err) } return taskFromDetail(detail), nil } func (adapter *UsecaseAdapter) UploadReference( ctx context.Context, input UploadReferenceInput, ) (UploadedAsset, error) { result, err := adapter.assets.UploadTaskReference( ctx, usecase.UploadTaskReferenceCommand{ CreatorSubject: localAdminSubject, IdempotencyKey: input.IdempotencyKey, DeclaredMediaType: input.DeclaredType, Content: input.Content, }, ) if err != nil { return UploadedAsset{}, mapUsecaseError(err) } return UploadedAsset{ ID: result.Asset.ID, }, nil } func (adapter *UsecaseAdapter) CreateTask( ctx context.Context, input CreateTaskInput, ) (Task, error) { maxInt := int64(^uint(0) >> 1) if input.Quantity > maxInt { return Task{}, ErrValidation } var budget *string if input.MaxBudget != "" { value := input.MaxBudget budget = &value } result, err := adapter.tasks.Create(ctx, usecase.CreateTaskCommand{ CreatorSubject: localAdminSubject, ActorUserID: actorUserID(ctx), IdempotencyKey: input.IdempotencyKey, Title: input.Title, Description: input.Description, SKU: input.SKU, ImageAssetID: input.ImageAssetID, Quantity: int(input.Quantity), MaxBudget: budget, }) if err != nil { return Task{}, mapUsecaseError(err) } return taskFromPurchase(result.Task), nil } func actorUserID(ctx context.Context) string { principal, ok := authcommon.Principal(ctx) if !ok || principal.Role != domain.UserRoleAdmin { return "" } return principal.UserID } func (adapter *UsecaseAdapter) CancelTask( ctx context.Context, input CancelTaskInput, ) (Task, error) { task, err := adapter.tasks.Cancel(ctx, usecase.CancelTaskCommand{ CreatorSubject: localAdminSubject, ActorUserID: actorUserID(ctx), TaskID: input.TaskID, Reason: "管理员取消", }) if err != nil { return Task{}, mapUsecaseError(err) } return taskFromPurchase(task), nil } func taskSummaryFrom(task domain.PurchaseTask) TaskSummary { return TaskSummary{ ID: task.ID, Title: task.Title, SKU: task.SKU, Status: string(task.Status), UpdatedAt: task.UpdatedAt, } } func taskFromPurchase(task domain.PurchaseTask) Task { budget := domain.FormatOptionalCNY(task.MaxBudgetCents) result := Task{ ID: task.ID, Title: task.Title, SKU: task.SKU, Description: task.Description, Quantity: int64(task.Quantity), Status: string(task.Status), ReferenceAssetID: task.ImageAssetID, CreatedAt: task.CreatedAt, UpdatedAt: task.UpdatedAt, } if budget != nil { result.MaxBudget = *budget } return result } func taskFromDetail(detail domain.TaskDetail) Task { task := taskFromPurchase(detail.Task) task.ReferenceAssetID = detail.Asset.ID task.ExecutionReport = executionReportFrom(detail.Report) return task } func executionReportFrom(report *domain.ExecutionReport) *ExecutionReport { if report == nil { return nil } result := &ExecutionReport{ Events: make([]ExecutionReportEvent, 0, len(report.Events)), Evidence: make([]ExecutionReportEvidence, 0, len(report.EvidenceAssets)), } for _, event := range report.Events { result.Events = append(result.Events, ExecutionReportEvent{ Step: event.Step, Type: event.Type, Message: event.Message, OccurredAt: event.OccurredAt, ReceivedAfterExecutionExpiry: event.ReceivedAfterExecutionExpiry, }) } for _, evidence := range report.EvidenceAssets { result.Evidence = append(result.Evidence, ExecutionReportEvidence{ ID: evidence.ID, ContentURL: "/api/v1/tasks/" + evidence.TaskID + "/evidence/" + evidence.ID + "/content", SHA256: evidence.SHA256, SizeBytes: evidence.SizeBytes, CreatedAt: evidence.CreatedAt, ReceivedAfterExecutionExpiry: evidence.ReceivedAfterExecutionExpiry, }) } if batch := report.CandidateBatch; batch != nil { result.Mode = batch.ExecutionMode result.SearchQuery = batch.SearchQuery result.Provenance = prettyAuditJSON(batch.ProvenanceJSON) result.Candidates = prettyAuditJSON(&batch.CandidatesJSON) result.Recommendation = prettyAuditJSON(batch.RecommendationJSON) } if dataset := report.DecisionDataset; dataset != nil { result.Observations = prettyValueJSON(dataset.Observations) result.ModelPredictions = prettyValueJSON(map[string]any{ "model_run": dataset.ModelRun, "evaluations": dataset.Evaluations, }) result.DeterministicRecommendation = prettyValueJSON( dataset.Recommendation, ) result.HumanReviews = prettyValueJSON(dataset.HumanReviews) } if outcome := report.Outcome; outcome != nil { result.Outcome = &ExecutionReportOutcome{ ResultType: outcome.ResultType, Outcome: stringValue(outcome.Outcome), OperatorReason: stringValue(outcome.OperatorReason), ErrorCode: stringValue(outcome.ErrorCode), ErrorMessage: stringValue(outcome.ErrorMessage), ErrorStep: stringValue(outcome.ErrorStep), OrderSubmitted: outcome.OrderSubmitted, ReceivedAt: outcome.ReceivedAt, ReceivedAfterExecutionExpiry: outcome.ReceivedAfterExecutionExpiry, } } return result } func prettyAuditJSON(value *string) string { if value == nil || strings.TrimSpace(*value) == "" { return "" } var decoded any if err := json.Unmarshal([]byte(*value), &decoded); err != nil { return "" } formatted, err := json.MarshalIndent(decoded, "", " ") if err != nil { return "" } return string(formatted) } func prettyValueJSON(value any) string { if value == nil { return "" } formatted, err := json.MarshalIndent(value, "", " ") if err != nil || string(formatted) == "null" || string(formatted) == "[]" { return "" } return string(formatted) } func stringValue(value *string) string { if value == nil { return "" } return *value } func mapUsecaseError(err error) error { var typed *usecase.Error if !errors.As(err, &typed) { return err } var public error switch typed.Kind { case usecase.ErrorKindInvalid: if typed.Code == "TASK_CANCEL_INVALID" { public = ErrNotFound } else if typed.Code == "ASSET_TOO_LARGE" || typed.Code == "ASSET_MEDIA_TYPE_UNSUPPORTED" || typed.Code == "ASSET_IMAGE_INVALID" || typed.Code == "ASSET_FILE_REQUIRED" { public = ErrInvalidFile } else { public = ErrValidation } case usecase.ErrorKindNotFound: public = ErrNotFound case usecase.ErrorKindConflict: public = ErrConflict case usecase.ErrorKindUnavailable: public = ErrUnavailable default: return err } return &adapterError{ public: public, cause: err, } } type adapterError struct { public error cause error } func (err *adapterError) Error() string { return err.public.Error() } func (err *adapterError) Unwrap() []error { return []error{err.public, err.cause} } var _ Service = (*UsecaseAdapter)(nil)