diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 38c8721..0d7cc7d 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.- -wiki_revision: 5702b75cf0a7e045f09f58eb14d5362fc3734c09 -synchronized_at: 2026-08-20T15:12:12Z +wiki_revision: 7946f2c375d59e4b70d6485f454a0f048e6a14b6 +synchronized_at: 2026-08-20T15:29:14Z # 架构与代码地图 @@ -118,7 +118,7 @@ Android Portal/Agent | SYB 店铺准入、发现与过滤 | `server/app/goauto/sybshop/`、`server/app/goauto/sybimport/`;迁移 `server/cmd/migrate/migration/version-local/1786700900000_syb_shop.go` | | SYB 后台导入任务、进度、单任务互斥与启动恢复 | `server/app/goauto/sybimport/sync_run.go`、`sync_run_handler.go`;表 `syb_sync_run`,迁移 `server/cmd/migrate/migration/version-local/1786701000000_syb_sync_run.go` | | 采购任务数据与类型化规则契约 | `server/app/goauto/models/purchase.go`、`server/app/goauto/purchasecontract/`;迁移 `server/cmd/migrate/migration/version-local/1786701100000_purchase_contract.go` | -| 采购任务创建、租约、attempt 幂等和人工处置状态机 | `server/app/goauto/purchase/`;追加迁移 `server/cmd/migrate/migration/version-local/1786701200000_purchase_state_machine.go` | +| 采购任务创建、租约、attempt 幂等、Admin 只读查询和人工处置状态机 | `server/app/goauto/purchase/`;追加迁移 `server/cmd/migrate/migration/version-local/1786701200000_purchase_state_machine.go` | | 任务领取、结果、重置与删除 | `server/app/goauto/task/` | | 管理端基线 | `web/`(go-admin-ui v3.0.0) | | 管理端闭环页面 | `web/src/views/goauto/` | diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 6b3fc10..3b41f67 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Android-Agent-API-Contract wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.- -wiki_revision: cac56434a387fcac4314ab62248c6ae5a9c69309 -synchronized_at: 2026-08-20T15:12:36Z +wiki_revision: 5952395a43ce26d7ee6cdc8076f2367769de9310 +synchronized_at: 2026-08-20T15:29:46Z # MVP 共享 API 契约 @@ -460,6 +460,8 @@ POST /api/agent/v1/tasks/{taskId}/fail | 方法 | 路径 | 幂等键 / 说明 | |---|---|---| +| `GET` | `/api/admin/v1/purchase-tasks` | 分页列表;可按任务 ID、状态、模式、SYB 商品 ID 和 PDD 订单号筛选 | +| `GET` | `/api/admin/v1/purchase-tasks/{taskId}` | 只读任务详情与 attempt 历史;不返回规则原文、Token、凭据、完整地址、控件树或截图 | | `POST` | `/api/admin/v1/purchase-tasks` | `requestId`;单条创建 | | `POST` | `/api/admin/v1/purchase-tasks/{taskId}/authorize-repurchase` | 一次性授权;创建新任务后自动消耗 | | `POST` | `/api/admin/v1/purchase-tasks/{taskId}/payment-review` | `paid` 或 `unpaid`,管理员和采购员可操作 | diff --git a/docs/09-delivery-issues.md b/docs/09-delivery-issues.md index fa42220..7a62aa3 100644 --- a/docs/09-delivery-issues.md +++ b/docs/09-delivery-issues.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Delivery-Issues wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Delivery-Issues.- -wiki_revision: 9dca116d826e7c6a6f471363dc1a4114a18f7a12 -synchronized_at: 2026-08-20T15:21:28Z +wiki_revision: 04a5ca5907a36e20d0258a419aed7b2763f739bd +synchronized_at: 2026-08-20T15:29:51Z # 当前 MVP 交付工单索引 @@ -71,6 +71,7 @@ synchronized_at: 2026-08-20T15:21:28Z | T50 | [#52](https://git.ilapage.cn/OPC/goauto/issues/52) | 修复 SYB 同步预创建失败无法定位(2026-08-20 已验收) | 已完成 | | T51 | [#53](https://git.ilapage.cn/OPC/goauto/issues/53) | 补齐采购规则参数化动作契约(2026-08-20 已验收) | 已完成;#42 阻塞已解除 | | T52 | [#54](https://git.ilapage.cn/OPC/goauto/issues/54) | 统一采集与采购任务的设备心跳、忙碌和离线处理(2026-08-20 已验收) | 已完成 | +| T53 | [#55](https://git.ilapage.cn/OPC/goauto/issues/55) | 补齐采购任务 Admin 列表与详情查询接口 | 已实施,等待验收;解除 #35 查询接口阻塞 | 推荐依赖顺序:#31、#40、#41 完成商品域 → #33、#34 建立采购契约和服务端状态机 → #53 补齐参数化动作契约 → #42 完成不下单演练 → #35 管理端人工处理 → #36 高风险真实订单动作 → #37、#38 物流闭环 → #39 真机总验收。 diff --git a/server/app/goauto/purchase/admin_query.go b/server/app/goauto/purchase/admin_query.go new file mode 100644 index 0000000..f56c5fc --- /dev/null +++ b/server/app/goauto/purchase/admin_query.go @@ -0,0 +1,239 @@ +package purchase + +import ( + "context" + "errors" + "strings" + "time" + + "go-admin/app/goauto/models" + + "gorm.io/gorm" +) + +type AdminListRequest struct { + Page int + PageSize int + TaskID uint64 + Status string + ExecutionMode string + SYBProductID uint64 + PDDOrderNo string +} + +type AdminTaskItem struct { + ID uint64 `json:"id"` + ExecutionMode string `json:"executionMode"` + Status string `json:"status"` + SYBProductID *uint64 `json:"sybProductId,omitempty"` + ShopeeProductID *uint64 `json:"shopeeProductId,omitempty"` + PDDProductID uint64 `json:"pddProductId"` + DeviceID *uint64 `json:"deviceId,omitempty"` + DeviceName string `json:"deviceName,omitempty"` + ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot"` + ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot"` + ShopeeShopNameSnapshot string `json:"shopeeShopNameSnapshot"` + PDDGoodsIDSnapshot string `json:"pddGoodsIdSnapshot"` + PDDTitleSnapshot string `json:"pddTitleSnapshot"` + TargetColorSnapshot string `json:"targetColorSnapshot"` + TargetSizeSnapshot string `json:"targetSizeSnapshot"` + MappedColorSnapshot string `json:"mappedColorSnapshot"` + MappedSizeSnapshot string `json:"mappedSizeSnapshot"` + SpecSource string `json:"specSource"` + Quantity int64 `json:"quantity"` + ReferenceUnitPriceCent int64 `json:"referenceUnitPriceCent"` + MinUnitPriceCent int64 `json:"minUnitPriceCent"` + MaxUnitPriceCent int64 `json:"maxUnitPriceCent"` + Currency string `json:"currency"` + PDDAccountRefSnapshot string `json:"pddAccountRefSnapshot"` + AddressSuffix string `json:"addressSuffix"` + PDDOrderNo *string `json:"pddOrderNo,omitempty"` + OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"` + IrreversibleAt *time.Time `json:"irreversibleAt,omitempty"` + PaymentReviewStatus string `json:"paymentReviewStatus"` + PaymentReviewedAt *time.Time `json:"paymentReviewedAt,omitempty"` + TrackingNo *string `json:"trackingNo,omitempty"` + TrackingCollectedAt *time.Time `json:"trackingCollectedAt,omitempty"` + LogisticsStatus string `json:"logisticsStatus"` + WritebackStatus string `json:"writebackStatus"` + WritebackAt *time.Time `json:"writebackAt,omitempty"` + RePurchaseAuthorizedAt *time.Time `json:"rePurchaseAuthorizedAt,omitempty"` + RePurchaseConsumedAt *time.Time `json:"rePurchaseConsumedAt,omitempty"` + CancelledAt *time.Time `json:"cancelledAt,omitempty"` + CancelReason *string `json:"cancelReason,omitempty"` + ErrorCode *string `json:"errorCode,omitempty"` + ErrorMessage *string `json:"errorMessage,omitempty"` + StatusVersion uint64 `json:"statusVersion"` + StatusChangedAt time.Time `json:"statusChangedAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type AdminAttemptItem struct { + AttemptID string `json:"attemptId"` + AttemptNumber int `json:"attemptNumber"` + Phase string `json:"phase"` + Status string `json:"status"` + DeviceID *uint64 `json:"deviceId,omitempty"` + RuleSnapshotHash string `json:"ruleSnapshotHash"` + ResultType *string `json:"resultType,omitempty"` + ErrorCode *string `json:"errorCode,omitempty"` + ErrorMessage *string `json:"errorMessage,omitempty"` + StartedAt *time.Time `json:"startedAt,omitempty"` + FinishedAt *time.Time `json:"finishedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +type AdminListResponse struct { + Items []AdminTaskItem `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +type AdminDetailResponse struct { + Task AdminTaskItem `json:"task"` + Attempts []AdminAttemptItem `json:"attempts"` +} + +func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminListResponse, error) { + if req.Page < 1 { + req.Page = 1 + } + if req.PageSize < 1 { + req.PageSize = 20 + } + if req.PageSize > 100 { + req.PageSize = 100 + } + if req.Status != "" && !validPurchaseStatus(req.Status) { + return AdminListResponse{}, fail(CodeInvalidRequest, "status 无效") + } + if req.ExecutionMode != "" && req.ExecutionMode != models.PurchaseExecutionModeRehearsal && req.ExecutionMode != models.PurchaseExecutionModeLive { + return AdminListResponse{}, fail(CodeInvalidRequest, "executionMode 无效") + } + query := s.DB.WithContext(ctx).Model(&models.PurchaseTask{}) + if req.TaskID > 0 { + query = query.Where("id = ?", req.TaskID) + } + if req.Status != "" { + query = query.Where("status = ?", req.Status) + } + if req.ExecutionMode != "" { + query = query.Where("execution_mode = ?", req.ExecutionMode) + } + if req.SYBProductID > 0 { + query = query.Where("syb_product_id = ?", req.SYBProductID) + } + if orderNo := strings.TrimSpace(req.PDDOrderNo); orderNo != "" { + query = query.Where("pdd_order_no LIKE ?", "%"+orderNo+"%") + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return AdminListResponse{}, internal(err) + } + var tasks []models.PurchaseTask + if err := query.Order("created_at DESC, id DESC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&tasks).Error; err != nil { + return AdminListResponse{}, internal(err) + } + deviceNames, err := loadDeviceNames(s.DB.WithContext(ctx), tasks) + if err != nil { + return AdminListResponse{}, err + } + items := make([]AdminTaskItem, 0, len(tasks)) + for _, task := range tasks { + items = append(items, adminTaskItem(task, deviceNames)) + } + return AdminListResponse{Items: items, Total: total, Page: req.Page, PageSize: req.PageSize}, nil +} + +func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailResponse, error) { + var task models.PurchaseTask + if err := s.DB.WithContext(ctx).First(&task, taskID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return AdminDetailResponse{}, fail(CodeTaskNotFound, "采购任务不存在") + } + return AdminDetailResponse{}, internal(err) + } + deviceNames, err := loadDeviceNames(s.DB.WithContext(ctx), []models.PurchaseTask{task}) + if err != nil { + return AdminDetailResponse{}, err + } + var attempts []models.PurchaseTaskAttempt + if err := s.DB.WithContext(ctx).Where("task_id = ?", task.ID).Order("attempt_number ASC, id ASC").Find(&attempts).Error; err != nil { + return AdminDetailResponse{}, internal(err) + } + items := make([]AdminAttemptItem, 0, len(attempts)) + for _, attempt := range attempts { + items = append(items, AdminAttemptItem{ + AttemptID: attempt.AttemptID, AttemptNumber: attempt.AttemptNumber, Phase: attempt.Phase, + Status: attempt.Status, DeviceID: attempt.DeviceID, RuleSnapshotHash: attempt.RuleSnapshotHash, + ResultType: attempt.ResultType, ErrorCode: attempt.ErrorCode, ErrorMessage: attempt.ErrorMessage, + StartedAt: attempt.StartedAt, FinishedAt: attempt.FinishedAt, CreatedAt: attempt.CreatedAt, + }) + } + return AdminDetailResponse{Task: adminTaskItem(task, deviceNames), Attempts: items}, nil +} + +func loadDeviceNames(db *gorm.DB, tasks []models.PurchaseTask) (map[uint64]string, error) { + ids := make([]uint64, 0, len(tasks)) + seen := make(map[uint64]bool) + for _, task := range tasks { + if task.DeviceID != nil && !seen[*task.DeviceID] { + ids = append(ids, *task.DeviceID) + seen[*task.DeviceID] = true + } + } + names := make(map[uint64]string, len(ids)) + if len(ids) == 0 { + return names, nil + } + var devices []models.AgentDevice + if err := db.Select("id, name").Where("id IN ?", ids).Find(&devices).Error; err != nil { + return nil, internal(err) + } + for _, device := range devices { + names[device.ID] = device.Name + } + return names, nil +} + +func adminTaskItem(task models.PurchaseTask, deviceNames map[uint64]string) AdminTaskItem { + item := AdminTaskItem{ + ID: task.ID, ExecutionMode: task.ExecutionMode, Status: task.Status, + SYBProductID: task.SYBProductID, ShopeeProductID: task.ShopeeProductID, PDDProductID: task.PDDProductID, + DeviceID: task.DeviceID, ShopeeItemIDSnapshot: task.ShopeeItemIDSnapshot, + ShopeeTitleSnapshot: task.ShopeeTitleSnapshot, ShopeeShopNameSnapshot: task.ShopeeShopNameSnapshot, + PDDGoodsIDSnapshot: task.PDDGoodsIDSnapshot, PDDTitleSnapshot: task.PDDTitleSnapshot, + TargetColorSnapshot: task.TargetColorSnapshot, TargetSizeSnapshot: task.TargetSizeSnapshot, + MappedColorSnapshot: task.MappedColorSnapshot, MappedSizeSnapshot: task.MappedSizeSnapshot, + SpecSource: task.SpecSource, Quantity: task.Quantity, ReferenceUnitPriceCent: task.ReferenceUnitPriceCent, + MinUnitPriceCent: task.MinUnitPriceCent, MaxUnitPriceCent: task.MaxUnitPriceCent, Currency: task.Currency, + PDDAccountRefSnapshot: task.PDDAccountRefSnapshot, AddressSuffix: task.AddressSuffix, + PDDOrderNo: task.PDDOrderNo, OrderSubmittedAt: task.OrderSubmittedAt, IrreversibleAt: task.IrreversibleAt, + PaymentReviewStatus: task.PaymentReviewStatus, PaymentReviewedAt: task.PaymentReviewedAt, + TrackingNo: task.TrackingNo, TrackingCollectedAt: task.TrackingCollectedAt, + LogisticsStatus: task.LogisticsStatus, WritebackStatus: task.WritebackStatus, WritebackAt: task.WritebackAt, + RePurchaseAuthorizedAt: task.RePurchaseAuthorizedAt, RePurchaseConsumedAt: task.RePurchaseConsumedAt, + CancelledAt: task.CancelledAt, CancelReason: task.CancelReason, ErrorCode: task.ErrorCode, + ErrorMessage: task.ErrorMessage, StatusVersion: task.StatusVersion, StatusChangedAt: task.StatusChangedAt, + CreatedAt: task.CreatedAt, UpdatedAt: task.UpdatedAt, + } + if task.DeviceID != nil { + item.DeviceName = deviceNames[*task.DeviceID] + } + return item +} + +func validPurchaseStatus(status string) bool { + switch status { + case models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending, + models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusRehearsalCompleted, + models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusOrderCreated, + models.PurchaseTaskStatusOrderResultUnknown, models.PurchaseTaskStatusFailed, + models.PurchaseTaskStatusCancelled: + return true + default: + return false + } +} diff --git a/server/app/goauto/purchase/admin_query_test.go b/server/app/goauto/purchase/admin_query_test.go new file mode 100644 index 0000000..5f2106b --- /dev/null +++ b/server/app/goauto/purchase/admin_query_test.go @@ -0,0 +1,103 @@ +package purchase + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/purchasecontract" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +func createAdminQueryTask(t *testing.T, s *Service, f fixture) models.PurchaseTask { + t.Helper() + rule := json.RawMessage(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],"actions":[{"type":"openProduct"},{"type":"verifyProduct"},{"type":"verifyOrderSummary"}]}`) + task, _, err := s.Create(context.Background(), CreateRequest{ + RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeRehearsal, + PDDProductID: &f.pdd.ID, DeviceID: &f.device.ID, Quantity: 1, + MinUnitPriceCent: 100, MaxUnitPriceCent: 5000, Currency: "CNY", RuleSnapshot: rule, + }) + if err != nil { + t.Fatalf("create purchase task: %v", err) + } + return task +} + +func TestAdminListAndDetailExposeSafePurchaseViews(t *testing.T) { + db := testDB(t) + f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true) + s := testService(db) + task := createAdminQueryTask(t, s, f) + attempt := models.PurchaseTaskAttempt{ + TaskID: task.ID, AttemptID: uuid.NewString(), AttemptNumber: 1, + Phase: models.PurchaseAttemptPhasePurchase, Status: models.PurchaseAttemptStatusPending, + DeviceID: &f.device.ID, RuleSnapshotHash: strings.Repeat("a", 64), SpecDecisionSnapshot: `{}`, + } + if err := db.Create(&attempt).Error; err != nil { + t.Fatalf("create attempt: %v", err) + } + + list, err := s.AdminList(context.Background(), AdminListRequest{ + Page: 1, PageSize: 20, TaskID: task.ID, Status: models.PurchaseTaskStatusPending, + ExecutionMode: models.PurchaseExecutionModeRehearsal, + }) + if err != nil { + t.Fatalf("admin list: %v", err) + } + if list.Total != 1 || len(list.Items) != 1 || list.Items[0].DeviceName != f.device.Name || list.Items[0].PDDGoodsIDSnapshot != f.pdd.GoodsID { + t.Fatalf("unexpected list: %+v", list) + } + detail, err := s.AdminDetail(context.Background(), task.ID) + if err != nil { + t.Fatalf("admin detail: %v", err) + } + if detail.Task.ID != task.ID || len(detail.Attempts) != 1 || detail.Attempts[0].AttemptID != attempt.AttemptID { + t.Fatalf("unexpected detail: %+v", detail) + } + raw, err := json.Marshal(detail) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"ruleSnapshot\"", "requiredCapabilities", "createRequestId", "token", "credential", "shippingAddress", "accessibilityTree", "screenshot"} { + if strings.Contains(string(raw), forbidden) { + t.Fatalf("admin detail leaked %q: %s", forbidden, raw) + } + } +} + +func TestAdminQueryValidationAndNotFound(t *testing.T) { + s := testService(testDB(t)) + if _, err := s.AdminList(context.Background(), AdminListRequest{Status: "unknown"}); code(err) != CodeInvalidRequest { + t.Fatalf("invalid status accepted: %v", err) + } + if _, err := s.AdminList(context.Background(), AdminListRequest{ExecutionMode: "unknown"}); code(err) != CodeInvalidRequest { + t.Fatalf("invalid mode accepted: %v", err) + } + if _, err := s.AdminDetail(context.Background(), 999); code(err) != CodeTaskNotFound { + t.Fatalf("missing task error=%v", err) + } +} + +func TestAdminQueryHandlersRequireOperatorRole(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, path := range []string{"/api/admin/v1/purchase-tasks", "/api/admin/v1/purchase-tasks/1"} { + recorder := httptest.NewRecorder() + context, _ := gin.CreateTestContext(recorder) + context.Request = httptest.NewRequest(http.MethodGet, path, nil) + context.Params = gin.Params{{Key: "taskId", Value: "1"}} + if strings.HasSuffix(path, "/1") { + (Handler{}).AdminDetail(context) + } else { + (Handler{}).AdminList(context) + } + if recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), "FORBIDDEN") { + t.Fatalf("unauthorized query %s returned %d %s", path, recorder.Code, recorder.Body.String()) + } + } +} diff --git a/server/app/goauto/purchase/handler.go b/server/app/goauto/purchase/handler.go index 535ff75..0b01915 100644 --- a/server/app/goauto/purchase/handler.go +++ b/server/app/goauto/purchase/handler.go @@ -20,6 +20,66 @@ import ( type Handler struct{ DB *gorm.DB } +func (h Handler) AdminList(c *gin.Context) { + if !allowedOperator(c) { + return + } + page, err := positiveAdminQuery(c.Query("page"), 1) + if err != nil { + writeError(c, fail(CodeInvalidRequest, "page 无效")) + return + } + pageSize, err := positiveAdminQuery(c.Query("pageSize"), 20) + if err != nil { + writeError(c, fail(CodeInvalidRequest, "pageSize 无效")) + return + } + taskID, err := optionalAdminUint(c.Query("taskId")) + if err != nil { + writeError(c, fail(CodeInvalidRequest, "taskId 无效")) + return + } + sybProductID, err := optionalAdminUint(c.Query("sybProductId")) + if err != nil { + writeError(c, fail(CodeInvalidRequest, "sybProductId 无效")) + return + } + service, ok := h.service(c) + if !ok { + return + } + response, err := service.AdminList(c.Request.Context(), AdminListRequest{ + Page: page, PageSize: pageSize, TaskID: taskID, SYBProductID: sybProductID, + Status: strings.TrimSpace(c.Query("status")), ExecutionMode: strings.TrimSpace(c.Query("executionMode")), + PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")), + }) + if err != nil { + writeError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"data": response}) +} + +func (h Handler) AdminDetail(c *gin.Context) { + if !allowedOperator(c) { + return + } + id, ok := pathID(c) + if !ok { + return + } + service, serviceOK := h.service(c) + if !serviceOK { + return + } + response, err := service.AdminDetail(c.Request.Context(), id) + if err != nil { + writeError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"data": response}) +} + func (h Handler) AdminCreate(c *gin.Context) { if !allowedOperator(c) { return @@ -248,3 +308,21 @@ func operatorID(c *gin.Context) uint64 { } return 0 } + +func positiveAdminQuery(raw string, fallback int) (int, error) { + if strings.TrimSpace(raw) == "" { + return fallback, nil + } + value, err := strconv.Atoi(raw) + if err != nil || value < 1 { + return 0, errors.New("invalid positive integer") + } + return value, nil +} + +func optionalAdminUint(raw string) (uint64, error) { + if strings.TrimSpace(raw) == "" { + return 0, nil + } + return strconv.ParseUint(raw, 10, 64) +} diff --git a/server/app/goauto/purchase/router.go b/server/app/goauto/purchase/router.go index b26cf28..3c90da2 100644 --- a/server/app/goauto/purchase/router.go +++ b/server/app/goauto/purchase/router.go @@ -22,6 +22,8 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { agent.POST("/:taskId/order-submit-started", h.OrderSubmitStarted) agent.POST("/:taskId/result", h.Result) admin := engine.Group("/api/admin/v1/purchase-tasks").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()) + admin.GET("", h.AdminList) + admin.GET("/:taskId", h.AdminDetail) admin.POST("", h.AdminCreate) admin.POST("/:taskId/spec-decision", h.SpecDecision) admin.POST("/:taskId/authorize-repurchase", h.AuthorizeRePurchase)