diff --git a/cmd/api/main.go b/cmd/api/main.go index bceb268..5e3f2fd 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -5,12 +5,10 @@ import ( "log" "net/http" "os" - "time" - "gitea.starryskymeow.cn/B309/datamarket/internal/handler" "gitea.starryskymeow.cn/B309/datamarket/internal/repository" - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" + "gitea.starryskymeow.cn/B309/datamarket/internal/router" + "gitea.starryskymeow.cn/B309/datamarket/internal/service" "github.com/jackc/pgx/v5/pgxpool" ) @@ -29,25 +27,16 @@ func main() { } queries := repository.New(pool) + appService := service.New(queries) + r := router.New(appService) - // route - r := chi.NewRouter() + addr := os.Getenv("SERVER_ADDR") - r.Use(middleware.RequestID) - r.Use(middleware.RealIP) - r.Use(middleware.Logger) - r.Use(middleware.Recoverer) - r.Use(middleware.Timeout(60 * time.Second)) + if addr == "" { + addr = ":8080" + } - r.Route("/api", func(r chi.Router) { - r.Route("/assets", func(r chi.Router) { - r.Post("/", handler.CreateDataAsset(queries)) - r.Get("/", handler.ListDataAssets(queries)) - r.Get("/{id}", handler.GetDataAsset(queries)) - }) - }) - - err = http.ListenAndServe(":8080", r) + err = http.ListenAndServe(addr, r) if err != nil { log.Fatal(err) } diff --git a/db/query/market.sql b/db/query/market.sql index 69219e8..e45537c 100644 --- a/db/query/market.sql +++ b/db/query/market.sql @@ -1,22 +1,64 @@ -- name: CreateDataAsset :one -INSERT INTO data_assets (asset_name, asset_type, domain, application_scene, - data_description, data_scale, collection_method, labeling_status, - update_frequency, privacy_level, permission_mode, supports_validation, - seller_expected_price_min, seller_expected_price_max, asset_status) -VALUES ($1, $2, $3, $4, - $5, $6, $7, $8, - $9, $10, $11, $12, - $13, $14, $15) -RETURNING *; +INSERT INTO data_assets ( + asset_name, + asset_type, + domain, + application_scene, + data_description, + data_scale, + collection_method, + labeling_status, + update_frequency, + privacy_level, + permission_mode, + supports_validation, + seller_expected_price_min, + seller_expected_price_max, + quality_level, + scarcity_level, + base_value_score, + base_price_min, + base_price_max, + asset_status +) +VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, $16, $17, $18, $19, $20 +) +RETURNING id, asset_name, asset_type, domain, application_scene, data_description, data_scale, collection_method, labeling_status, update_frequency, privacy_level, permission_mode, supports_validation, seller_expected_price_min, seller_expected_price_max, quality_level, scarcity_level, base_value_score, base_price_min, base_price_max, asset_status, created_at, updated_at; +-- name: CountDataAssets :one +SELECT COUNT(*) +FROM data_assets +WHERE ( + NULLIF(sqlc.narg(keyword)::text, '') IS NULL + OR asset_name ILIKE '%' || sqlc.narg(keyword)::text || '%' + OR data_description ILIKE '%' || sqlc.narg(keyword)::text || '%' + ) + AND ( + NULLIF(sqlc.narg(asset_type)::text, '') IS NULL + OR asset_type = sqlc.narg(asset_type)::text + ) + AND ( + NULLIF(sqlc.narg(domain)::text, '') IS NULL + OR domain = sqlc.narg(domain)::text + ) + AND ( + NULLIF(sqlc.narg(privacy_level)::text, '') IS NULL + OR privacy_level = sqlc.narg(privacy_level)::text + ) + AND ( + sqlc.narg(supports_validation)::boolean IS NULL + OR supports_validation = sqlc.narg(supports_validation)::boolean + ); -- name: GetDataAsset :one -SELECT * +SELECT id, asset_name, asset_type, domain, application_scene, data_description, data_scale, collection_method, labeling_status, update_frequency, privacy_level, permission_mode, supports_validation, seller_expected_price_min, seller_expected_price_max, quality_level, scarcity_level, base_value_score, base_price_min, base_price_max, asset_status, created_at, updated_at FROM data_assets WHERE id = $1; -- name: ListDataAssets :many -SELECT * +SELECT id, asset_name, asset_type, domain, application_scene, data_description, data_scale, collection_method, labeling_status, update_frequency, privacy_level, permission_mode, supports_validation, seller_expected_price_min, seller_expected_price_max, quality_level, scarcity_level, base_value_score, base_price_min, base_price_max, asset_status, created_at, updated_at FROM data_assets WHERE ( NULLIF(sqlc.narg(keyword)::text, '') IS NULL @@ -42,46 +84,149 @@ WHERE ( ORDER BY created_at DESC, id DESC LIMIT $1 OFFSET $2; +-- name: UpdateDataAssetStatus :one +UPDATE data_assets +SET asset_status = $2, + updated_at = now() +WHERE id = $1 +RETURNING id, asset_name, asset_type, domain, application_scene, data_description, data_scale, collection_method, labeling_status, update_frequency, privacy_level, permission_mode, supports_validation, seller_expected_price_min, seller_expected_price_max, quality_level, scarcity_level, base_value_score, base_price_min, base_price_max, asset_status, created_at, updated_at; + +-- name: CreateBuyerRequest :one +INSERT INTO buyer_requests ( + asset_id, + task_type, + model_type, + buyer_budget_min, + buyer_budget_max, + privacy_requirement, + usage_purpose, + request_note, + request_status +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +RETURNING id, asset_id, task_type, model_type, buyer_budget_min, buyer_budget_max, privacy_requirement, usage_purpose, request_note, request_status, created_at, updated_at; + -- name: GetBuyerRequest :one -SELECT * +SELECT id, asset_id, task_type, model_type, buyer_budget_min, buyer_budget_max, privacy_requirement, usage_purpose, request_note, request_status, created_at, updated_at FROM buyer_requests WHERE id = $1; -- name: ListBuyerRequests :many -SELECT * +SELECT id, asset_id, task_type, model_type, buyer_budget_min, buyer_budget_max, privacy_requirement, usage_purpose, request_note, request_status, created_at, updated_at FROM buyer_requests ORDER BY created_at DESC, id DESC LIMIT $1 OFFSET $2; +-- name: CreatePricingResult :one +INSERT INTO pricing_results ( + asset_id, + request_id, + scenario_value_score, + scenario_price_min, + scenario_price_max, + suggested_price, + success_probability, + pricing_reason_1, + pricing_reason_2, + pricing_reason_3, + verification_suggestion, + pricing_status +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) +RETURNING id, asset_id, request_id, scenario_value_score, scenario_price_min, scenario_price_max, suggested_price, success_probability, pricing_reason_1, pricing_reason_2, pricing_reason_3, verification_suggestion, pricing_status, created_at, updated_at; + -- name: GetPricingResult :one -SELECT * +SELECT id, asset_id, request_id, scenario_value_score, scenario_price_min, scenario_price_max, suggested_price, success_probability, pricing_reason_1, pricing_reason_2, pricing_reason_3, verification_suggestion, pricing_status, created_at, updated_at FROM pricing_results WHERE id = $1; -- name: ListPricingResults :many -SELECT * +SELECT id, asset_id, request_id, scenario_value_score, scenario_price_min, scenario_price_max, suggested_price, success_probability, pricing_reason_1, pricing_reason_2, pricing_reason_3, verification_suggestion, pricing_status, created_at, updated_at FROM pricing_results ORDER BY created_at DESC, id DESC LIMIT $1 OFFSET $2; +-- name: CreateValidation :one +INSERT INTO validations ( + asset_id, + request_id, + validation_type, + validation_requested, + validation_status, + validation_signal, + validation_score, + risk_warning, + continue_recommendation, + validation_finished_at +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +RETURNING id, asset_id, request_id, validation_type, validation_requested, validation_status, validation_signal, validation_score, risk_warning, continue_recommendation, validation_created_at, validation_finished_at; + +-- name: UpdateValidationResult :one +UPDATE validations +SET validation_status = $2, + validation_signal = $3, + validation_score = $4, + risk_warning = $5, + continue_recommendation = $6, + validation_finished_at = $7 +WHERE id = $1 +RETURNING id, asset_id, request_id, validation_type, validation_requested, validation_status, validation_signal, validation_score, risk_warning, continue_recommendation, validation_created_at, validation_finished_at; + -- name: GetValidation :one -SELECT * +SELECT id, asset_id, request_id, validation_type, validation_requested, validation_status, validation_signal, validation_score, risk_warning, continue_recommendation, validation_created_at, validation_finished_at FROM validations WHERE id = $1; -- name: ListValidations :many -SELECT * +SELECT id, asset_id, request_id, validation_type, validation_requested, validation_status, validation_signal, validation_score, risk_warning, continue_recommendation, validation_created_at, validation_finished_at FROM validations ORDER BY validation_created_at DESC, id DESC LIMIT $1 OFFSET $2; +-- name: CountValidations :one +SELECT COUNT(*) +FROM validations; + +-- name: CreateOrder :one +INSERT INTO orders ( + asset_id, + request_id, + pricing_id, + validation_id, + asset_name, + current_price, + negotiation_min, + negotiation_max, + validation_used, + delivery_mode, + order_status +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) +RETURNING id, asset_id, request_id, pricing_id, validation_id, asset_name, current_price, negotiation_min, negotiation_max, validation_used, delivery_mode, order_status, order_created_at, order_updated_at; + -- name: GetOrder :one -SELECT * +SELECT id, asset_id, request_id, pricing_id, validation_id, asset_name, current_price, negotiation_min, negotiation_max, validation_used, delivery_mode, order_status, order_created_at, order_updated_at FROM orders WHERE id = $1; -- name: ListOrders :many -SELECT * +SELECT id, asset_id, request_id, pricing_id, validation_id, asset_name, current_price, negotiation_min, negotiation_max, validation_used, delivery_mode, order_status, order_created_at, order_updated_at FROM orders +WHERE NULLIF(sqlc.narg(order_status)::text, '') IS NULL + OR order_status = sqlc.narg(order_status)::text ORDER BY order_created_at DESC, id DESC LIMIT $1 OFFSET $2; + +-- name: CountOrders :one +SELECT COUNT(*) +FROM orders +WHERE NULLIF(sqlc.narg(order_status)::text, '') IS NULL + OR order_status = sqlc.narg(order_status)::text; + +-- name: UpdateOrderStatus :one +UPDATE orders +SET order_status = $2, + order_updated_at = now() +WHERE id = $1 +RETURNING id, asset_id, request_id, pricing_id, validation_id, asset_name, current_price, negotiation_min, negotiation_max, validation_used, delivery_mode, order_status, order_created_at, order_updated_at; diff --git a/internal/handler/admin.go b/internal/handler/admin.go new file mode 100644 index 0000000..d681a73 --- /dev/null +++ b/internal/handler/admin.go @@ -0,0 +1,19 @@ +package handler + +import ( + "net/http" + + "gitea.starryskymeow.cn/B309/datamarket/internal/service" +) + +func AdminListAssets(svc service.AssetService) http.HandlerFunc { + return ListAssets(svc) +} + +func AdminListValidations(svc service.ValidationService) http.HandlerFunc { + return ListValidations(svc) +} + +func AdminListOrders(svc service.OrderService) http.HandlerFunc { + return ListOrders(svc) +} diff --git a/internal/handler/assets.go b/internal/handler/assets.go new file mode 100644 index 0000000..1278226 --- /dev/null +++ b/internal/handler/assets.go @@ -0,0 +1,144 @@ +package handler + +import ( + "net/http" + + "gitea.starryskymeow.cn/B309/datamarket/internal/service" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +type CreateAssetRequest struct { + AssetName string `json:"asset_name"` + AssetType string `json:"asset_type"` + Domain string `json:"domain"` + ApplicationScene *string `json:"application_scene"` + DataDescription string `json:"data_description"` + DataScale string `json:"data_scale"` + CollectionMethod string `json:"collection_method"` + LabelingStatus *string `json:"labeling_status"` + UpdateFrequency *string `json:"update_frequency"` + PrivacyLevel string `json:"privacy_level"` + PermissionMode string `json:"permission_mode"` + SupportsValidation bool `json:"supports_validation"` + SellerExpectedPriceMin *float64 `json:"seller_expected_price_min"` + SellerExpectedPriceMax *float64 `json:"seller_expected_price_max"` +} + +func (req *CreateAssetRequest) Bind(_ *http.Request) error { + return nil +} + +type ListAssetsQuery struct { + Limit int32 `schema:"limit"` + Offset int32 `schema:"offset"` + Keyword *string `schema:"keyword"` + AssetType *string `schema:"asset_type"` + Domain *string `schema:"domain"` + PrivacyLevel *string `schema:"privacy_level"` + SupportsValidation *bool `schema:"supports_validation"` +} + +type UpdateStatusRequest struct { + Status string `json:"asset_status"` +} + +func (req *UpdateStatusRequest) Bind(_ *http.Request) error { + return nil +} + +func CreateAsset(svc service.AssetService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + req := &CreateAssetRequest{} + if err := render.Bind(r, req); err != nil { + renderServiceError(w, r, service.NewValidationError("request data error: "+err.Error())) + return + } + + result, err := svc.CreateAsset(r.Context(), service.AssetCreateInput{ + AssetName: req.AssetName, + AssetType: req.AssetType, + Domain: req.Domain, + ApplicationScene: req.ApplicationScene, + DataDescription: req.DataDescription, + DataScale: req.DataScale, + CollectionMethod: req.CollectionMethod, + LabelingStatus: req.LabelingStatus, + UpdateFrequency: req.UpdateFrequency, + PrivacyLevel: req.PrivacyLevel, + PermissionMode: req.PermissionMode, + SupportsValidation: req.SupportsValidation, + SellerExpectedPriceMin: req.SellerExpectedPriceMin, + SellerExpectedPriceMax: req.SellerExpectedPriceMax, + }) + if err != nil { + renderServiceError(w, r, err) + return + } + + renderSuccess(w, r, http.StatusCreated, "资产创建成功", result) + } +} + +func ListAssets(svc service.AssetService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var query ListAssetsQuery + if err := decoder.Decode(&query, r.URL.Query()); err != nil { + renderServiceError(w, r, service.NewValidationError("request data error: "+err.Error())) + return + } + + result, err := svc.ListAssets(r.Context(), service.AssetListInput{ + Limit: query.Limit, + Offset: query.Offset, + Keyword: query.Keyword, + AssetType: query.AssetType, + Domain: query.Domain, + PrivacyLevel: query.PrivacyLevel, + SupportsValidation: query.SupportsValidation, + }) + if err != nil { + renderServiceError(w, r, err) + return + } + + renderSuccess(w, r, http.StatusOK, "success", ListResponse[service.Asset]{ + List: result.List, + Total: result.Total, + Limit: result.Limit, + Offset: result.Offset, + }) + } +} + +func GetAsset(svc service.AssetService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + result, err := svc.GetAsset(r.Context(), chi.URLParam(r, "id")) + if err != nil { + renderServiceError(w, r, err) + return + } + + renderSuccess(w, r, http.StatusOK, "success", result) + } +} + +func UpdateAssetStatus(svc service.AssetService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + req := &UpdateStatusRequest{} + if err := render.Bind(r, req); err != nil { + renderServiceError(w, r, service.NewValidationError("request data error: "+err.Error())) + return + } + + result, err := svc.UpdateAssetStatus(r.Context(), chi.URLParam(r, "id"), service.StatusUpdate{ + Status: req.Status, + }) + if err != nil { + renderServiceError(w, r, err) + return + } + + renderSuccess(w, r, http.StatusOK, "状态更新成功", result) + } +} diff --git a/internal/handler/assets_test.go b/internal/handler/assets_test.go new file mode 100644 index 0000000..880bd2e --- /dev/null +++ b/internal/handler/assets_test.go @@ -0,0 +1,90 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "gitea.starryskymeow.cn/B309/datamarket/internal/service" +) + +type fakeAssetService struct { + listResult service.ListResult[service.Asset] + listErr error +} + +func (f fakeAssetService) CreateAsset(context.Context, service.AssetCreateInput) (service.AssetStatusResult, error) { + return service.AssetStatusResult{}, nil +} + +func (f fakeAssetService) GetAsset(context.Context, string) (service.Asset, error) { + return service.Asset{}, nil +} + +func (f fakeAssetService) ListAssets(context.Context, service.AssetListInput) (service.ListResult[service.Asset], error) { + return f.listResult, f.listErr +} + +func (f fakeAssetService) UpdateAssetStatus(context.Context, string, service.StatusUpdate) (service.AssetStatusResult, error) { + return service.AssetStatusResult{}, nil +} + +func TestCreateAssetHandlerRejectsInvalidJSON(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/assets", bytes.NewBufferString("{")) + rec := httptest.NewRecorder() + + CreateAsset(fakeAssetService{}).ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestListAssetsHandlerReturnsListPayload(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/assets?limit=5&offset=0", nil) + rec := httptest.NewRecorder() + + handler := ListAssets(fakeAssetService{ + listResult: service.ListResult[service.Asset]{ + List: []service.Asset{ + { + ID: "asset-1", + AssetName: "机械臂抓取演示数据集A", + AssetType: "视频+轨迹", + Domain: "机器人", + DataDescription: "包含工业抓取场景的机械臂操作视频与轨迹", + DataScale: "1200条轨迹", + CollectionMethod: "真实采集", + PrivacyLevel: "中", + PermissionMode: "授权访问", + SupportsValidation: true, + AssetStatus: "已上架", + }, + }, + Total: 1, + Limit: 5, + Offset: 0, + }, + }) + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body Response[ListResponse[service.Asset]] + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if body.Code != 0 { + t.Fatalf("code = %d, want 0", body.Code) + } + if body.Data.Total != 1 || len(body.Data.List) != 1 { + t.Fatalf("unexpected payload: %+v", body.Data) + } +} diff --git a/internal/handler/common.go b/internal/handler/common.go new file mode 100644 index 0000000..3c9a3b7 --- /dev/null +++ b/internal/handler/common.go @@ -0,0 +1,46 @@ +package handler + +import ( + "net/http" + + "gitea.starryskymeow.cn/B309/datamarket/internal/service" + "github.com/go-chi/render" + "github.com/gorilla/schema" +) + +type Response[T any] struct { + Code int `json:"code"` + Message string `json:"message"` + Data T `json:"data,omitempty"` +} + +type ListResponse[T any] struct { + List []T `json:"list"` + Total int64 `json:"total"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +var decoder = schema.NewDecoder() + +func init() { + decoder.IgnoreUnknownKeys(true) +} + +func renderSuccess[T any](w http.ResponseWriter, r *http.Request, status int, message string, data T) { + render.Status(r, status) + render.JSON(w, r, Response[T]{ + Code: 0, + Message: message, + Data: data, + }) +} + +func renderServiceError(w http.ResponseWriter, r *http.Request, err error) { + status := service.StatusCode(err) + render.Status(r, status) + render.JSON(w, r, Response[any]{ + Code: status, + Message: service.Message(err), + }) +} diff --git a/internal/handler/data_assets.go b/internal/handler/data_assets.go deleted file mode 100644 index e8657d1..0000000 --- a/internal/handler/data_assets.go +++ /dev/null @@ -1,140 +0,0 @@ -package handler - -import ( - "log" - "net/http" - - "gitea.starryskymeow.cn/B309/datamarket/internal/repository" - "github.com/go-chi/chi/v5" - "github.com/go-chi/render" - "github.com/jackc/pgx/v5/pgtype" -) - -type CreateDataAssetRequest struct { - AssetName string `json:"asset_name"` - AssetType string `json:"asset_type"` - Domain string `json:"domain"` - ApplicationScene pgtype.Text `json:"application_scene"` - DataDescription string `json:"data_description"` - DataScale string `json:"data_scale"` - CollectionMethod string `json:"collection_method"` - LabelingStatus pgtype.Text `json:"labeling_status"` - UpdateFrequency pgtype.Text `json:"update_frequency"` - PrivacyLevel string `json:"privacy_level"` - PermissionMode string `json:"permission_mode"` - SupportsValidation bool `json:"supports_validation"` - SellerExpectedPriceMin pgtype.Numeric `json:"seller_expected_price_min"` - SellerExpectedPriceMax pgtype.Numeric `json:"seller_expected_price_max"` -} - -func (req *CreateDataAssetRequest) Bind(r *http.Request) error { - return nil -} - -type ListDataAssetsParams struct { - Limit *int32 `json:"limit" schema:"limit"` - Offset *int32 `json:"offset" schema:"offset"` - Keyword *string `json:"keyword" schema:"keyword"` - AssetType *string `json:"asset_type" schema:"asset_type"` - Domain *string `json:"domain" schema:"domain"` - PrivacyLevel *string `json:"privacy_level" schema:"privacy_level"` - SupportsValidation *bool `json:"supports_validation" schema:"supports_validation"` -} - -// CreateDataAsset POST /api/assets -func CreateDataAsset(queries *repository.Queries) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - req := &CreateDataAssetRequest{} - err := render.Bind(r, req) - if err != nil { - render.Status(r, http.StatusBadRequest) - render.JSON(w, r, Response[any]{ - Code: http.StatusBadRequest, - Message: "request data error: " + err.Error(), - }) - return - } - - dataAsset, err := queries.CreateDataAsset(r.Context(), repository.CreateDataAssetParams{ - AssetName: req.AssetName, - AssetType: req.AssetType, - Domain: req.Domain, - ApplicationScene: req.ApplicationScene, - DataDescription: req.DataDescription, - DataScale: req.DataScale, - CollectionMethod: req.CollectionMethod, - LabelingStatus: req.LabelingStatus, - UpdateFrequency: req.UpdateFrequency, - PrivacyLevel: req.PrivacyLevel, - PermissionMode: req.PermissionMode, - SupportsValidation: req.SupportsValidation, - SellerExpectedPriceMin: req.SellerExpectedPriceMin, - SellerExpectedPriceMax: req.SellerExpectedPriceMax, - AssetStatus: "已上架", - }) - - render.Status(r, http.StatusCreated) - render.JSON(w, r, Response[repository.DataAsset]{ - Code: 0, - Message: "create assets successfully", - Data: dataAsset, - }) - } -} - -// GetDataAsset GET /api/assets/{id} -func GetDataAsset(queries *repository.Queries) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - var id pgtype.UUID - err := id.Scan(chi.URLParam(r, "id")) - if err != nil { - render.Status(r, http.StatusBadRequest) - render.JSON(w, r, Response[any]{ - Code: http.StatusBadRequest, - Message: "request data error: " + err.Error(), - }) - return - } - - dataAsset, err := queries.GetDataAsset(r.Context(), id) - if err != nil { - if err.Error() == "no rows in result set" { - render.Status(r, http.StatusNotFound) - render.JSON(w, r, Response[any]{ - Code: http.StatusNotFound, - Message: "data_asset not found", - }) - } else { - render.Status(r, http.StatusInternalServerError) - render.JSON(w, r, Response[any]{ - Code: http.StatusInternalServerError, - Message: "internal server error", - }) - } - return - } - - render.Status(r, http.StatusOK) - render.JSON(w, r, Response[repository.DataAsset]{ - Code: 0, - Message: "get asset successfully", - Data: dataAsset, - }) - } -} - -// ListDataAssets GET /api/assets -func ListDataAssets(queries *repository.Queries) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - params := &ListDataAssetsParams{} - if err := decoder.Decode(params, r.URL.Query()); err != nil { - render.Status(r, http.StatusBadRequest) - render.JSON(w, r, Response[any]{ - Code: http.StatusBadRequest, - Message: "request data error: " + err.Error(), - }) - return - } - log.Println(params) - } -} diff --git a/internal/handler/handler.go b/internal/handler/handler.go deleted file mode 100644 index 30cd8d1..0000000 --- a/internal/handler/handler.go +++ /dev/null @@ -1,11 +0,0 @@ -package handler - -import "github.com/gorilla/schema" - -type Response[T any] struct { - Code int `json:"code"` - Message string `json:"message"` - Data T `json:"data,omitempty"` -} - -var decoder = schema.NewDecoder() diff --git a/internal/handler/orders.go b/internal/handler/orders.go new file mode 100644 index 0000000..879e266 --- /dev/null +++ b/internal/handler/orders.go @@ -0,0 +1,114 @@ +package handler + +import ( + "net/http" + + "gitea.starryskymeow.cn/B309/datamarket/internal/service" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +type CreateOrderRequest struct { + AssetID string `json:"asset_id"` + RequestID string `json:"request_id"` + PricingID string `json:"pricing_id"` + ValidationID *string `json:"validation_id"` + CurrentPrice *float64 `json:"current_price"` + DeliveryMode string `json:"delivery_mode"` +} + +func (req *CreateOrderRequest) Bind(_ *http.Request) error { + return nil +} + +type UpdateOrderStatusRequest struct { + Status string `json:"order_status"` +} + +func (req *UpdateOrderStatusRequest) Bind(_ *http.Request) error { + return nil +} + +func CreateOrder(svc service.OrderService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + req := &CreateOrderRequest{} + if err := render.Bind(r, req); err != nil { + renderServiceError(w, r, service.NewValidationError("request data error: "+err.Error())) + return + } + + result, err := svc.CreateOrder(r.Context(), service.OrderCreateInput{ + AssetID: req.AssetID, + RequestID: req.RequestID, + PricingID: req.PricingID, + ValidationID: req.ValidationID, + CurrentPrice: req.CurrentPrice, + DeliveryMode: req.DeliveryMode, + }) + if err != nil { + renderServiceError(w, r, err) + return + } + + renderSuccess(w, r, http.StatusCreated, "订单创建成功", result) + } +} + +func GetOrder(svc service.OrderService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + result, err := svc.GetOrder(r.Context(), chi.URLParam(r, "id")) + if err != nil { + renderServiceError(w, r, err) + return + } + + renderSuccess(w, r, http.StatusOK, "success", result) + } +} + +func ListOrders(svc service.OrderService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var query ListPageQuery + if err := decoder.Decode(&query, r.URL.Query()); err != nil { + renderServiceError(w, r, service.NewValidationError("request data error: "+err.Error())) + return + } + + result, err := svc.ListOrders(r.Context(), service.OrderListInput{ + Limit: query.Limit, + Offset: query.Offset, + OrderStatus: query.OrderStatus, + }) + if err != nil { + renderServiceError(w, r, err) + return + } + + renderSuccess(w, r, http.StatusOK, "success", ListResponse[service.Order]{ + List: result.List, + Total: result.Total, + Limit: result.Limit, + Offset: result.Offset, + }) + } +} + +func UpdateOrderStatus(svc service.OrderService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + req := &UpdateOrderStatusRequest{} + if err := render.Bind(r, req); err != nil { + renderServiceError(w, r, service.NewValidationError("request data error: "+err.Error())) + return + } + + result, err := svc.UpdateOrderStatus(r.Context(), chi.URLParam(r, "id"), service.StatusUpdate{ + Status: req.Status, + }) + if err != nil { + renderServiceError(w, r, err) + return + } + + renderSuccess(w, r, http.StatusOK, "订单状态更新成功", result) + } +} diff --git a/internal/handler/pricing.go b/internal/handler/pricing.go new file mode 100644 index 0000000..ab1c7fa --- /dev/null +++ b/internal/handler/pricing.go @@ -0,0 +1,63 @@ +package handler + +import ( + "net/http" + + "gitea.starryskymeow.cn/B309/datamarket/internal/service" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +type CreatePricingRequest struct { + AssetID string `json:"asset_id"` + TaskType string `json:"task_type"` + ModelType string `json:"model_type"` + BuyerBudgetMin *float64 `json:"buyer_budget_min"` + BuyerBudgetMax *float64 `json:"buyer_budget_max"` + PrivacyRequirement *string `json:"privacy_requirement"` + UsagePurpose *string `json:"usage_purpose"` + RequestNote *string `json:"request_note"` +} + +func (req *CreatePricingRequest) Bind(_ *http.Request) error { + return nil +} + +func CreatePricing(svc service.PricingService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + req := &CreatePricingRequest{} + if err := render.Bind(r, req); err != nil { + renderServiceError(w, r, service.NewValidationError("request data error: "+err.Error())) + return + } + + result, err := svc.CreatePricing(r.Context(), service.PricingCreateInput{ + AssetID: req.AssetID, + TaskType: req.TaskType, + ModelType: req.ModelType, + BuyerBudgetMin: req.BuyerBudgetMin, + BuyerBudgetMax: req.BuyerBudgetMax, + PrivacyRequirement: req.PrivacyRequirement, + UsagePurpose: req.UsagePurpose, + RequestNote: req.RequestNote, + }) + if err != nil { + renderServiceError(w, r, err) + return + } + + renderSuccess(w, r, http.StatusCreated, "定价建议生成成功", result) + } +} + +func GetPricing(svc service.PricingService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + result, err := svc.GetPricing(r.Context(), chi.URLParam(r, "id")) + if err != nil { + renderServiceError(w, r, err) + return + } + + renderSuccess(w, r, http.StatusOK, "success", result) + } +} diff --git a/internal/handler/validations.go b/internal/handler/validations.go new file mode 100644 index 0000000..427df5f --- /dev/null +++ b/internal/handler/validations.go @@ -0,0 +1,85 @@ +package handler + +import ( + "net/http" + + "gitea.starryskymeow.cn/B309/datamarket/internal/service" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +type CreateValidationRequest struct { + AssetID string `json:"asset_id"` + RequestID string `json:"request_id"` + ValidationType string `json:"validation_type"` +} + +func (req *CreateValidationRequest) Bind(_ *http.Request) error { + return nil +} + +type ListPageQuery struct { + Limit int32 `schema:"limit"` + Offset int32 `schema:"offset"` + OrderStatus string `schema:"order_status"` +} + +func CreateValidation(svc service.ValidationService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + req := &CreateValidationRequest{} + if err := render.Bind(r, req); err != nil { + renderServiceError(w, r, service.NewValidationError("request data error: "+err.Error())) + return + } + + result, err := svc.CreateValidation(r.Context(), service.ValidationCreateInput{ + AssetID: req.AssetID, + RequestID: req.RequestID, + ValidationType: req.ValidationType, + }) + if err != nil { + renderServiceError(w, r, err) + return + } + + renderSuccess(w, r, http.StatusCreated, "验证申请提交成功", result) + } +} + +func GetValidation(svc service.ValidationService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + result, err := svc.GetValidation(r.Context(), chi.URLParam(r, "id")) + if err != nil { + renderServiceError(w, r, err) + return + } + + renderSuccess(w, r, http.StatusOK, "success", result) + } +} + +func ListValidations(svc service.ValidationService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var query ListPageQuery + if err := decoder.Decode(&query, r.URL.Query()); err != nil { + renderServiceError(w, r, service.NewValidationError("request data error: "+err.Error())) + return + } + + result, err := svc.ListValidations(r.Context(), service.ValidationListInput{ + Limit: query.Limit, + Offset: query.Offset, + }) + if err != nil { + renderServiceError(w, r, err) + return + } + + renderSuccess(w, r, http.StatusOK, "success", ListResponse[service.Validation]{ + List: result.List, + Total: result.Total, + Limit: result.Limit, + Offset: result.Offset, + }) + } +} diff --git a/internal/repository/market.sql.go b/internal/repository/market.sql.go index 4ad7226..6db2aa7 100644 --- a/internal/repository/market.sql.go +++ b/internal/repository/market.sql.go @@ -11,15 +11,164 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const countDataAssets = `-- name: CountDataAssets :one +SELECT COUNT(*) +FROM data_assets +WHERE ( + NULLIF($1::text, '') IS NULL + OR asset_name ILIKE '%' || $1::text || '%' + OR data_description ILIKE '%' || $1::text || '%' + ) + AND ( + NULLIF($2::text, '') IS NULL + OR asset_type = $2::text + ) + AND ( + NULLIF($3::text, '') IS NULL + OR domain = $3::text + ) + AND ( + NULLIF($4::text, '') IS NULL + OR privacy_level = $4::text + ) + AND ( + $5::boolean IS NULL + OR supports_validation = $5::boolean + ) +` + +type CountDataAssetsParams struct { + Keyword pgtype.Text `json:"keyword"` + AssetType pgtype.Text `json:"asset_type"` + Domain pgtype.Text `json:"domain"` + PrivacyLevel pgtype.Text `json:"privacy_level"` + SupportsValidation pgtype.Bool `json:"supports_validation"` +} + +func (q *Queries) CountDataAssets(ctx context.Context, arg CountDataAssetsParams) (int64, error) { + row := q.db.QueryRow(ctx, countDataAssets, + arg.Keyword, + arg.AssetType, + arg.Domain, + arg.PrivacyLevel, + arg.SupportsValidation, + ) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countOrders = `-- name: CountOrders :one +SELECT COUNT(*) +FROM orders +WHERE NULLIF($1::text, '') IS NULL + OR order_status = $1::text +` + +func (q *Queries) CountOrders(ctx context.Context, orderStatus pgtype.Text) (int64, error) { + row := q.db.QueryRow(ctx, countOrders, orderStatus) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countValidations = `-- name: CountValidations :one +SELECT COUNT(*) +FROM validations +` + +func (q *Queries) CountValidations(ctx context.Context) (int64, error) { + row := q.db.QueryRow(ctx, countValidations) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createBuyerRequest = `-- name: CreateBuyerRequest :one +INSERT INTO buyer_requests ( + asset_id, + task_type, + model_type, + buyer_budget_min, + buyer_budget_max, + privacy_requirement, + usage_purpose, + request_note, + request_status +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +RETURNING id, asset_id, task_type, model_type, buyer_budget_min, buyer_budget_max, privacy_requirement, usage_purpose, request_note, request_status, created_at, updated_at +` + +type CreateBuyerRequestParams struct { + AssetID pgtype.UUID `json:"asset_id"` + TaskType string `json:"task_type"` + ModelType string `json:"model_type"` + BuyerBudgetMin pgtype.Numeric `json:"buyer_budget_min"` + BuyerBudgetMax pgtype.Numeric `json:"buyer_budget_max"` + PrivacyRequirement pgtype.Text `json:"privacy_requirement"` + UsagePurpose pgtype.Text `json:"usage_purpose"` + RequestNote pgtype.Text `json:"request_note"` + RequestStatus string `json:"request_status"` +} + +func (q *Queries) CreateBuyerRequest(ctx context.Context, arg CreateBuyerRequestParams) (BuyerRequest, error) { + row := q.db.QueryRow(ctx, createBuyerRequest, + arg.AssetID, + arg.TaskType, + arg.ModelType, + arg.BuyerBudgetMin, + arg.BuyerBudgetMax, + arg.PrivacyRequirement, + arg.UsagePurpose, + arg.RequestNote, + arg.RequestStatus, + ) + var i BuyerRequest + err := row.Scan( + &i.ID, + &i.AssetID, + &i.TaskType, + &i.ModelType, + &i.BuyerBudgetMin, + &i.BuyerBudgetMax, + &i.PrivacyRequirement, + &i.UsagePurpose, + &i.RequestNote, + &i.RequestStatus, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const createDataAsset = `-- name: CreateDataAsset :one -INSERT INTO data_assets (asset_name, asset_type, domain, application_scene, - data_description, data_scale, collection_method, labeling_status, - update_frequency, privacy_level, permission_mode, supports_validation, - seller_expected_price_min, seller_expected_price_max, asset_status) -VALUES ($1, $2, $3, $4, - $5, $6, $7, $8, - $9, $10, $11, $12, - $13, $14, $15) +INSERT INTO data_assets ( + asset_name, + asset_type, + domain, + application_scene, + data_description, + data_scale, + collection_method, + labeling_status, + update_frequency, + privacy_level, + permission_mode, + supports_validation, + seller_expected_price_min, + seller_expected_price_max, + quality_level, + scarcity_level, + base_value_score, + base_price_min, + base_price_max, + asset_status +) +VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, $16, $17, $18, $19, $20 +) RETURNING id, asset_name, asset_type, domain, application_scene, data_description, data_scale, collection_method, labeling_status, update_frequency, privacy_level, permission_mode, supports_validation, seller_expected_price_min, seller_expected_price_max, quality_level, scarcity_level, base_value_score, base_price_min, base_price_max, asset_status, created_at, updated_at ` @@ -38,6 +187,11 @@ type CreateDataAssetParams struct { SupportsValidation bool `json:"supports_validation"` SellerExpectedPriceMin pgtype.Numeric `json:"seller_expected_price_min"` SellerExpectedPriceMax pgtype.Numeric `json:"seller_expected_price_max"` + QualityLevel pgtype.Text `json:"quality_level"` + ScarcityLevel pgtype.Text `json:"scarcity_level"` + BaseValueScore pgtype.Numeric `json:"base_value_score"` + BasePriceMin pgtype.Numeric `json:"base_price_min"` + BasePriceMax pgtype.Numeric `json:"base_price_max"` AssetStatus string `json:"asset_status"` } @@ -57,6 +211,11 @@ func (q *Queries) CreateDataAsset(ctx context.Context, arg CreateDataAssetParams arg.SupportsValidation, arg.SellerExpectedPriceMin, arg.SellerExpectedPriceMax, + arg.QualityLevel, + arg.ScarcityLevel, + arg.BaseValueScore, + arg.BasePriceMin, + arg.BasePriceMax, arg.AssetStatus, ) var i DataAsset @@ -88,6 +247,203 @@ func (q *Queries) CreateDataAsset(ctx context.Context, arg CreateDataAssetParams return i, err } +const createOrder = `-- name: CreateOrder :one +INSERT INTO orders ( + asset_id, + request_id, + pricing_id, + validation_id, + asset_name, + current_price, + negotiation_min, + negotiation_max, + validation_used, + delivery_mode, + order_status +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) +RETURNING id, asset_id, request_id, pricing_id, validation_id, asset_name, current_price, negotiation_min, negotiation_max, validation_used, delivery_mode, order_status, order_created_at, order_updated_at +` + +type CreateOrderParams struct { + AssetID pgtype.UUID `json:"asset_id"` + RequestID pgtype.UUID `json:"request_id"` + PricingID pgtype.UUID `json:"pricing_id"` + ValidationID pgtype.UUID `json:"validation_id"` + AssetName string `json:"asset_name"` + CurrentPrice pgtype.Numeric `json:"current_price"` + NegotiationMin pgtype.Numeric `json:"negotiation_min"` + NegotiationMax pgtype.Numeric `json:"negotiation_max"` + ValidationUsed bool `json:"validation_used"` + DeliveryMode string `json:"delivery_mode"` + OrderStatus string `json:"order_status"` +} + +func (q *Queries) CreateOrder(ctx context.Context, arg CreateOrderParams) (Order, error) { + row := q.db.QueryRow(ctx, createOrder, + arg.AssetID, + arg.RequestID, + arg.PricingID, + arg.ValidationID, + arg.AssetName, + arg.CurrentPrice, + arg.NegotiationMin, + arg.NegotiationMax, + arg.ValidationUsed, + arg.DeliveryMode, + arg.OrderStatus, + ) + var i Order + err := row.Scan( + &i.ID, + &i.AssetID, + &i.RequestID, + &i.PricingID, + &i.ValidationID, + &i.AssetName, + &i.CurrentPrice, + &i.NegotiationMin, + &i.NegotiationMax, + &i.ValidationUsed, + &i.DeliveryMode, + &i.OrderStatus, + &i.OrderCreatedAt, + &i.OrderUpdatedAt, + ) + return i, err +} + +const createPricingResult = `-- name: CreatePricingResult :one +INSERT INTO pricing_results ( + asset_id, + request_id, + scenario_value_score, + scenario_price_min, + scenario_price_max, + suggested_price, + success_probability, + pricing_reason_1, + pricing_reason_2, + pricing_reason_3, + verification_suggestion, + pricing_status +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) +RETURNING id, asset_id, request_id, scenario_value_score, scenario_price_min, scenario_price_max, suggested_price, success_probability, pricing_reason_1, pricing_reason_2, pricing_reason_3, verification_suggestion, pricing_status, created_at, updated_at +` + +type CreatePricingResultParams struct { + AssetID pgtype.UUID `json:"asset_id"` + RequestID pgtype.UUID `json:"request_id"` + ScenarioValueScore pgtype.Numeric `json:"scenario_value_score"` + ScenarioPriceMin pgtype.Numeric `json:"scenario_price_min"` + ScenarioPriceMax pgtype.Numeric `json:"scenario_price_max"` + SuggestedPrice pgtype.Numeric `json:"suggested_price"` + SuccessProbability pgtype.Numeric `json:"success_probability"` + PricingReason1 pgtype.Text `json:"pricing_reason_1"` + PricingReason2 pgtype.Text `json:"pricing_reason_2"` + PricingReason3 pgtype.Text `json:"pricing_reason_3"` + VerificationSuggestion pgtype.Text `json:"verification_suggestion"` + PricingStatus string `json:"pricing_status"` +} + +func (q *Queries) CreatePricingResult(ctx context.Context, arg CreatePricingResultParams) (PricingResult, error) { + row := q.db.QueryRow(ctx, createPricingResult, + arg.AssetID, + arg.RequestID, + arg.ScenarioValueScore, + arg.ScenarioPriceMin, + arg.ScenarioPriceMax, + arg.SuggestedPrice, + arg.SuccessProbability, + arg.PricingReason1, + arg.PricingReason2, + arg.PricingReason3, + arg.VerificationSuggestion, + arg.PricingStatus, + ) + var i PricingResult + err := row.Scan( + &i.ID, + &i.AssetID, + &i.RequestID, + &i.ScenarioValueScore, + &i.ScenarioPriceMin, + &i.ScenarioPriceMax, + &i.SuggestedPrice, + &i.SuccessProbability, + &i.PricingReason1, + &i.PricingReason2, + &i.PricingReason3, + &i.VerificationSuggestion, + &i.PricingStatus, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createValidation = `-- name: CreateValidation :one +INSERT INTO validations ( + asset_id, + request_id, + validation_type, + validation_requested, + validation_status, + validation_signal, + validation_score, + risk_warning, + continue_recommendation, + validation_finished_at +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +RETURNING id, asset_id, request_id, validation_type, validation_requested, validation_status, validation_signal, validation_score, risk_warning, continue_recommendation, validation_created_at, validation_finished_at +` + +type CreateValidationParams struct { + AssetID pgtype.UUID `json:"asset_id"` + RequestID pgtype.UUID `json:"request_id"` + ValidationType pgtype.Text `json:"validation_type"` + ValidationRequested bool `json:"validation_requested"` + ValidationStatus string `json:"validation_status"` + ValidationSignal pgtype.Text `json:"validation_signal"` + ValidationScore pgtype.Numeric `json:"validation_score"` + RiskWarning pgtype.Text `json:"risk_warning"` + ContinueRecommendation pgtype.Text `json:"continue_recommendation"` + ValidationFinishedAt pgtype.Timestamptz `json:"validation_finished_at"` +} + +func (q *Queries) CreateValidation(ctx context.Context, arg CreateValidationParams) (Validation, error) { + row := q.db.QueryRow(ctx, createValidation, + arg.AssetID, + arg.RequestID, + arg.ValidationType, + arg.ValidationRequested, + arg.ValidationStatus, + arg.ValidationSignal, + arg.ValidationScore, + arg.RiskWarning, + arg.ContinueRecommendation, + arg.ValidationFinishedAt, + ) + var i Validation + err := row.Scan( + &i.ID, + &i.AssetID, + &i.RequestID, + &i.ValidationType, + &i.ValidationRequested, + &i.ValidationStatus, + &i.ValidationSignal, + &i.ValidationScore, + &i.RiskWarning, + &i.ContinueRecommendation, + &i.ValidationCreatedAt, + &i.ValidationFinishedAt, + ) + return i, err +} + const getBuyerRequest = `-- name: GetBuyerRequest :one SELECT id, asset_id, task_type, model_type, buyer_budget_min, buyer_budget_max, privacy_requirement, usage_purpose, request_note, request_status, created_at, updated_at FROM buyer_requests @@ -372,17 +728,20 @@ func (q *Queries) ListDataAssets(ctx context.Context, arg ListDataAssetsParams) const listOrders = `-- name: ListOrders :many SELECT id, asset_id, request_id, pricing_id, validation_id, asset_name, current_price, negotiation_min, negotiation_max, validation_used, delivery_mode, order_status, order_created_at, order_updated_at FROM orders +WHERE NULLIF($3::text, '') IS NULL + OR order_status = $3::text ORDER BY order_created_at DESC, id DESC LIMIT $1 OFFSET $2 ` type ListOrdersParams struct { - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` + OrderStatus pgtype.Text `json:"order_status"` } func (q *Queries) ListOrders(ctx context.Context, arg ListOrdersParams) ([]Order, error) { - rows, err := q.db.Query(ctx, listOrders, arg.Limit, arg.Offset) + rows, err := q.db.Query(ctx, listOrders, arg.Limit, arg.Offset, arg.OrderStatus) if err != nil { return nil, err } @@ -508,3 +867,132 @@ func (q *Queries) ListValidations(ctx context.Context, arg ListValidationsParams } return items, nil } + +const updateDataAssetStatus = `-- name: UpdateDataAssetStatus :one +UPDATE data_assets +SET asset_status = $2, + updated_at = now() +WHERE id = $1 +RETURNING id, asset_name, asset_type, domain, application_scene, data_description, data_scale, collection_method, labeling_status, update_frequency, privacy_level, permission_mode, supports_validation, seller_expected_price_min, seller_expected_price_max, quality_level, scarcity_level, base_value_score, base_price_min, base_price_max, asset_status, created_at, updated_at +` + +type UpdateDataAssetStatusParams struct { + ID pgtype.UUID `json:"id"` + AssetStatus string `json:"asset_status"` +} + +func (q *Queries) UpdateDataAssetStatus(ctx context.Context, arg UpdateDataAssetStatusParams) (DataAsset, error) { + row := q.db.QueryRow(ctx, updateDataAssetStatus, arg.ID, arg.AssetStatus) + var i DataAsset + err := row.Scan( + &i.ID, + &i.AssetName, + &i.AssetType, + &i.Domain, + &i.ApplicationScene, + &i.DataDescription, + &i.DataScale, + &i.CollectionMethod, + &i.LabelingStatus, + &i.UpdateFrequency, + &i.PrivacyLevel, + &i.PermissionMode, + &i.SupportsValidation, + &i.SellerExpectedPriceMin, + &i.SellerExpectedPriceMax, + &i.QualityLevel, + &i.ScarcityLevel, + &i.BaseValueScore, + &i.BasePriceMin, + &i.BasePriceMax, + &i.AssetStatus, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateOrderStatus = `-- name: UpdateOrderStatus :one +UPDATE orders +SET order_status = $2, + order_updated_at = now() +WHERE id = $1 +RETURNING id, asset_id, request_id, pricing_id, validation_id, asset_name, current_price, negotiation_min, negotiation_max, validation_used, delivery_mode, order_status, order_created_at, order_updated_at +` + +type UpdateOrderStatusParams struct { + ID pgtype.UUID `json:"id"` + OrderStatus string `json:"order_status"` +} + +func (q *Queries) UpdateOrderStatus(ctx context.Context, arg UpdateOrderStatusParams) (Order, error) { + row := q.db.QueryRow(ctx, updateOrderStatus, arg.ID, arg.OrderStatus) + var i Order + err := row.Scan( + &i.ID, + &i.AssetID, + &i.RequestID, + &i.PricingID, + &i.ValidationID, + &i.AssetName, + &i.CurrentPrice, + &i.NegotiationMin, + &i.NegotiationMax, + &i.ValidationUsed, + &i.DeliveryMode, + &i.OrderStatus, + &i.OrderCreatedAt, + &i.OrderUpdatedAt, + ) + return i, err +} + +const updateValidationResult = `-- name: UpdateValidationResult :one +UPDATE validations +SET validation_status = $2, + validation_signal = $3, + validation_score = $4, + risk_warning = $5, + continue_recommendation = $6, + validation_finished_at = $7 +WHERE id = $1 +RETURNING id, asset_id, request_id, validation_type, validation_requested, validation_status, validation_signal, validation_score, risk_warning, continue_recommendation, validation_created_at, validation_finished_at +` + +type UpdateValidationResultParams struct { + ID pgtype.UUID `json:"id"` + ValidationStatus string `json:"validation_status"` + ValidationSignal pgtype.Text `json:"validation_signal"` + ValidationScore pgtype.Numeric `json:"validation_score"` + RiskWarning pgtype.Text `json:"risk_warning"` + ContinueRecommendation pgtype.Text `json:"continue_recommendation"` + ValidationFinishedAt pgtype.Timestamptz `json:"validation_finished_at"` +} + +func (q *Queries) UpdateValidationResult(ctx context.Context, arg UpdateValidationResultParams) (Validation, error) { + row := q.db.QueryRow(ctx, updateValidationResult, + arg.ID, + arg.ValidationStatus, + arg.ValidationSignal, + arg.ValidationScore, + arg.RiskWarning, + arg.ContinueRecommendation, + arg.ValidationFinishedAt, + ) + var i Validation + err := row.Scan( + &i.ID, + &i.AssetID, + &i.RequestID, + &i.ValidationType, + &i.ValidationRequested, + &i.ValidationStatus, + &i.ValidationSignal, + &i.ValidationScore, + &i.RiskWarning, + &i.ContinueRecommendation, + &i.ValidationCreatedAt, + &i.ValidationFinishedAt, + ) + return i, err +} diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..91df911 --- /dev/null +++ b/internal/router/router.go @@ -0,0 +1,55 @@ +package router + +import ( + "net/http" + "time" + + "gitea.starryskymeow.cn/B309/datamarket/internal/handler" + "gitea.starryskymeow.cn/B309/datamarket/internal/service" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +func New(svc *service.Service) http.Handler { + r := chi.NewRouter() + + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.Timeout(60 * time.Second)) + + r.Route("/api", func(r chi.Router) { + r.Route("/assets", func(r chi.Router) { + r.Post("/", handler.CreateAsset(svc)) + r.Get("/", handler.ListAssets(svc)) + r.Get("/{id}", handler.GetAsset(svc)) + r.Put("/{id}/status", handler.UpdateAssetStatus(svc)) + }) + + r.Route("/pricing", func(r chi.Router) { + r.Post("/", handler.CreatePricing(svc)) + r.Get("/{id}", handler.GetPricing(svc)) + }) + + r.Route("/validations", func(r chi.Router) { + r.Post("/", handler.CreateValidation(svc)) + r.Get("/{id}", handler.GetValidation(svc)) + }) + + r.Route("/orders", func(r chi.Router) { + r.Post("/", handler.CreateOrder(svc)) + r.Get("/", handler.ListOrders(svc)) + r.Get("/{id}", handler.GetOrder(svc)) + r.Put("/{id}/status", handler.UpdateOrderStatus(svc)) + }) + + r.Route("/admin", func(r chi.Router) { + r.Get("/assets", handler.AdminListAssets(svc)) + r.Get("/validations", handler.AdminListValidations(svc)) + r.Get("/orders", handler.AdminListOrders(svc)) + }) + }) + + return r +} diff --git a/internal/service/errors.go b/internal/service/errors.go new file mode 100644 index 0000000..242e8fa --- /dev/null +++ b/internal/service/errors.go @@ -0,0 +1,65 @@ +package service + +import "errors" + +type Error struct { + Status int + Message string + Err error +} + +func (e *Error) Error() string { + if e == nil { + return "" + } + + return e.Message +} + +func (e *Error) Unwrap() error { + if e == nil { + return nil + } + + return e.Err +} + +func newError(status int, message string, err error) error { + return &Error{ + Status: status, + Message: message, + Err: err, + } +} + +func badRequest(message string) error { + return newError(400, message, nil) +} + +func NewValidationError(message string) error { + return badRequest(message) +} + +func notFound(message string) error { + return newError(404, message, nil) +} + +func internalError(message string, err error) error { + return newError(500, message, err) +} + +func StatusCode(err error) int { + if serviceErr, ok := errors.AsType[*Error](err); ok { + return serviceErr.Status + } + + return 500 +} + +func Message(err error) string { + if serviceErr, ok := errors.AsType[*Error](err); ok { + return serviceErr.Message + } + + return "internal server error" +} diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..3b3e510 --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,1020 @@ +package service + +import ( + "context" + "errors" + "fmt" + "math" + "strconv" + "strings" + "time" + + "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 + now func() time.Time +} + +func New(queries *repository.Queries) *Service { + return &Service{ + queries: queries, + 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), + } +} + +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 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 +} diff --git a/internal/service/service_test.go b/internal/service/service_test.go new file mode 100644 index 0000000..754bb49 --- /dev/null +++ b/internal/service/service_test.go @@ -0,0 +1,84 @@ +package service + +import ( + "testing" + + "gitea.starryskymeow.cn/B309/datamarket/internal/repository" +) + +func TestNormalizePage(t *testing.T) { + tests := []struct { + name string + limit int32 + offset int32 + wantLimit int32 + wantOffset int32 + }{ + {name: "default values", limit: 0, offset: -1, wantLimit: defaultLimit, wantOffset: 0}, + {name: "cap max limit", limit: 999, offset: 3, wantLimit: maxLimit, wantOffset: 3}, + {name: "keep valid page", limit: 15, offset: 8, wantLimit: 15, wantOffset: 8}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizePage(tt.limit, tt.offset) + if got.Limit != tt.wantLimit || got.Offset != tt.wantOffset { + t.Fatalf("normalizePage() = %+v, want limit=%d offset=%d", got, tt.wantLimit, tt.wantOffset) + } + }) + } +} + +func TestBuildAssetDerivedFields(t *testing.T) { + input := AssetCreateInput{ + AssetName: "机械臂抓取演示数据集A", + AssetType: "视频+轨迹", + Domain: "机器人", + DataDescription: "demo", + DataScale: "1200条轨迹", + CollectionMethod: "真实采集", + LabelingStatus: new("已完整标注"), + PrivacyLevel: "中", + PermissionMode: "授权访问", + SupportsValidation: true, + SellerExpectedPriceMin: new(float64(20000)), + SellerExpectedPriceMax: new(float64(50000)), + } + + derived, err := buildAssetDerivedFields(input) + if err != nil { + t.Fatalf("buildAssetDerivedFields() error = %v", err) + } + + if derived.QualityLevel != "高" { + t.Fatalf("unexpected quality level: %s", derived.QualityLevel) + } + if derived.ScarcityLevel != "高" { + t.Fatalf("unexpected scarcity level: %s", derived.ScarcityLevel) + } + if derived.BaseValueScore <= 0 { + t.Fatalf("unexpected base value score: %v", derived.BaseValueScore) + } + if derived.BasePriceMin >= derived.BasePriceMax { + t.Fatalf("unexpected base price range: %v - %v", derived.BasePriceMin, derived.BasePriceMax) + } +} + +func TestBuildNegotiationRange(t *testing.T) { + pricing := repository.PricingResult{ + ScenarioPriceMin: numericValue(30000), + ScenarioPriceMax: numericValue(42000), + } + + min, max, err := buildNegotiationRange(pricing, 38000) + if err != nil { + t.Fatalf("buildNegotiationRange() error = %v", err) + } + + if min != 34960 { + t.Fatalf("unexpected negotiation min: %v", min) + } + if max != 41040 { + t.Fatalf("unexpected negotiation max: %v", max) + } +} diff --git a/internal/service/types.go b/internal/service/types.go new file mode 100644 index 0000000..f49e6da --- /dev/null +++ b/internal/service/types.go @@ -0,0 +1,197 @@ +package service + +import ( + "context" + "time" +) + +type ListResult[T any] struct { + List []T `json:"list"` + Total int64 `json:"total"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +type Asset struct { + ID string `json:"id"` + AssetName string `json:"asset_name"` + AssetType string `json:"asset_type"` + Domain string `json:"domain"` + ApplicationScene *string `json:"application_scene,omitempty"` + DataDescription string `json:"data_description"` + DataScale string `json:"data_scale"` + CollectionMethod string `json:"collection_method"` + LabelingStatus *string `json:"labeling_status,omitempty"` + UpdateFrequency *string `json:"update_frequency,omitempty"` + PrivacyLevel string `json:"privacy_level"` + PermissionMode string `json:"permission_mode"` + SupportsValidation bool `json:"supports_validation"` + SellerExpectedPriceMin *float64 `json:"seller_expected_price_min,omitempty"` + SellerExpectedPriceMax *float64 `json:"seller_expected_price_max,omitempty"` + QualityLevel *string `json:"quality_level,omitempty"` + ScarcityLevel *string `json:"scarcity_level,omitempty"` + BaseValueScore *float64 `json:"base_value_score,omitempty"` + BasePriceMin *float64 `json:"base_price_min,omitempty"` + BasePriceMax *float64 `json:"base_price_max,omitempty"` + AssetStatus string `json:"asset_status"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +type AssetCreateInput struct { + AssetName string + AssetType string + Domain string + ApplicationScene *string + DataDescription string + DataScale string + CollectionMethod string + LabelingStatus *string + UpdateFrequency *string + PrivacyLevel string + PermissionMode string + SupportsValidation bool + SellerExpectedPriceMin *float64 + SellerExpectedPriceMax *float64 +} + +type AssetListInput struct { + Limit int32 + Offset int32 + Keyword *string + AssetType *string + Domain *string + PrivacyLevel *string + SupportsValidation *bool +} + +type StatusUpdate struct { + Status string +} + +type AssetStatusResult struct { + AssetID string `json:"asset_id"` + AssetStatus string `json:"asset_status"` +} + +type Pricing struct { + ID string `json:"pricing_id"` + AssetID string `json:"asset_id"` + RequestID string `json:"request_id"` + ScenarioValueScore *float64 `json:"scenario_value_score,omitempty"` + ScenarioPriceMin *float64 `json:"scenario_price_min,omitempty"` + ScenarioPriceMax *float64 `json:"scenario_price_max,omitempty"` + SuggestedPrice *float64 `json:"suggested_price,omitempty"` + SuccessProbability *float64 `json:"success_probability,omitempty"` + PricingReason1 *string `json:"pricing_reason_1,omitempty"` + PricingReason2 *string `json:"pricing_reason_2,omitempty"` + PricingReason3 *string `json:"pricing_reason_3,omitempty"` + VerificationSuggestion *string `json:"verification_suggestion,omitempty"` + PricingStatus string `json:"pricing_status"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +type PricingCreateInput struct { + AssetID string + TaskType string + ModelType string + BuyerBudgetMin *float64 + BuyerBudgetMax *float64 + PrivacyRequirement *string + UsagePurpose *string + RequestNote *string +} + +type Validation struct { + ID string `json:"validation_id"` + AssetID string `json:"asset_id"` + RequestID string `json:"request_id"` + ValidationType *string `json:"validation_type,omitempty"` + ValidationRequested bool `json:"validation_requested"` + ValidationStatus string `json:"validation_status"` + ValidationSignal *string `json:"validation_signal,omitempty"` + ValidationScore *float64 `json:"validation_score,omitempty"` + RiskWarning *string `json:"risk_warning,omitempty"` + ContinueRecommendation *string `json:"continue_recommendation,omitempty"` + ValidationCreatedAt *time.Time `json:"validation_created_at,omitempty"` + ValidationFinishedAt *time.Time `json:"validation_finished_at,omitempty"` +} + +type ValidationCreateInput struct { + AssetID string + RequestID string + ValidationType string +} + +type ValidationCreateResult struct { + ValidationID string `json:"validation_id"` + ValidationStatus string `json:"validation_status"` +} + +type Order struct { + ID string `json:"order_id"` + AssetID string `json:"asset_id"` + RequestID string `json:"request_id"` + PricingID string `json:"pricing_id"` + ValidationID string `json:"validation_id,omitempty"` + AssetName string `json:"asset_name"` + CurrentPrice *float64 `json:"current_price,omitempty"` + NegotiationMin *float64 `json:"negotiation_min,omitempty"` + NegotiationMax *float64 `json:"negotiation_max,omitempty"` + ValidationUsed bool `json:"validation_used"` + DeliveryMode string `json:"delivery_mode"` + OrderStatus string `json:"order_status"` + OrderCreatedAt *time.Time `json:"order_created_at,omitempty"` + OrderUpdatedAt *time.Time `json:"order_updated_at,omitempty"` +} + +type OrderCreateInput struct { + AssetID string + RequestID string + PricingID string + ValidationID *string + CurrentPrice *float64 + DeliveryMode string +} + +type OrderCreateResult struct { + OrderID string `json:"order_id"` + OrderStatus string `json:"order_status"` +} + +type OrderListInput struct { + Limit int32 + Offset int32 + OrderStatus string +} + +type ValidationListInput struct { + Limit int32 + Offset int32 +} + +type AssetService interface { + CreateAsset(context.Context, AssetCreateInput) (AssetStatusResult, error) + GetAsset(context.Context, string) (Asset, error) + ListAssets(context.Context, AssetListInput) (ListResult[Asset], error) + UpdateAssetStatus(context.Context, string, StatusUpdate) (AssetStatusResult, error) +} + +type PricingService interface { + CreatePricing(context.Context, PricingCreateInput) (Pricing, error) + GetPricing(context.Context, string) (Pricing, error) +} + +type ValidationService interface { + CreateValidation(context.Context, ValidationCreateInput) (ValidationCreateResult, error) + GetValidation(context.Context, string) (Validation, error) + ListValidations(context.Context, ValidationListInput) (ListResult[Validation], error) +} + +type OrderService interface { + CreateOrder(context.Context, OrderCreateInput) (OrderCreateResult, error) + GetOrder(context.Context, string) (Order, error) + ListOrders(context.Context, OrderListInput) (ListResult[Order], error) + UpdateOrderStatus(context.Context, string, StatusUpdate) (OrderCreateResult, error) +}