feat(#55): add purchase admin query APIs

This commit is contained in:
QiuSW
2026-08-20 23:31:23 +08:00
parent e5b8d48856
commit 182ef3940a
7 changed files with 432 additions and 7 deletions
+3 -3
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -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/` |
+4 -2
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 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`,管理员和采购员可操作 |
+3 -2
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 当前 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 真机总验收。
+239
View File
@@ -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
}
}
@@ -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())
}
}
}
+78
View File
@@ -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)
}
+2
View File
@@ -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)