1038 lines
31 KiB
Go
1038 lines
31 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.starryskymeow.cn/B309/datamarket/internal/config"
|
|
"gitea.starryskymeow.cn/B309/datamarket/internal/repository"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
const (
|
|
defaultLimit int32 = 20
|
|
maxLimit int32 = 100
|
|
)
|
|
|
|
var (
|
|
allowedAssetStatuses = map[string]struct{}{
|
|
"待评估": {},
|
|
"已上架": {},
|
|
"可交易": {},
|
|
"已下架": {},
|
|
}
|
|
allowedOrderStatuses = map[string]struct{}{
|
|
"待确认": {},
|
|
"已报价": {},
|
|
"验证中": {},
|
|
"已成交": {},
|
|
"已取消": {},
|
|
}
|
|
)
|
|
|
|
type Service struct {
|
|
queries *repository.Queries
|
|
config *config.Config
|
|
now func() time.Time
|
|
}
|
|
|
|
func New(queries *repository.Queries, cfg *config.Config) *Service {
|
|
return &Service{
|
|
queries: queries,
|
|
config: cfg,
|
|
now: time.Now,
|
|
}
|
|
}
|
|
|
|
func (s *Service) CreateAsset(ctx context.Context, input AssetCreateInput) (AssetStatusResult, error) {
|
|
if err := validateAssetInput(input); err != nil {
|
|
return AssetStatusResult{}, err
|
|
}
|
|
|
|
derived, err := buildAssetDerivedFields(input)
|
|
if err != nil {
|
|
return AssetStatusResult{}, err
|
|
}
|
|
|
|
asset, err := s.queries.CreateDataAsset(ctx, repository.CreateDataAssetParams{
|
|
AssetName: strings.TrimSpace(input.AssetName),
|
|
AssetType: strings.TrimSpace(input.AssetType),
|
|
Domain: strings.TrimSpace(input.Domain),
|
|
ApplicationScene: textValue(input.ApplicationScene),
|
|
DataDescription: strings.TrimSpace(input.DataDescription),
|
|
DataScale: strings.TrimSpace(input.DataScale),
|
|
CollectionMethod: strings.TrimSpace(input.CollectionMethod),
|
|
LabelingStatus: textValue(input.LabelingStatus),
|
|
UpdateFrequency: textValue(input.UpdateFrequency),
|
|
PrivacyLevel: strings.TrimSpace(input.PrivacyLevel),
|
|
PermissionMode: strings.TrimSpace(input.PermissionMode),
|
|
SupportsValidation: input.SupportsValidation,
|
|
SellerExpectedPriceMin: numericValue(*input.SellerExpectedPriceMin),
|
|
SellerExpectedPriceMax: numericValue(*input.SellerExpectedPriceMax),
|
|
QualityLevel: textValue(&derived.QualityLevel),
|
|
ScarcityLevel: textValue(&derived.ScarcityLevel),
|
|
BaseValueScore: numericValue(derived.BaseValueScore),
|
|
BasePriceMin: numericValue(derived.BasePriceMin),
|
|
BasePriceMax: numericValue(derived.BasePriceMax),
|
|
AssetStatus: "已上架",
|
|
})
|
|
if err != nil {
|
|
return AssetStatusResult{}, internalError("create asset failed", err)
|
|
}
|
|
|
|
return AssetStatusResult{
|
|
AssetID: asset.ID.String(),
|
|
AssetStatus: asset.AssetStatus,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) GetAsset(ctx context.Context, assetID string) (Asset, error) {
|
|
id, err := parseUUID(assetID)
|
|
if err != nil {
|
|
return Asset{}, err
|
|
}
|
|
|
|
asset, err := s.queries.GetDataAsset(ctx, id)
|
|
if err != nil {
|
|
return Asset{}, handleQueryError(err, "asset not found")
|
|
}
|
|
|
|
return toAsset(asset), nil
|
|
}
|
|
|
|
func (s *Service) ListAssets(ctx context.Context, input AssetListInput) (ListResult[Asset], error) {
|
|
params := buildAssetListParams(input)
|
|
total, err := s.queries.CountDataAssets(ctx, repository.CountDataAssetsParams{
|
|
Keyword: params.Keyword,
|
|
AssetType: params.AssetType,
|
|
Domain: params.Domain,
|
|
PrivacyLevel: params.PrivacyLevel,
|
|
SupportsValidation: params.SupportsValidation,
|
|
})
|
|
if err != nil {
|
|
return ListResult[Asset]{}, internalError("count assets failed", err)
|
|
}
|
|
|
|
items, err := s.queries.ListDataAssets(ctx, params)
|
|
if err != nil {
|
|
return ListResult[Asset]{}, internalError("list assets failed", err)
|
|
}
|
|
|
|
result := ListResult[Asset]{
|
|
List: make([]Asset, 0, len(items)),
|
|
Total: total,
|
|
Limit: params.Limit,
|
|
Offset: params.Offset,
|
|
}
|
|
for _, item := range items {
|
|
result.List = append(result.List, toAsset(item))
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) UpdateAssetStatus(ctx context.Context, assetID string, update StatusUpdate) (AssetStatusResult, error) {
|
|
if strings.TrimSpace(update.Status) == "" {
|
|
return AssetStatusResult{}, badRequest("asset_status is required")
|
|
}
|
|
if _, ok := allowedAssetStatuses[update.Status]; !ok {
|
|
return AssetStatusResult{}, badRequest("asset_status is invalid")
|
|
}
|
|
|
|
id, err := parseUUID(assetID)
|
|
if err != nil {
|
|
return AssetStatusResult{}, err
|
|
}
|
|
|
|
asset, err := s.queries.UpdateDataAssetStatus(ctx, repository.UpdateDataAssetStatusParams{
|
|
ID: id,
|
|
AssetStatus: update.Status,
|
|
})
|
|
if err != nil {
|
|
return AssetStatusResult{}, handleQueryError(err, "asset not found")
|
|
}
|
|
|
|
return AssetStatusResult{
|
|
AssetID: asset.ID.String(),
|
|
AssetStatus: asset.AssetStatus,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) CreatePricing(ctx context.Context, input PricingCreateInput) (Pricing, error) {
|
|
if err := validatePricingInput(input); err != nil {
|
|
return Pricing{}, err
|
|
}
|
|
|
|
assetID, err := parseUUID(input.AssetID)
|
|
if err != nil {
|
|
return Pricing{}, err
|
|
}
|
|
|
|
asset, err := s.queries.GetDataAsset(ctx, assetID)
|
|
if err != nil {
|
|
return Pricing{}, handleQueryError(err, "asset not found")
|
|
}
|
|
|
|
request, err := s.queries.CreateBuyerRequest(ctx, repository.CreateBuyerRequestParams{
|
|
AssetID: assetID,
|
|
TaskType: strings.TrimSpace(input.TaskType),
|
|
ModelType: strings.TrimSpace(input.ModelType),
|
|
BuyerBudgetMin: numericValue(*input.BuyerBudgetMin),
|
|
BuyerBudgetMax: numericValue(*input.BuyerBudgetMax),
|
|
PrivacyRequirement: textValue(input.PrivacyRequirement),
|
|
UsagePurpose: textValue(input.UsagePurpose),
|
|
RequestNote: textValue(input.RequestNote),
|
|
RequestStatus: "已分析",
|
|
})
|
|
if err != nil {
|
|
return Pricing{}, internalError("create buyer request failed", err)
|
|
}
|
|
|
|
derived, err := buildPricingDerivedFields(asset, request)
|
|
if err != nil {
|
|
return Pricing{}, err
|
|
}
|
|
|
|
pricing, err := s.queries.CreatePricingResult(ctx, repository.CreatePricingResultParams{
|
|
AssetID: asset.ID,
|
|
RequestID: request.ID,
|
|
ScenarioValueScore: numericValue(derived.ScenarioValueScore),
|
|
ScenarioPriceMin: numericValue(derived.ScenarioPriceMin),
|
|
ScenarioPriceMax: numericValue(derived.ScenarioPriceMax),
|
|
SuggestedPrice: numericValue(derived.SuggestedPrice),
|
|
SuccessProbability: numericValue(derived.SuccessProbability),
|
|
PricingReason1: textValue(&derived.PricingReason1),
|
|
PricingReason2: textValue(&derived.PricingReason2),
|
|
PricingReason3: textValue(&derived.PricingReason3),
|
|
VerificationSuggestion: textValue(&derived.VerificationSuggestion),
|
|
PricingStatus: "已生成",
|
|
})
|
|
if err != nil {
|
|
return Pricing{}, internalError("create pricing result failed", err)
|
|
}
|
|
|
|
return toPricing(pricing), nil
|
|
}
|
|
|
|
func (s *Service) GetPricing(ctx context.Context, pricingID string) (Pricing, error) {
|
|
id, err := parseUUID(pricingID)
|
|
if err != nil {
|
|
return Pricing{}, err
|
|
}
|
|
|
|
pricing, err := s.queries.GetPricingResult(ctx, id)
|
|
if err != nil {
|
|
return Pricing{}, handleQueryError(err, "pricing result not found")
|
|
}
|
|
|
|
return toPricing(pricing), nil
|
|
}
|
|
|
|
func (s *Service) CreateValidation(ctx context.Context, input ValidationCreateInput) (ValidationCreateResult, error) {
|
|
if strings.TrimSpace(input.ValidationType) == "" {
|
|
return ValidationCreateResult{}, badRequest("validation_type is required")
|
|
}
|
|
|
|
assetID, err := parseUUID(input.AssetID)
|
|
if err != nil {
|
|
return ValidationCreateResult{}, err
|
|
}
|
|
requestID, err := parseUUID(input.RequestID)
|
|
if err != nil {
|
|
return ValidationCreateResult{}, err
|
|
}
|
|
|
|
asset, err := s.queries.GetDataAsset(ctx, assetID)
|
|
if err != nil {
|
|
return ValidationCreateResult{}, handleQueryError(err, "asset not found")
|
|
}
|
|
if !asset.SupportsValidation {
|
|
return ValidationCreateResult{}, badRequest("asset does not support validation")
|
|
}
|
|
|
|
request, err := s.queries.GetBuyerRequest(ctx, requestID)
|
|
if err != nil {
|
|
return ValidationCreateResult{}, handleQueryError(err, "buyer request not found")
|
|
}
|
|
|
|
created, err := s.queries.CreateValidation(ctx, repository.CreateValidationParams{
|
|
AssetID: assetID,
|
|
RequestID: requestID,
|
|
ValidationType: textValue(&input.ValidationType),
|
|
ValidationRequested: true,
|
|
ValidationStatus: "处理中",
|
|
ValidationSignal: pgtype.Text{},
|
|
ValidationScore: pgtype.Numeric{},
|
|
RiskWarning: pgtype.Text{},
|
|
ContinueRecommendation: pgtype.Text{},
|
|
ValidationFinishedAt: pgtype.Timestamptz{},
|
|
})
|
|
if err != nil {
|
|
return ValidationCreateResult{}, internalError("create validation failed", err)
|
|
}
|
|
|
|
derived, err := buildValidationDerivedFields(asset, request)
|
|
if err != nil {
|
|
return ValidationCreateResult{}, err
|
|
}
|
|
|
|
_, err = s.queries.UpdateValidationResult(ctx, repository.UpdateValidationResultParams{
|
|
ID: created.ID,
|
|
ValidationStatus: "已完成",
|
|
ValidationSignal: textValue(&derived.ValidationSignal),
|
|
ValidationScore: numericValue(derived.ValidationScore),
|
|
RiskWarning: textValue(&derived.RiskWarning),
|
|
ContinueRecommendation: textValue(&derived.ContinueRecommendation),
|
|
ValidationFinishedAt: timestamptzValue(s.now()),
|
|
})
|
|
if err != nil {
|
|
return ValidationCreateResult{}, internalError("finalize validation failed", err)
|
|
}
|
|
|
|
return ValidationCreateResult{
|
|
ValidationID: created.ID.String(),
|
|
ValidationStatus: "处理中",
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) GetValidation(ctx context.Context, validationID string) (Validation, error) {
|
|
id, err := parseUUID(validationID)
|
|
if err != nil {
|
|
return Validation{}, err
|
|
}
|
|
|
|
validation, err := s.queries.GetValidation(ctx, id)
|
|
if err != nil {
|
|
return Validation{}, handleQueryError(err, "validation not found")
|
|
}
|
|
|
|
return toValidation(validation), nil
|
|
}
|
|
|
|
func (s *Service) ListValidations(ctx context.Context, input ValidationListInput) (ListResult[Validation], error) {
|
|
params := normalizePage(input.Limit, input.Offset)
|
|
|
|
total, err := s.queries.CountValidations(ctx)
|
|
if err != nil {
|
|
return ListResult[Validation]{}, internalError("count validations failed", err)
|
|
}
|
|
|
|
items, err := s.queries.ListValidations(ctx, repository.ListValidationsParams{
|
|
Limit: params.Limit,
|
|
Offset: params.Offset,
|
|
})
|
|
if err != nil {
|
|
return ListResult[Validation]{}, internalError("list validations failed", err)
|
|
}
|
|
|
|
result := ListResult[Validation]{
|
|
List: make([]Validation, 0, len(items)),
|
|
Total: total,
|
|
Limit: params.Limit,
|
|
Offset: params.Offset,
|
|
}
|
|
for _, item := range items {
|
|
result.List = append(result.List, toValidation(item))
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) CreateOrder(ctx context.Context, input OrderCreateInput) (OrderCreateResult, error) {
|
|
if err := validateOrderInput(input); err != nil {
|
|
return OrderCreateResult{}, err
|
|
}
|
|
|
|
assetID, err := parseUUID(input.AssetID)
|
|
if err != nil {
|
|
return OrderCreateResult{}, err
|
|
}
|
|
requestID, err := parseUUID(input.RequestID)
|
|
if err != nil {
|
|
return OrderCreateResult{}, err
|
|
}
|
|
pricingID, err := parseUUID(input.PricingID)
|
|
if err != nil {
|
|
return OrderCreateResult{}, err
|
|
}
|
|
|
|
asset, err := s.queries.GetDataAsset(ctx, assetID)
|
|
if err != nil {
|
|
return OrderCreateResult{}, handleQueryError(err, "asset not found")
|
|
}
|
|
if _, err := s.queries.GetBuyerRequest(ctx, requestID); err != nil {
|
|
return OrderCreateResult{}, handleQueryError(err, "buyer request not found")
|
|
}
|
|
pricing, err := s.queries.GetPricingResult(ctx, pricingID)
|
|
if err != nil {
|
|
return OrderCreateResult{}, handleQueryError(err, "pricing result not found")
|
|
}
|
|
|
|
validationID := pgtype.UUID{}
|
|
validationUsed := false
|
|
if input.ValidationID != nil && strings.TrimSpace(*input.ValidationID) != "" {
|
|
validationID, err = parseUUID(*input.ValidationID)
|
|
if err != nil {
|
|
return OrderCreateResult{}, err
|
|
}
|
|
if _, err := s.queries.GetValidation(ctx, validationID); err != nil {
|
|
return OrderCreateResult{}, handleQueryError(err, "validation not found")
|
|
}
|
|
validationUsed = true
|
|
}
|
|
|
|
negotiationMin, negotiationMax, err := buildNegotiationRange(pricing, *input.CurrentPrice)
|
|
if err != nil {
|
|
return OrderCreateResult{}, err
|
|
}
|
|
|
|
order, err := s.queries.CreateOrder(ctx, repository.CreateOrderParams{
|
|
AssetID: asset.ID,
|
|
RequestID: requestID,
|
|
PricingID: pricingID,
|
|
ValidationID: validationID,
|
|
AssetName: asset.AssetName,
|
|
CurrentPrice: numericValue(*input.CurrentPrice),
|
|
NegotiationMin: numericValue(negotiationMin),
|
|
NegotiationMax: numericValue(negotiationMax),
|
|
ValidationUsed: validationUsed,
|
|
DeliveryMode: strings.TrimSpace(input.DeliveryMode),
|
|
OrderStatus: "待确认",
|
|
})
|
|
if err != nil {
|
|
return OrderCreateResult{}, internalError("create order failed", err)
|
|
}
|
|
|
|
return OrderCreateResult{
|
|
OrderID: order.ID.String(),
|
|
OrderStatus: order.OrderStatus,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) GetOrder(ctx context.Context, orderID string) (Order, error) {
|
|
id, err := parseUUID(orderID)
|
|
if err != nil {
|
|
return Order{}, err
|
|
}
|
|
|
|
order, err := s.queries.GetOrder(ctx, id)
|
|
if err != nil {
|
|
return Order{}, handleQueryError(err, "order not found")
|
|
}
|
|
|
|
return toOrder(order), nil
|
|
}
|
|
|
|
func buildOrderListParams(input OrderListInput) repository.ListOrdersParams {
|
|
params := repository.ListOrdersParams{}
|
|
page := normalizePage(input.Limit, input.Offset)
|
|
params.Limit, params.Offset = page.Limit, page.Offset
|
|
params.OrderStatus = textValue(&input.OrderStatus)
|
|
return params
|
|
}
|
|
|
|
func (s *Service) ListOrders(ctx context.Context, input OrderListInput) (ListResult[Order], error) {
|
|
params := buildOrderListParams(input)
|
|
|
|
total, err := s.queries.CountOrders(ctx, params.OrderStatus)
|
|
if err != nil {
|
|
return ListResult[Order]{}, internalError("count orders failed", err)
|
|
}
|
|
|
|
items, err := s.queries.ListOrders(ctx, params)
|
|
if err != nil {
|
|
return ListResult[Order]{}, internalError("list orders failed", err)
|
|
}
|
|
|
|
result := ListResult[Order]{
|
|
List: make([]Order, 0, len(items)),
|
|
Total: total,
|
|
Limit: params.Limit,
|
|
Offset: params.Offset,
|
|
}
|
|
for _, item := range items {
|
|
result.List = append(result.List, toOrder(item))
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) UpdateOrderStatus(ctx context.Context, orderID string, update StatusUpdate) (OrderCreateResult, error) {
|
|
if strings.TrimSpace(update.Status) == "" {
|
|
return OrderCreateResult{}, badRequest("order_status is required")
|
|
}
|
|
if _, ok := allowedOrderStatuses[update.Status]; !ok {
|
|
return OrderCreateResult{}, badRequest("order_status is invalid")
|
|
}
|
|
|
|
id, err := parseUUID(orderID)
|
|
if err != nil {
|
|
return OrderCreateResult{}, err
|
|
}
|
|
|
|
order, err := s.queries.UpdateOrderStatus(ctx, repository.UpdateOrderStatusParams{
|
|
ID: id,
|
|
OrderStatus: update.Status,
|
|
})
|
|
if err != nil {
|
|
return OrderCreateResult{}, handleQueryError(err, "order not found")
|
|
}
|
|
|
|
return OrderCreateResult{
|
|
OrderID: order.ID.String(),
|
|
OrderStatus: order.OrderStatus,
|
|
}, nil
|
|
}
|
|
|
|
type normalizedPage struct {
|
|
Limit int32
|
|
Offset int32
|
|
}
|
|
|
|
type derivedAssetFields struct {
|
|
QualityLevel string
|
|
ScarcityLevel string
|
|
BaseValueScore float64
|
|
BasePriceMin float64
|
|
BasePriceMax float64
|
|
}
|
|
|
|
type derivedPricingFields struct {
|
|
ScenarioValueScore float64
|
|
ScenarioPriceMin float64
|
|
ScenarioPriceMax float64
|
|
SuggestedPrice float64
|
|
SuccessProbability float64
|
|
PricingReason1 string
|
|
PricingReason2 string
|
|
PricingReason3 string
|
|
VerificationSuggestion string
|
|
}
|
|
|
|
type derivedValidationFields struct {
|
|
ValidationSignal string
|
|
ValidationScore float64
|
|
RiskWarning string
|
|
ContinueRecommendation string
|
|
}
|
|
|
|
func buildAssetListParams(input AssetListInput) repository.ListDataAssetsParams {
|
|
page := normalizePage(input.Limit, input.Offset)
|
|
|
|
return repository.ListDataAssetsParams{
|
|
Limit: page.Limit,
|
|
Offset: page.Offset,
|
|
Keyword: optionalText(input.Keyword),
|
|
AssetType: optionalText(input.AssetType),
|
|
Domain: optionalText(input.Domain),
|
|
PrivacyLevel: optionalText(input.PrivacyLevel),
|
|
SupportsValidation: optionalBool(input.SupportsValidation),
|
|
}
|
|
}
|
|
|
|
func normalizePage(limit, offset int32) normalizedPage {
|
|
switch {
|
|
case limit <= 0:
|
|
limit = defaultLimit
|
|
case limit > maxLimit:
|
|
limit = maxLimit
|
|
}
|
|
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
|
|
return normalizedPage{
|
|
Limit: limit,
|
|
Offset: offset,
|
|
}
|
|
}
|
|
|
|
func validateAssetInput(input AssetCreateInput) error {
|
|
switch {
|
|
case strings.TrimSpace(input.AssetName) == "":
|
|
return badRequest("asset_name is required")
|
|
case strings.TrimSpace(input.AssetType) == "":
|
|
return badRequest("asset_type is required")
|
|
case strings.TrimSpace(input.Domain) == "":
|
|
return badRequest("domain is required")
|
|
case strings.TrimSpace(input.DataDescription) == "":
|
|
return badRequest("data_description is required")
|
|
case strings.TrimSpace(input.DataScale) == "":
|
|
return badRequest("data_scale is required")
|
|
case strings.TrimSpace(input.CollectionMethod) == "":
|
|
return badRequest("collection_method is required")
|
|
case strings.TrimSpace(input.PrivacyLevel) == "":
|
|
return badRequest("privacy_level is required")
|
|
case strings.TrimSpace(input.PermissionMode) == "":
|
|
return badRequest("permission_mode is required")
|
|
case input.SellerExpectedPriceMin == nil:
|
|
return badRequest("seller_expected_price_min is required")
|
|
case input.SellerExpectedPriceMax == nil:
|
|
return badRequest("seller_expected_price_max is required")
|
|
case *input.SellerExpectedPriceMin < 0 || *input.SellerExpectedPriceMax < 0:
|
|
return badRequest("seller expected prices must be non-negative")
|
|
case *input.SellerExpectedPriceMax < *input.SellerExpectedPriceMin:
|
|
return badRequest("seller_expected_price_max must be greater than or equal to seller_expected_price_min")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validatePricingInput(input PricingCreateInput) error {
|
|
switch {
|
|
case strings.TrimSpace(input.AssetID) == "":
|
|
return badRequest("asset_id is required")
|
|
case strings.TrimSpace(input.TaskType) == "":
|
|
return badRequest("task_type is required")
|
|
case strings.TrimSpace(input.ModelType) == "":
|
|
return badRequest("model_type is required")
|
|
case input.BuyerBudgetMin == nil:
|
|
return badRequest("buyer_budget_min is required")
|
|
case input.BuyerBudgetMax == nil:
|
|
return badRequest("buyer_budget_max is required")
|
|
case *input.BuyerBudgetMin < 0 || *input.BuyerBudgetMax < 0:
|
|
return badRequest("buyer budgets must be non-negative")
|
|
case *input.BuyerBudgetMax < *input.BuyerBudgetMin:
|
|
return badRequest("buyer_budget_max must be greater than or equal to buyer_budget_min")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateOrderInput(input OrderCreateInput) error {
|
|
switch {
|
|
case strings.TrimSpace(input.AssetID) == "":
|
|
return badRequest("asset_id is required")
|
|
case strings.TrimSpace(input.RequestID) == "":
|
|
return badRequest("request_id is required")
|
|
case strings.TrimSpace(input.PricingID) == "":
|
|
return badRequest("pricing_id is required")
|
|
case input.CurrentPrice == nil:
|
|
return badRequest("current_price is required")
|
|
case *input.CurrentPrice < 0:
|
|
return badRequest("current_price must be non-negative")
|
|
case strings.TrimSpace(input.DeliveryMode) == "":
|
|
return badRequest("delivery_mode is required")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func buildAssetDerivedFields(input AssetCreateInput) (derivedAssetFields, error) {
|
|
baseMin := *input.SellerExpectedPriceMin
|
|
baseMax := *input.SellerExpectedPriceMax
|
|
midPrice := (baseMin + baseMax) / 2
|
|
|
|
qualityScore := 60.0
|
|
switch {
|
|
case strings.Contains(input.CollectionMethod, "真实"):
|
|
qualityScore += 10
|
|
case strings.Contains(input.CollectionMethod, "混合"):
|
|
qualityScore += 6
|
|
default:
|
|
qualityScore += 3
|
|
}
|
|
|
|
switch {
|
|
case input.LabelingStatus != nil && strings.Contains(*input.LabelingStatus, "完整"):
|
|
qualityScore += 8
|
|
case input.LabelingStatus != nil && strings.Contains(*input.LabelingStatus, "部分"):
|
|
qualityScore += 4
|
|
}
|
|
|
|
if input.SupportsValidation {
|
|
qualityScore += 5
|
|
}
|
|
if strings.Contains(input.PrivacyLevel, "低") {
|
|
qualityScore += 4
|
|
}
|
|
|
|
scarcityScore := 12.0
|
|
switch strings.TrimSpace(input.Domain) {
|
|
case "机器人", "医疗", "制造":
|
|
scarcityScore = 18
|
|
case "金融", "教育":
|
|
scarcityScore = 15
|
|
}
|
|
if strings.Contains(strings.TrimSpace(input.AssetType), "多模态") || strings.Contains(strings.TrimSpace(input.AssetType), "视频") {
|
|
scarcityScore += 4
|
|
}
|
|
|
|
score := clamp(qualityScore+scarcityScore+midPrice/10000, 55, 95)
|
|
|
|
qualityLevel := "中"
|
|
if score >= 82 {
|
|
qualityLevel = "高"
|
|
} else if score <= 68 {
|
|
qualityLevel = "基础"
|
|
}
|
|
|
|
scarcityLevel := "中"
|
|
if scarcityScore >= 18 {
|
|
scarcityLevel = "高"
|
|
} else if scarcityScore <= 12 {
|
|
scarcityLevel = "一般"
|
|
}
|
|
|
|
buffer := 0.08
|
|
if input.SupportsValidation {
|
|
buffer = 0.05
|
|
}
|
|
|
|
return derivedAssetFields{
|
|
QualityLevel: qualityLevel,
|
|
ScarcityLevel: scarcityLevel,
|
|
BaseValueScore: round2(score),
|
|
BasePriceMin: round2(baseMin * (1 - buffer)),
|
|
BasePriceMax: round2(baseMax * (1 + buffer)),
|
|
}, nil
|
|
}
|
|
|
|
// TODO: use real agent
|
|
func buildPricingDerivedFields(asset repository.DataAsset, request repository.BuyerRequest) (derivedPricingFields, error) {
|
|
baseMin, err := numericToFloat(asset.BasePriceMin)
|
|
if err != nil {
|
|
return derivedPricingFields{}, badRequest("asset base_price_min is invalid")
|
|
}
|
|
baseMax, err := numericToFloat(asset.BasePriceMax)
|
|
if err != nil {
|
|
return derivedPricingFields{}, badRequest("asset base_price_max is invalid")
|
|
}
|
|
budgetMin, err := numericToFloat(request.BuyerBudgetMin)
|
|
if err != nil {
|
|
return derivedPricingFields{}, badRequest("buyer_budget_min is invalid")
|
|
}
|
|
budgetMax, err := numericToFloat(request.BuyerBudgetMax)
|
|
if err != nil {
|
|
return derivedPricingFields{}, badRequest("buyer_budget_max is invalid")
|
|
}
|
|
baseScore, err := numericToFloat(asset.BaseValueScore)
|
|
if err != nil {
|
|
baseScore = 70
|
|
}
|
|
|
|
matchBonus := 6.0
|
|
if request.PrivacyRequirement.Valid && request.PrivacyRequirement.String == asset.PrivacyLevel {
|
|
matchBonus += 4
|
|
}
|
|
if request.UsagePurpose.Valid && strings.Contains(request.UsagePurpose.String, "微调") {
|
|
matchBonus += 2
|
|
}
|
|
if strings.Contains(request.TaskType, "抓取") || strings.Contains(request.TaskType, "视觉") {
|
|
matchBonus += 3
|
|
}
|
|
|
|
scenarioScore := clamp(baseScore+matchBonus, 60, 97)
|
|
|
|
priceFloor := math.Max(baseMin, budgetMin*0.95)
|
|
priceCeil := math.Max(priceFloor+1000, math.Min(baseMax*1.12, budgetMax*1.08))
|
|
suggested := clamp((priceFloor+priceCeil)/2, priceFloor, priceCeil)
|
|
successProbability := clamp(0.58+(budgetMax-suggested)/math.Max(suggested, 1)*0.35, 0.35, 0.92)
|
|
|
|
verificationSuggestion := "建议验证"
|
|
if asset.SupportsValidation && suggested <= budgetMax {
|
|
verificationSuggestion = "建议先验证后成交"
|
|
} else if !asset.SupportsValidation {
|
|
verificationSuggestion = "可直接进入询价"
|
|
}
|
|
|
|
return derivedPricingFields{
|
|
ScenarioValueScore: round2(scenarioScore),
|
|
ScenarioPriceMin: round2(priceFloor),
|
|
ScenarioPriceMax: round2(priceCeil),
|
|
SuggestedPrice: round2(suggested),
|
|
SuccessProbability: round4(successProbability),
|
|
PricingReason1: fmt.Sprintf("任务类型“%s”与该资产场景匹配度较高", request.TaskType),
|
|
PricingReason2: fmt.Sprintf("资产基础估值区间为 %.0f - %.0f", baseMin, baseMax),
|
|
PricingReason3: fmt.Sprintf("买家预算区间为 %.0f - %.0f,当前建议价格可接受", budgetMin, budgetMax),
|
|
VerificationSuggestion: verificationSuggestion,
|
|
}, nil
|
|
}
|
|
|
|
// TODO: use real agent
|
|
func buildValidationDerivedFields(asset repository.DataAsset, request repository.BuyerRequest) (derivedValidationFields, error) {
|
|
score := 0.74
|
|
signal := "正向"
|
|
riskWarning := "风险可控,建议继续推进"
|
|
recommendation := "建议继续成交"
|
|
|
|
if strings.Contains(asset.PrivacyLevel, "高") {
|
|
score -= 0.08
|
|
riskWarning = "隐私等级较高,建议采用授权访问与受控验证"
|
|
}
|
|
if request.PrivacyRequirement.Valid && strings.Contains(request.PrivacyRequirement.String, "高") {
|
|
score += 0.04
|
|
}
|
|
if !asset.SupportsValidation {
|
|
score -= 0.12
|
|
signal = "谨慎"
|
|
recommendation = "建议补充验证条件后再推进"
|
|
}
|
|
if score < 0.68 {
|
|
signal = "谨慎"
|
|
}
|
|
|
|
return derivedValidationFields{
|
|
ValidationSignal: signal,
|
|
ValidationScore: round4(clamp(score, 0.4, 0.95)),
|
|
RiskWarning: riskWarning,
|
|
ContinueRecommendation: recommendation,
|
|
}, nil
|
|
}
|
|
|
|
// TODO: use real agent
|
|
func buildNegotiationRange(pricing repository.PricingResult, currentPrice float64) (float64, float64, error) {
|
|
priceMin, err := numericToFloat(pricing.ScenarioPriceMin)
|
|
if err != nil {
|
|
return 0, 0, badRequest("pricing scenario_price_min is invalid")
|
|
}
|
|
priceMax, err := numericToFloat(pricing.ScenarioPriceMax)
|
|
if err != nil {
|
|
return 0, 0, badRequest("pricing scenario_price_max is invalid")
|
|
}
|
|
|
|
min := math.Max(priceMin, currentPrice*0.92)
|
|
max := math.Min(priceMax, currentPrice*1.08)
|
|
if max < min {
|
|
max = min
|
|
}
|
|
|
|
return round2(min), round2(max), nil
|
|
}
|
|
|
|
func handleQueryError(err error, notFoundMessage string) error {
|
|
switch {
|
|
case errors.Is(err, pgx.ErrNoRows):
|
|
return notFound(notFoundMessage)
|
|
default:
|
|
return internalError("database query failed", err)
|
|
}
|
|
}
|
|
|
|
func parseUUID(value string) (pgtype.UUID, error) {
|
|
var id pgtype.UUID
|
|
if err := id.Scan(strings.TrimSpace(value)); err != nil {
|
|
return pgtype.UUID{}, badRequest("invalid id")
|
|
}
|
|
|
|
return id, nil
|
|
}
|
|
|
|
func textValue(value *string) pgtype.Text {
|
|
if value == nil {
|
|
return pgtype.Text{}
|
|
}
|
|
|
|
trimmed := strings.TrimSpace(*value)
|
|
if trimmed == "" {
|
|
return pgtype.Text{}
|
|
}
|
|
|
|
return pgtype.Text{
|
|
String: trimmed,
|
|
Valid: true,
|
|
}
|
|
}
|
|
|
|
func optionalText(value *string) pgtype.Text {
|
|
if value == nil {
|
|
return pgtype.Text{}
|
|
}
|
|
|
|
return textValue(value)
|
|
}
|
|
|
|
func optionalBool(value *bool) pgtype.Bool {
|
|
if value == nil {
|
|
return pgtype.Bool{}
|
|
}
|
|
|
|
return pgtype.Bool{
|
|
Bool: *value,
|
|
Valid: true,
|
|
}
|
|
}
|
|
|
|
func numericValue(value float64) pgtype.Numeric {
|
|
var numeric pgtype.Numeric
|
|
_ = numeric.ScanScientific(strconv.FormatFloat(round4(value), 'f', -1, 64))
|
|
return numeric
|
|
}
|
|
|
|
func numericToFloat(value pgtype.Numeric) (float64, error) {
|
|
floatValue, err := value.Float64Value()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if !floatValue.Valid {
|
|
return 0, errors.New("numeric value is null")
|
|
}
|
|
|
|
return floatValue.Float64, nil
|
|
}
|
|
|
|
func timestamptzValue(value time.Time) pgtype.Timestamptz {
|
|
return pgtype.Timestamptz{
|
|
Time: value.UTC(),
|
|
Valid: true,
|
|
}
|
|
}
|
|
|
|
func toAsset(asset repository.DataAsset) Asset {
|
|
return Asset{
|
|
ID: asset.ID.String(),
|
|
AssetName: asset.AssetName,
|
|
AssetType: asset.AssetType,
|
|
Domain: asset.Domain,
|
|
ApplicationScene: toTextPointer(asset.ApplicationScene),
|
|
DataDescription: asset.DataDescription,
|
|
DataScale: asset.DataScale,
|
|
CollectionMethod: asset.CollectionMethod,
|
|
LabelingStatus: toTextPointer(asset.LabelingStatus),
|
|
UpdateFrequency: toTextPointer(asset.UpdateFrequency),
|
|
PrivacyLevel: asset.PrivacyLevel,
|
|
PermissionMode: asset.PermissionMode,
|
|
SupportsValidation: asset.SupportsValidation,
|
|
SellerExpectedPriceMin: toNumericPointer(asset.SellerExpectedPriceMin),
|
|
SellerExpectedPriceMax: toNumericPointer(asset.SellerExpectedPriceMax),
|
|
QualityLevel: toTextPointer(asset.QualityLevel),
|
|
ScarcityLevel: toTextPointer(asset.ScarcityLevel),
|
|
BaseValueScore: toNumericPointer(asset.BaseValueScore),
|
|
BasePriceMin: toNumericPointer(asset.BasePriceMin),
|
|
BasePriceMax: toNumericPointer(asset.BasePriceMax),
|
|
AssetStatus: asset.AssetStatus,
|
|
CreatedAt: toTimePointer(asset.CreatedAt),
|
|
UpdatedAt: toTimePointer(asset.UpdatedAt),
|
|
AgentAssetSummary: toTextPointer(asset.AgentAssetSummary),
|
|
AgentRecommendedTasks: toStringArray(asset.AgentRecommendedTasks),
|
|
AgentRiskPerMissionAdvice: toTextPointer(asset.AgentRiskPerMissionAdvice),
|
|
AgentAssetExplanation: toTextPointer(asset.AgentAssetExplanation),
|
|
}
|
|
}
|
|
|
|
func toPricing(pricing repository.PricingResult) Pricing {
|
|
return Pricing{
|
|
ID: pricing.ID.String(),
|
|
AssetID: pricing.AssetID.String(),
|
|
RequestID: pricing.RequestID.String(),
|
|
ScenarioValueScore: toNumericPointer(pricing.ScenarioValueScore),
|
|
ScenarioPriceMin: toNumericPointer(pricing.ScenarioPriceMin),
|
|
ScenarioPriceMax: toNumericPointer(pricing.ScenarioPriceMax),
|
|
SuggestedPrice: toNumericPointer(pricing.SuggestedPrice),
|
|
SuccessProbability: toNumericPointer(pricing.SuccessProbability),
|
|
PricingReason1: toTextPointer(pricing.PricingReason1),
|
|
PricingReason2: toTextPointer(pricing.PricingReason2),
|
|
PricingReason3: toTextPointer(pricing.PricingReason3),
|
|
VerificationSuggestion: toTextPointer(pricing.VerificationSuggestion),
|
|
PricingStatus: pricing.PricingStatus,
|
|
CreatedAt: toTimePointer(pricing.CreatedAt),
|
|
UpdatedAt: toTimePointer(pricing.UpdatedAt),
|
|
}
|
|
}
|
|
|
|
func toValidation(validation repository.Validation) Validation {
|
|
return Validation{
|
|
ID: validation.ID.String(),
|
|
AssetID: validation.AssetID.String(),
|
|
RequestID: validation.RequestID.String(),
|
|
ValidationType: toTextPointer(validation.ValidationType),
|
|
ValidationRequested: validation.ValidationRequested,
|
|
ValidationStatus: validation.ValidationStatus,
|
|
ValidationSignal: toTextPointer(validation.ValidationSignal),
|
|
ValidationScore: toNumericPointer(validation.ValidationScore),
|
|
RiskWarning: toTextPointer(validation.RiskWarning),
|
|
ContinueRecommendation: toTextPointer(validation.ContinueRecommendation),
|
|
ValidationCreatedAt: toTimePointer(validation.ValidationCreatedAt),
|
|
ValidationFinishedAt: toTimePointer(validation.ValidationFinishedAt),
|
|
}
|
|
}
|
|
|
|
func toOrder(order repository.Order) Order {
|
|
validationID := ""
|
|
if order.ValidationID.Valid {
|
|
validationID = order.ValidationID.String()
|
|
}
|
|
|
|
return Order{
|
|
ID: order.ID.String(),
|
|
AssetID: order.AssetID.String(),
|
|
RequestID: order.RequestID.String(),
|
|
PricingID: order.PricingID.String(),
|
|
ValidationID: validationID,
|
|
AssetName: order.AssetName,
|
|
CurrentPrice: toNumericPointer(order.CurrentPrice),
|
|
NegotiationMin: toNumericPointer(order.NegotiationMin),
|
|
NegotiationMax: toNumericPointer(order.NegotiationMax),
|
|
ValidationUsed: order.ValidationUsed,
|
|
DeliveryMode: order.DeliveryMode,
|
|
OrderStatus: order.OrderStatus,
|
|
OrderCreatedAt: toTimePointer(order.OrderCreatedAt),
|
|
OrderUpdatedAt: toTimePointer(order.OrderUpdatedAt),
|
|
}
|
|
}
|
|
|
|
func toTextPointer(value pgtype.Text) *string {
|
|
if !value.Valid {
|
|
return nil
|
|
}
|
|
|
|
return new(value.String)
|
|
}
|
|
|
|
func toStringArray(value []byte) []string {
|
|
var result []string
|
|
if err := json.Unmarshal(value, &result); err != nil {
|
|
slog.Warn(err.Error(), "value", string(value))
|
|
}
|
|
return result
|
|
}
|
|
|
|
func toNumericPointer(value pgtype.Numeric) *float64 {
|
|
if !value.Valid {
|
|
return nil
|
|
}
|
|
|
|
floatValue, err := value.Float64Value()
|
|
if err != nil || !floatValue.Valid {
|
|
return nil
|
|
}
|
|
|
|
return new(round4(floatValue.Float64))
|
|
}
|
|
|
|
func toTimePointer(value pgtype.Timestamptz) *time.Time {
|
|
if !value.Valid {
|
|
return nil
|
|
}
|
|
|
|
return new(value.Time)
|
|
}
|
|
|
|
func clamp(value, min, max float64) float64 {
|
|
if value < min {
|
|
return min
|
|
}
|
|
if value > max {
|
|
return max
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
func round2(value float64) float64 {
|
|
return math.Round(value*100) / 100
|
|
}
|
|
|
|
func round4(value float64) float64 {
|
|
return math.Round(value*10000) / 10000
|
|
}
|