diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 2632f58..8cb595b 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.- -wiki_revision: 5da7f2250f851caab6d0d1713f869b619ed55eb2 -synchronized_at: 2026-08-20T08:47:11Z +wiki_revision: 39297e8886ddcd441c9cb874f399b3962055a3a1 +synchronized_at: 2026-08-20T14:49:50Z # 业务规则与术语 @@ -140,8 +140,9 @@ synchronized_at: 2026-08-20T08:47:11Z - Device Token 明文只返回给客户端,服务端数据库只保存不可逆摘要。 - 设备状态只保存在线、离线或停用;是否忙碌由运行中的任务关系确定。 - Agent 默认每 15 秒心跳一次,45 秒没有心跳即离线;离线扫描每 15 秒执行。 -- 心跳上报的 `currentTaskId` 必须与服务端该设备唯一运行任务一致,否则拒绝更新心跳。 -- 设备超时离线时,其运行任务立即以 `DEVICE_OFFLINE` 失败,清除租约且不自动重试。 +- 心跳上报的 `currentTaskId` 必须与服务端该设备唯一运行任务一致;采集任务和采购任务共用这一设备级忙碌事实,异常出现跨域同时运行时按服务端一致性错误处理。 +- 设备超时离线时,运行中的采集任务和尚未提交订单的采购任务立即以 `DEVICE_OFFLINE` 失败,清除租约及运行槽且不自动重试;采购 attempt 同步结束并标为失败。 +- 采购任务已进入 `order_submit_started` 后设备离线时,任务转为 `order_result_unknown` 并释放设备/账号运行槽,必须人工核对,禁止自动重派或再次点击创建订单。 ## 术语 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 9b014b5..b280e9a 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: 8b81c816989f9063a6927939a3391c103ddc08ad -synchronized_at: 2026-08-20T09:51:45Z +wiki_revision: 60e8cebb504e23f1a42076977d8d99b486909594 +synchronized_at: 2026-08-20T14:51:34Z # MVP 共享 API 契约 @@ -273,9 +273,9 @@ POST /api/admin/v1/devices/{deviceId}/token/revoke } ``` -服务端校验 `currentTaskId` 必须与该设备唯一的 `running` 任务一致,不一致返回 HTTP 409 和 `DEVICE_TASK_MISMATCH`,不更新心跳。响应返回服务端确认的 `currentTaskId`、`online`、由运行任务派生的 `busy`、服务端时间和下一次心跳间隔。相同 `requestId` 重放不推进心跳时间。 +服务端校验 `currentTaskId` 必须与该设备唯一的执行中任务一致;执行中任务统一覆盖采集任务的 `running`,以及采购任务的 `running` / `order_submit_started`。不一致返回 HTTP 409 和 `DEVICE_TASK_MISMATCH`,不更新心跳。响应返回服务端确认的 `currentTaskId`、`online`、由两类任务统一派生的 `busy`、服务端时间和下一次心跳间隔。相同 `requestId` 重放不推进心跳时间;若数据库异常存在跨任务域同时运行,服务端返回内部一致性错误,不静默选择其中一条。 -默认心跳间隔 15 秒、离线阈值 45 秒、扫描间隔 15 秒。超过阈值后设备标为 `offline`;其 `running` 任务同时清除租约和活动 guard,转为 `failed`,错误码为 `DEVICE_OFFLINE`,且不自动重试或换机。设备重新发送合法心跳后可以恢复 `online`,但失败任务不会自动恢复。 +默认心跳间隔 15 秒、离线阈值 45 秒、扫描间隔 15 秒。超过阈值后设备标为 `offline`:运行中的采集任务和未进入订单提交边界的采购任务清除租约与运行 guard,转为 `failed`,对应采购 attempt 同步失败并结束,错误码为 `DEVICE_OFFLINE`,且不自动重试或换机。采购任务已处于 `order_submit_started` 时必须转为 `order_result_unknown`,释放设备/账号运行槽并等待人工核对,禁止自动重派或再次点击创建订单。设备重新发送合法心跳后可以恢复 `online`,但失败或结果未知的任务不会自动恢复。 ## Android:获取、领取和开始任务 diff --git a/docs/09-delivery-issues.md b/docs/09-delivery-issues.md index 91c85f1..bd5af9f 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: 89a8d94f95fb4b3a572e7e1094c82d8cbd129220 -synchronized_at: 2026-08-20T10:04:18Z +wiki_revision: 7c3027260213a9b4dc969349f41cdd25d8337e13 +synchronized_at: 2026-08-20T14:51:37Z # 当前 MVP 交付工单索引 @@ -62,7 +62,7 @@ synchronized_at: 2026-08-20T10:04:18Z | T37 | [#39](https://git.ilapage.cn/OPC/goauto/issues/39) | 采购闭环真机端到端验收 | #33~#38、#42 | | T38 | [#40](https://git.ilapage.cn/OPC/goauto/issues/40) | 虾皮商品档案、PDD 关联与规格映射(2026-08-20 已验收) | 已完成 | | T39 | [#41](https://git.ilapage.cn/OPC/goauto/issues/41) | SYB 货运单商品导入与虾皮信息提取(2026-08-20 已验收) | 已完成 | -| T40 | [#42](https://git.ilapage.cn/OPC/goauto/issues/42) | Android 采购演练规则与持久执行基线 | #33、#34、#53;只演练,不改地址、不创建订单 | +| T40 | [#42](https://git.ilapage.cn/OPC/goauto/issues/42) | Android 采购演练规则与持久执行基线 | #33、#34、#53、#54;只演练,不改地址、不创建订单 | | T43 | [#45](https://git.ilapage.cn/OPC/goauto/issues/45) | Admin PDD 商品列表多选与批量采集任务创建(2026-08-18 已验收) | 已完成 | | T46 | [#48](https://git.ilapage.cn/OPC/goauto/issues/48) | SYB ERP 客户端移植与真实导入 | #41;已实施,等待验收 | | T47 | [#49](https://git.ilapage.cn/OPC/goauto/issues/49) | 店铺管理与 SYB 同步店铺过滤(2026-08-20 已验收) | 已完成 | @@ -70,6 +70,7 @@ synchronized_at: 2026-08-20T10:04:18Z | T49 | [#51](https://git.ilapage.cn/OPC/goauto/issues/51) | 优化 SYB 商品列表工具栏与列宽(2026-08-20 已验收) | 已完成 | | 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) | 统一采集与采购任务的设备心跳、忙碌和离线处理 | 已实施,等待验收;解除 #42 服务端联调阻塞 | 推荐依赖顺序:#31、#40、#41 完成商品域 → #33、#34 建立采购契约和服务端状态机 → #53 补齐参数化动作契约 → #42 完成不下单演练 → #35 管理端人工处理 → #36 高风险真实订单动作 → #37、#38 物流闭环 → #39 真机总验收。 diff --git a/server/app/goauto/device/current_task.go b/server/app/goauto/device/current_task.go new file mode 100644 index 0000000..16c5475 --- /dev/null +++ b/server/app/goauto/device/current_task.go @@ -0,0 +1,72 @@ +package device + +import ( + "fmt" + + "go-admin/app/goauto/models" + + "gorm.io/gorm" +) + +type deviceTaskRow struct { + ID uint64 + DeviceID uint64 +} + +// currentTaskIDsForDevices derives the single task currently executing on each +// device across collection and purchase domains. A cross-domain conflict is a +// broken server invariant and must never be hidden by choosing either task. +func currentTaskIDsForDevices(db *gorm.DB, deviceIDs []uint64) (map[uint64]uint64, error) { + current := make(map[uint64]uint64) + if len(deviceIDs) == 0 { + return current, nil + } + + var collectionTasks []deviceTaskRow + if err := db.Model(&models.CollectionTask{}). + Select("id, device_id"). + Where("device_id IN ? AND status = ?", deviceIDs, models.TaskStatusRunning). + Scan(&collectionTasks).Error; err != nil { + return nil, internalError(err) + } + for _, task := range collectionTasks { + if _, exists := current[task.DeviceID]; exists { + return nil, deviceTaskConflictError(task.DeviceID) + } + current[task.DeviceID] = task.ID + } + + var purchaseTasks []deviceTaskRow + if err := db.Model(&models.PurchaseTask{}). + Select("id, device_id"). + Where("device_id IN ? AND status IN ?", deviceIDs, []string{ + models.PurchaseTaskStatusRunning, + models.PurchaseTaskStatusOrderSubmitStarted, + }). + Scan(&purchaseTasks).Error; err != nil { + return nil, internalError(err) + } + for _, task := range purchaseTasks { + if _, exists := current[task.DeviceID]; exists { + return nil, deviceTaskConflictError(task.DeviceID) + } + current[task.DeviceID] = task.ID + } + return current, nil +} + +func currentTaskForDevice(db *gorm.DB, deviceID uint64) (*uint64, error) { + current, err := currentTaskIDsForDevices(db, []uint64{deviceID}) + if err != nil { + return nil, err + } + taskID, exists := current[deviceID] + if !exists { + return nil, nil + } + return &taskID, nil +} + +func deviceTaskConflictError(deviceID uint64) error { + return internalError(fmt.Errorf("device %d has multiple running tasks across task domains", deviceID)) +} diff --git a/server/app/goauto/device/heartbeat.go b/server/app/goauto/device/heartbeat.go index c015eeb..f0138d2 100644 --- a/server/app/goauto/device/heartbeat.go +++ b/server/app/goauto/device/heartbeat.go @@ -63,7 +63,7 @@ func (service *Service) Heartbeat(ctx context.Context, request HeartbeatRequest, return &ServiceError{Code: CodeDeviceDisabled, Message: "设备已停用", Retryable: false} } - runningTaskID, err := runningTaskForDevice(tx, device.ID) + runningTaskID, err := currentTaskForDevice(tx, device.ID) if err != nil { return err } @@ -104,18 +104,6 @@ func (service *Service) Heartbeat(ctx context.Context, request HeartbeatRequest, return response, err } -func runningTaskForDevice(db *gorm.DB, deviceID uint64) (*uint64, error) { - var task models.CollectionTask - err := db.Select("id").Where("device_id = ? AND status = ?", deviceID, models.TaskStatusRunning).First(&task).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - if err != nil { - return nil, internalError(err) - } - return &task.ID, nil -} - func sameOptionalID(left, right *uint64) bool { if left == nil || right == nil { return left == nil && right == nil @@ -155,6 +143,9 @@ func (service *Service) MarkStaleDevicesOffline(ctx context.Context, threshold t }).Error; err != nil { return internalError(err) } + if err := markOfflinePurchaseTasks(tx, deviceIDs, now); err != nil { + return err + } result := tx.Model(&models.AgentDevice{}).Where("id IN ? AND status = ?", deviceIDs, models.DeviceStatusOnline). Update("status", models.DeviceStatusOffline) if result.Error != nil { @@ -166,6 +157,62 @@ func (service *Service) MarkStaleDevicesOffline(ctx context.Context, threshold t return changed, err } +func markOfflinePurchaseTasks(tx *gorm.DB, deviceIDs []uint64, now time.Time) error { + if err := markOfflinePurchaseStatus( + tx, deviceIDs, models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusFailed, + "failed", "设备心跳超时,采购任务失败且不会自动重试", now, + ); err != nil { + return err + } + return markOfflinePurchaseStatus( + tx, deviceIDs, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusOrderResultUnknown, + "order_result_unknown", "设备心跳超时,订单结果未知,请人工核对,禁止自动重试", now, + ) +} + +func markOfflinePurchaseStatus( + tx *gorm.DB, + deviceIDs []uint64, + fromStatus string, + toStatus string, + resultType string, + errorMessage string, + now time.Time, +) error { + var taskIDs []uint64 + if err := tx.Model(&models.PurchaseTask{}). + Where("device_id IN ? AND status = ?", deviceIDs, fromStatus). + Pluck("id", &taskIDs).Error; err != nil { + return internalError(err) + } + if len(taskIDs) == 0 { + return nil + } + errorCode := "DEVICE_OFFLINE" + if err := tx.Model(&models.PurchaseTaskAttempt{}). + Where("task_id IN ? AND status = ?", taskIDs, models.PurchaseAttemptStatusRunning). + Updates(map[string]any{ + "status": models.PurchaseAttemptStatusFailed, "result_type": resultType, + "error_code": errorCode, "error_message": errorMessage, "finished_at": now, + }).Error; err != nil { + return internalError(err) + } + updates := map[string]any{ + "status": toStatus, "device_run_slot": gorm.Expr("NULL"), "account_run_slot": gorm.Expr("NULL"), + "lease_expires_at": gorm.Expr("NULL"), "error_code": errorCode, "error_message": errorMessage, + "status_version": gorm.Expr("status_version + 1"), "status_changed_at": now, + } + if toStatus == models.PurchaseTaskStatusFailed { + updates["active_slot"] = gorm.Expr("NULL") + } + if err := tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}). + Where("id IN ? AND status = ?", taskIDs, fromStatus). + Updates(updates).Error; err != nil { + return internalError(err) + } + return nil +} + func RunOfflineMonitor(ctx context.Context, service *Service, scanInterval, threshold time.Duration, onError func(error)) { if scanInterval <= 0 { scanInterval = DefaultOfflineScan diff --git a/server/app/goauto/device/heartbeat_test.go b/server/app/goauto/device/heartbeat_test.go index fc4a991..d12fc04 100644 --- a/server/app/goauto/device/heartbeat_test.go +++ b/server/app/goauto/device/heartbeat_test.go @@ -2,6 +2,7 @@ package device import ( "context" + "strings" "testing" "time" @@ -27,6 +28,27 @@ func newHeartbeatTask(product models.PDDProduct, rule models.CollectionRule, dev } } +func newHeartbeatPurchaseTask(product models.PDDProduct, deviceID uint64, status string) models.PurchaseTask { + return models.PurchaseTask{ + PDDProductID: product.ID, DeviceID: &deviceID, + ExecutionMode: models.PurchaseExecutionModeRehearsal, Status: status, + ShopeeItemIDSnapshot: "", PDDURLSnapshot: product.URL, PDDGoodsIDSnapshot: product.GoodsID, + Quantity: 1, ReferenceUnitPriceCent: 100, MinUnitPriceCent: 20, MaxUnitPriceCent: 150, + Currency: "CNY", RuleType: "pddPurchase", RuleSchemaVersion: 1, + RequiredCapabilitiesJSON: `[]`, RuleSnapshot: `{}`, CreateRequestID: uuid.NewString(), + } +} + +func newRunningPurchaseAttempt(taskID, deviceID uint64) models.PurchaseTaskAttempt { + now := time.Date(2026, 8, 14, 7, 59, 0, 0, time.UTC) + return models.PurchaseTaskAttempt{ + TaskID: taskID, AttemptID: uuid.NewString(), AttemptNumber: 1, + Phase: models.PurchaseAttemptPhasePurchase, Status: models.PurchaseAttemptStatusRunning, + DeviceID: &deviceID, RuleSnapshotHash: strings.Repeat("a", 64), + SpecDecisionSnapshot: `{}`, StartedAt: &now, + } +} + func TestHeartbeatAuthenticatesAndIsIdempotent(t *testing.T) { db := openTestDatabase(t) service := newTestService(t, db) @@ -104,6 +126,56 @@ func TestHeartbeatRequiresCurrentTaskToMatchServer(t *testing.T) { } } +func TestHeartbeatAcceptsRunningPurchaseTaskAndRejectsMissingTask(t *testing.T) { + db := openTestDatabase(t) + service := newTestService(t, db) + _, registered := registerHeartbeatDevice(t, service) + product := models.PDDProduct{GoodsID: "729481506343", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=729481506343"} + if err := db.Create(&product).Error; err != nil { + t.Fatalf("create product: %v", err) + } + task := newHeartbeatPurchaseTask(product, registered.DeviceID, models.PurchaseTaskStatusRunning) + if err := db.Create(&task).Error; err != nil { + t.Fatalf("create purchase task: %v", err) + } + if _, err := service.Heartbeat(context.Background(), HeartbeatRequest{RequestID: uuid.NewString()}, testDeviceToken); serviceErrorCode(t, err) != CodeDeviceTaskMismatch { + t.Fatal("heartbeat omitted a running purchase task without conflict") + } + response, err := service.Heartbeat(context.Background(), HeartbeatRequest{RequestID: uuid.NewString(), CurrentTaskID: &task.ID}, testDeviceToken) + if err != nil { + t.Fatalf("purchase heartbeat: %v", err) + } + if !response.Busy || response.CurrentTaskID == nil || *response.CurrentTaskID != task.ID { + t.Fatalf("purchase busy state missing: %+v", response) + } +} + +func TestHeartbeatRejectsCrossDomainRunningTaskConflict(t *testing.T) { + db := openTestDatabase(t) + service := newTestService(t, db) + _, registered := registerHeartbeatDevice(t, service) + product := models.PDDProduct{GoodsID: "729481506344", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=729481506344"} + rule := models.CollectionRule{Name: "PDD 商品详情", ContentJSON: `{}`} + if err := db.Create(&product).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&rule).Error; err != nil { + t.Fatal(err) + } + collectionTask := newHeartbeatTask(product, rule, registered.DeviceID) + if err := db.Create(&collectionTask).Error; err != nil { + t.Fatal(err) + } + purchaseTask := newHeartbeatPurchaseTask(product, registered.DeviceID, models.PurchaseTaskStatusRunning) + if err := db.Create(&purchaseTask).Error; err != nil { + t.Fatal(err) + } + _, err := service.Heartbeat(context.Background(), HeartbeatRequest{RequestID: uuid.NewString(), CurrentTaskID: &collectionTask.ID}, testDeviceToken) + if serviceErrorCode(t, err) != CodeInternal { + t.Fatalf("cross-domain conflict was not rejected as an invariant error: %v", err) + } +} + func TestDisabledDeviceCannotHeartbeat(t *testing.T) { db := openTestDatabase(t) service := newTestService(t, db) @@ -172,3 +244,78 @@ func TestStaleDeviceGoesOfflineAndRunningTaskFailsWithoutRetry(t *testing.T) { t.Fatalf("offline task remains active: %d", activeCount) } } + +func TestStaleDeviceFailsRunningPurchaseTaskAndAttemptWithoutRetry(t *testing.T) { + db := openTestDatabase(t) + service := newTestService(t, db) + _, registered := registerHeartbeatDevice(t, service) + oldHeartbeat := service.Now().Add(-time.Minute) + if err := db.Model(&models.AgentDevice{}).Where("id = ?", registered.DeviceID).Update("last_heartbeat_at", oldHeartbeat).Error; err != nil { + t.Fatal(err) + } + product := models.PDDProduct{GoodsID: "729481506345", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=729481506345"} + if err := db.Create(&product).Error; err != nil { + t.Fatal(err) + } + task := newHeartbeatPurchaseTask(product, registered.DeviceID, models.PurchaseTaskStatusRunning) + lease := service.Now().Add(time.Minute) + task.LeaseExpiresAt = &lease + if err := db.Create(&task).Error; err != nil { + t.Fatal(err) + } + attempt := newRunningPurchaseAttempt(task.ID, registered.DeviceID) + if err := db.Create(&attempt).Error; err != nil { + t.Fatal(err) + } + if _, err := service.MarkStaleDevicesOffline(context.Background(), DefaultOfflineThreshold); err != nil { + t.Fatal(err) + } + var storedTask models.PurchaseTask + var storedAttempt models.PurchaseTaskAttempt + if err := db.First(&storedTask, task.ID).Error; err != nil { + t.Fatal(err) + } + if err := db.First(&storedAttempt, attempt.ID).Error; err != nil { + t.Fatal(err) + } + if storedTask.Status != models.PurchaseTaskStatusFailed || storedTask.ActiveSlot != nil || storedTask.DeviceRunSlot != nil || storedTask.AccountRunSlot != nil || storedTask.LeaseExpiresAt != nil || storedTask.ErrorCode == nil || *storedTask.ErrorCode != "DEVICE_OFFLINE" { + t.Fatalf("purchase task not failed cleanly: %+v", storedTask) + } + if storedAttempt.Status != models.PurchaseAttemptStatusFailed || storedAttempt.FinishedAt == nil || storedAttempt.ErrorCode == nil || *storedAttempt.ErrorCode != "DEVICE_OFFLINE" { + t.Fatalf("purchase attempt not failed cleanly: %+v", storedAttempt) + } +} + +func TestStaleDevicePreservesUnknownOrderBoundary(t *testing.T) { + db := openTestDatabase(t) + service := newTestService(t, db) + _, registered := registerHeartbeatDevice(t, service) + oldHeartbeat := service.Now().Add(-time.Minute) + if err := db.Model(&models.AgentDevice{}).Where("id = ?", registered.DeviceID).Update("last_heartbeat_at", oldHeartbeat).Error; err != nil { + t.Fatal(err) + } + product := models.PDDProduct{GoodsID: "729481506346", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=729481506346"} + if err := db.Create(&product).Error; err != nil { + t.Fatal(err) + } + task := newHeartbeatPurchaseTask(product, registered.DeviceID, models.PurchaseTaskStatusOrderSubmitStarted) + lease := service.Now().Add(time.Minute) + task.LeaseExpiresAt = &lease + if err := db.Create(&task).Error; err != nil { + t.Fatal(err) + } + attempt := newRunningPurchaseAttempt(task.ID, registered.DeviceID) + if err := db.Create(&attempt).Error; err != nil { + t.Fatal(err) + } + if _, err := service.MarkStaleDevicesOffline(context.Background(), DefaultOfflineThreshold); err != nil { + t.Fatal(err) + } + var storedTask models.PurchaseTask + if err := db.First(&storedTask, task.ID).Error; err != nil { + t.Fatal(err) + } + if storedTask.Status != models.PurchaseTaskStatusOrderResultUnknown || storedTask.DeviceRunSlot != nil || storedTask.AccountRunSlot != nil || storedTask.LeaseExpiresAt != nil { + t.Fatalf("unknown order boundary was not preserved: %+v", storedTask) + } +} diff --git a/server/app/goauto/device/list.go b/server/app/goauto/device/list.go index ad72ef9..1082741 100644 --- a/server/app/goauto/device/list.go +++ b/server/app/goauto/device/list.go @@ -86,19 +86,9 @@ func (service *Service) List(ctx context.Context, request ListRequest) (DeviceLi for _, record := range records { ids = append(ids, record.ID) } - type runningTask struct { - ID uint64 - DeviceID uint64 - } - var running []runningTask - if err := service.DB.WithContext(ctx).Model(&models.CollectionTask{}). - Select("id, device_id").Where("device_id IN ? AND status = ?", ids, models.TaskStatusRunning). - Scan(&running).Error; err != nil { - return DeviceListResponse{}, internalError(err) - } - currentTasks := make(map[uint64]uint64, len(running)) - for _, task := range running { - currentTasks[task.DeviceID] = task.ID + currentTasks, err := currentTaskIDsForDevices(service.DB.WithContext(ctx), ids) + if err != nil { + return DeviceListResponse{}, err } for _, record := range records { capabilities, err := Capabilities(record) diff --git a/server/app/goauto/device/list_test.go b/server/app/goauto/device/list_test.go index ebf8771..4846143 100644 --- a/server/app/goauto/device/list_test.go +++ b/server/app/goauto/device/list_test.go @@ -38,6 +38,29 @@ func TestDeviceListDerivesBusyAndSelectable(t *testing.T) { } } +func TestDeviceListDerivesBusyFromPurchaseTask(t *testing.T) { + db := openTestDatabase(t) + service := newTestService(t, db) + _, registered := registerHeartbeatDevice(t, service) + product := models.PDDProduct{GoodsID: "729481506401", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=729481506401"} + if err := db.Create(&product).Error; err != nil { + t.Fatal(err) + } + task := newHeartbeatPurchaseTask(product, registered.DeviceID, models.PurchaseTaskStatusRunning) + if err := db.Create(&task).Error; err != nil { + t.Fatal(err) + } + + response, err := service.List(context.Background(), ListRequest{Page: 1, PageSize: 20}) + if err != nil { + t.Fatalf("list devices: %v", err) + } + item := response.Items[0] + if !item.Busy || item.Selectable || item.CurrentTaskID == nil || *item.CurrentTaskID != task.ID { + t.Fatalf("purchase busy state was not derived: %+v", item) + } +} + func TestDisableFailsRunningTaskAndBlocksRegistrationUpdate(t *testing.T) { db := openTestDatabase(t) service := newTestService(t, db)