Files
cmroubao/backend-api/internal/transport/webui/usecase_adapter.go
T

446 lines
12 KiB
Go

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
authorizations *usecase.OrderAuthorizationService
}
func NewUsecaseAdapter(
tasks *usecase.TaskService,
assets *usecase.AssetService,
authorizations *usecase.OrderAuthorizationService,
) (*UsecaseAdapter, error) {
if tasks == nil || assets == nil || authorizations == nil {
return nil, errors.New("admin web use cases are required")
}
return &UsecaseAdapter{
tasks: tasks,
assets: assets,
authorizations: authorizations,
}, 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 (adapter *UsecaseAdapter) AuthorizeOrder(
ctx context.Context,
input AuthorizeOrderInput,
) (OrderAuthorization, error) {
detail, err := adapter.tasks.Get(ctx, localAdminSubject, input.TaskID)
if err != nil {
return OrderAuthorization{}, mapUsecaseError(err)
}
if detail.Task.Version != input.ExpectedTaskVersion ||
detail.Execution == nil ||
detail.Report == nil ||
detail.Report.DecisionDataset == nil {
return OrderAuthorization{}, ErrConflict
}
var supersedes *string
if input.SupersedesAuthorizationID != "" {
value := input.SupersedesAuthorizationID
supersedes = &value
}
items := make(
[]usecase.OrderAuthorizationItemInput,
0,
len(detail.Report.DecisionDataset.Observations),
)
for _, observation := range detail.Report.DecisionDataset.Observations {
if observation.Identity == nil {
return OrderAuthorization{}, ErrConflict
}
label := "REJECT"
reason := input.RejectedReasonCode
if observation.Identity.CandidateKey == input.CandidateKey {
label = "ACCEPT"
reason = input.SelectedReasonCode
}
items = append(items, usecase.OrderAuthorizationItemInput{
CandidateKey: observation.Identity.CandidateKey,
Label: label,
PrimaryReasonCode: reason,
ReasonCodes: []string{reason},
})
}
result, err := adapter.authorizations.Create(
ctx,
usecase.CreateOrderAuthorizationCommand{
ActorUserID: actorUserID(ctx),
TaskID: input.TaskID,
IdempotencyKey: input.IdempotencyKey,
ExecutionID: detail.Execution.ID,
TaskContentSHA256: usecase.TaskContentSHA256(detail.Task),
ExpectedTaskVersion: input.ExpectedTaskVersion,
CandidateKey: input.CandidateKey,
ReasonSchemaVersion: 1,
PrimaryReasonCode: "SELECTED_BEST_MATCH",
Note: input.Note,
SupersedesAuthorizationID: supersedes,
Items: items,
},
)
if err != nil {
return OrderAuthorization{}, mapUsecaseError(err)
}
return orderAuthorizationFrom(result.Authorization), 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.Version = detail.Task.Version
task.ReferenceAssetID = detail.Asset.ID
task.ExecutionReport = executionReportFrom(detail.Report)
task.TaskContentSHA256 = usecase.TaskContentSHA256(detail.Task)
if detail.Execution != nil {
task.ExecutionID = detail.Execution.ID
}
if detail.Report != nil && detail.Report.DecisionDataset != nil {
for _, observation := range detail.Report.DecisionDataset.Observations {
if observation.Identity == nil {
continue
}
candidate := AuthorizationCandidate{
CandidateKey: observation.Identity.CandidateKey,
Ordinal: observation.Ordinal,
Title: observation.Title,
SKUText: observation.SKUText,
PriceText: observation.PriceText,
EvidenceURLs: make([]string, 0, len(observation.EvidenceAssetIDs)),
}
for _, evidenceID := range observation.EvidenceAssetIDs {
candidate.EvidenceURLs = append(
candidate.EvidenceURLs,
"/api/v1/tasks/"+detail.Task.ID+
"/evidence/"+evidenceID+"/content",
)
}
task.Candidates = append(task.Candidates, candidate)
}
}
for _, authorization := range detail.OrderAuthorizations {
task.OrderAuthorizations = append(
task.OrderAuthorizations,
orderAuthorizationFrom(authorization),
)
}
return task
}
func orderAuthorizationFrom(
authorization domain.OrderAuthorization,
) OrderAuthorization {
result := OrderAuthorization{
ID: authorization.ID,
Version: authorization.AuthorizationVersion,
CandidateKey: authorization.CandidateKey,
CandidateSKUText: authorization.CandidateSKUText,
CandidatePriceText: authorization.CandidatePriceText,
Quantity: authorization.Quantity,
Status: string(authorization.Status),
CreatedAt: authorization.CreatedAt,
}
if authorization.SupersedesAuthorizationID != nil {
result.SupersedesID = *authorization.SupersedesAuthorizationID
}
return result
}
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)