feat(#55): add purchase admin query APIs
This commit is contained in:
@@ -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/` |
|
||||
|
||||
@@ -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`,管理员和采购员可操作 |
|
||||
|
||||
@@ -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 真机总验收。
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user