feat(tasks): implement atomic claims and leases

This commit is contained in:
QiuSW
2026-07-26 16:16:36 +08:00
parent 49db5b8305
commit ce875af889
50 changed files with 7589 additions and 207 deletions
+1 -1
View File
@@ -84,5 +84,5 @@ go run ./cmd/api
- [当前实现状态](docs/current-state.md)
- [完整文档导航](docs/README.md)
Android Phase 0/1 和后端 T-201 至 T-204 已完成。真实状态以
Android Phase 0/1 和后端 T-201 至 T-205 已完成。真实状态以
[`docs/current-state.md`](docs/current-state.md) 为准。
+11 -1
View File
@@ -3,6 +3,8 @@
Go 1.23.0、Gin 1.11.0 和 SQLite 构成的单进程采购任务服务。当前提供参考图上传与
规范化、任务创建/列表/详情/取消 API、服务端渲染管理页面、健康检查和显式数据库
迁移,并使用 ADMIN 服务端会话和 BUYER + 预授权设备联合身份隔离管理与执行接口。
App 设备接口已支持 readiness heartbeat、原子领取、安全幂等重放、参考图授权、
start/运行续租/release 和取消安全确认。
## 环境
@@ -19,6 +21,9 @@ Go 1.23.0、Gin 1.11.0 和 SQLite 构成的单进程采购任务服务。当前
| `CMROUBAO_ASSET_DIR` | `var/assets` | 规范化参考图片的受控本地目录 |
| `CMROUBAO_TLS_CERT_FILE` | 无 | TLS certificate;必须和 private key 同时设置 |
| `CMROUBAO_TLS_KEY_FILE` | 无 | TLS private key;非 loopback 监听必须设置 |
| `CMROUBAO_CLAIM_LEASE` | `10m` | CLAIMED 租约;允许 `1m` 至 `30m` |
| `CMROUBAO_RUNNING_LEASE` | `90s` | RUNNING/等待确认租约;允许 `30s` 至 `10m` |
| `CMROUBAO_READINESS_TTL` | `2m` | 设备就绪 heartbeat 新鲜度;允许 `30s` 至 `10m` |
不会自动读取 `.env`。本地配置和 `var/` 运行数据不得提交。
@@ -60,7 +65,7 @@ API 启动前会检查全部 migration 已应用;发现 pending migration 会
- `GET /tasks`:任务列表、搜索和状态筛选。
- `GET /tasks/new`:上传参考图并创建任务。
- `GET /tasks/{id}`:查看原始约束和任务状态,待领取任务可取消。
- `GET /tasks/{id}`:查看原始约束和任务状态;未执行任务立即取消,执行中请求安全停止。
- `GET/POST /login`、`POST /logout`:建立或撤销 8 小时 ADMIN 会话。
未登录管理页面会跳转 `/login`,未授权管理 API 返回 `401`。Cookie 认证的管理 API
@@ -68,6 +73,11 @@ API 启动前会检查全部 migration 已应用;发现 pending migration 会
Cookie 与 BUYER Bearer token 不能互换。两类登录在凭证校验前按来源地址独立限流:
5 分钟最多 10 次,超限返回 `429` 和 `Retry-After`,成功后清零。
设备任务路由为 `/api/v1/devices/heartbeat`、`/api/v1/tasks/claim-next` 及 task-scoped
`reference-image/start/heartbeat/release/cancel-ack`。除设备 heartbeat 外,任务读取
和迁移都受当前用户、设备、claim generation、`X-Claim-Token` 与服务端租约约束;
原 claim token 只由 App 生成和保存,服务端数据库只存 SHA-256。
默认 loopback 可使用 HTTP 开发。局域网监听必须同时设置 certificate/private key,
服务直接使用 TLS 启动,不会降级为明文。完整 API 合约见
[`../docs/api.md`](../docs/api.md)。
+22
View File
@@ -169,10 +169,30 @@ func buildRouter(
if err != nil {
return nil, err
}
lifecycle, err := usecase.NewLifecycleService(
store,
clock,
ids,
cfg.ClaimLease,
cfg.RunningLease,
cfg.ReadinessTTL,
)
if err != nil {
return nil, err
}
passwords, err := password.NewBcrypt(12)
if err != nil {
return nil, err
}
registerDeviceRoutes, err := httpapi.NewDeviceRouteRegistrar(
httpapi.DeviceServices{
Lifecycle: lifecycle,
Assets: assets,
},
)
if err != nil {
return nil, err
}
auth, err := usecase.NewAuthService(
store,
passwords,
@@ -248,7 +268,9 @@ func buildRouter(
return registerPublicAuth(routes)
},
RegisterAdminRoutes: registerProtectedRoutes,
RegisterDeviceRoutes: registerDeviceRoutes,
AdminSessions: auth,
DeviceAccess: auth,
LogEvent: func(event string) {
log.Print(event)
},
+3
View File
@@ -137,6 +137,9 @@ func TestBuildRouterRegistersProtectedLogoutRoute(t *testing.T) {
}
router, err := buildRouter(ctx, config.Config{
AssetDirectory: filepath.Join(t.TempDir(), "assets"),
ClaimLease: 10 * time.Minute,
RunningLease: 90 * time.Second,
ReadinessTTL: 2 * time.Minute,
}, db)
if err != nil {
t.Fatalf("buildRouter() error = %v", err)
+64
View File
@@ -15,10 +15,16 @@ const (
AssetDirectoryEnvironment = "CMROUBAO_ASSET_DIR"
TLSCertificateEnvironment = "CMROUBAO_TLS_CERT_FILE"
TLSPrivateKeyEnvironment = "CMROUBAO_TLS_KEY_FILE"
ClaimLeaseEnvironment = "CMROUBAO_CLAIM_LEASE"
RunningLeaseEnvironment = "CMROUBAO_RUNNING_LEASE"
ReadinessTTLEnvironment = "CMROUBAO_READINESS_TTL"
defaultHTTPAddress = "127.0.0.1:8080"
defaultDatabasePath = "var/cmroubao.db"
defaultAssetDirectory = "var/assets"
defaultClaimLease = 10 * time.Minute
defaultRunningLease = 90 * time.Second
defaultReadinessTTL = 2 * time.Minute
)
type LookupEnvironment func(string) (string, bool)
@@ -35,6 +41,9 @@ type Config struct {
IdleTimeout time.Duration
ShutdownTimeout time.Duration
MaxHeaderBytes int
ClaimLease time.Duration
RunningLease time.Duration
ReadinessTTL time.Duration
}
func Load(lookup LookupEnvironment) (Config, error) {
@@ -98,6 +107,36 @@ func Load(lookup LookupEnvironment) (Config, error) {
" must use loopback unless TLS is configured",
)
}
claimLease, err := durationEnvironment(
lookup,
ClaimLeaseEnvironment,
defaultClaimLease,
time.Minute,
30*time.Minute,
)
if err != nil {
return Config{}, err
}
runningLease, err := durationEnvironment(
lookup,
RunningLeaseEnvironment,
defaultRunningLease,
30*time.Second,
10*time.Minute,
)
if err != nil {
return Config{}, err
}
readinessTTL, err := durationEnvironment(
lookup,
ReadinessTTLEnvironment,
defaultReadinessTTL,
30*time.Second,
10*time.Minute,
)
if err != nil {
return Config{}, err
}
return Config{
HTTPAddress: httpAddress,
@@ -111,9 +150,34 @@ func Load(lookup LookupEnvironment) (Config, error) {
IdleTimeout: 60 * time.Second,
ShutdownTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20,
ClaimLease: claimLease,
RunningLease: runningLease,
ReadinessTTL: readinessTTL,
}, nil
}
func durationEnvironment(
lookup LookupEnvironment,
name string,
defaultValue time.Duration,
minimum time.Duration,
maximum time.Duration,
) (time.Duration, error) {
value, exists := lookup(name)
if !exists {
return defaultValue, nil
}
value = strings.TrimSpace(value)
duration, err := time.ParseDuration(value)
if err != nil || duration < minimum || duration > maximum {
return 0, errors.New(
name + " must be a duration between " +
minimum.String() + " and " + maximum.String(),
)
}
return duration, nil
}
func cleanOptionalPath(value string) string {
if value == "" {
return ""
@@ -29,6 +29,16 @@ func TestLoadUsesSafeDefaults(t *testing.T) {
cfg.MaxHeaderBytes <= 0 {
t.Fatal("server safety limits must all be positive")
}
if cfg.ClaimLease != 10*time.Minute ||
cfg.RunningLease != 90*time.Second ||
cfg.ReadinessTTL != 2*time.Minute {
t.Fatalf(
"lifecycle durations = %s / %s / %s",
cfg.ClaimLease,
cfg.RunningLease,
cfg.ReadinessTTL,
)
}
if cfg.ShutdownTimeout > 30*time.Second {
t.Fatalf("ShutdownTimeout = %s", cfg.ShutdownTimeout)
}
@@ -41,6 +51,9 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
AssetDirectoryEnvironment: "tmp/assets",
TLSCertificateEnvironment: "tmp/server.crt",
TLSPrivateKeyEnvironment: "tmp/server.key",
ClaimLeaseEnvironment: "15m",
RunningLeaseEnvironment: "2m",
ReadinessTTLEnvironment: "3m",
}
cfg, err := Load(mapEnvironment(values))
@@ -65,6 +78,16 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
cfg.TLSPrivateKey,
)
}
if cfg.ClaimLease != 15*time.Minute ||
cfg.RunningLease != 2*time.Minute ||
cfg.ReadinessTTL != 3*time.Minute {
t.Fatalf(
"lifecycle durations = %s / %s / %s",
cfg.ClaimLease,
cfg.RunningLease,
cfg.ReadinessTTL,
)
}
}
func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) {
@@ -145,6 +168,24 @@ func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) {
TLSPrivateKeyEnvironment: " ",
},
},
{
name: "claim lease below minimum",
values: map[string]string{
ClaimLeaseEnvironment: "59s",
},
},
{
name: "running lease above maximum",
values: map[string]string{
RunningLeaseEnvironment: "11m",
},
},
{
name: "invalid readiness TTL",
values: map[string]string{
ReadinessTTLEnvironment: "soon",
},
},
}
for _, test := range tests {
+3
View File
@@ -40,6 +40,9 @@ type Device struct {
AndroidVersion *string
PDDVersion *string
LastSeenAt *time.Time
ReadinessAt *time.Time
AccessibilityEnabled bool
PDDInstalled bool
IsEnabled bool
CreatedAt time.Time
UpdatedAt time.Time
@@ -0,0 +1,45 @@
package domain
import "testing"
func TestLifecycleStatusPredicates(t *testing.T) {
statuses := []TaskStatus{
TaskStatusPending,
TaskStatusClaimed,
TaskStatusRunning,
TaskStatusWaitingConfirmation,
TaskStatusSucceeded,
TaskStatusFailed,
TaskStatusCanceled,
}
for _, status := range statuses {
t.Run(string(status), func(t *testing.T) {
active := status == TaskStatusClaimed ||
status == TaskStatusRunning ||
status == TaskStatusWaitingConfirmation
if IsActiveTaskStatus(status) != active {
t.Fatalf("IsActiveTaskStatus(%s) = %v", status, !active)
}
if CanStart(status) != (status == TaskStatusClaimed) {
t.Fatalf("CanStart(%s) mismatch", status)
}
heartbeat := status == TaskStatusRunning ||
status == TaskStatusWaitingConfirmation
if CanHeartbeat(status) != heartbeat {
t.Fatalf("CanHeartbeat(%s) mismatch", status)
}
if CanRelease(status) != (status == TaskStatusClaimed) {
t.Fatalf("CanRelease(%s) mismatch", status)
}
immediateCancel := status == TaskStatusPending ||
status == TaskStatusClaimed
if CanAdminCancelImmediately(status) != immediateCancel {
t.Fatalf("CanAdminCancelImmediately(%s) mismatch", status)
}
if CanRequestCancel(status) != heartbeat ||
CanAcknowledgeCancel(status) != heartbeat {
t.Fatalf("cancel predicates for %s mismatch", status)
}
})
}
}
+63 -1
View File
@@ -46,7 +46,15 @@ type PurchaseTask struct {
Currency string
Status TaskStatus
Version int64
ClaimedByUserID *string
ClaimedByDeviceID *string
ClaimGeneration int64
ClaimTokenHash *string
ClaimIssuedAt *time.Time
ClaimExpiresAt *time.Time
CancelReason *string
CancelRequestedAt *time.Time
CancelRequestedByUserID *string
CanceledAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
@@ -56,14 +64,30 @@ type TaskEvent struct {
ID string
TaskID string
ActorUserID *string
ActorDeviceID *string
Type string
Message string
OccurredAt time.Time
}
type TaskExecution struct {
ID string
TaskID string
AttemptNo int64
ClaimGeneration int64
UserID string
DeviceID string
CurrentStep string
OrderSubmitted bool
StartedAt time.Time
LastHeartbeatAt *time.Time
FinishedAt *time.Time
}
type TaskDetail struct {
Task PurchaseTask
Asset Asset
Execution *TaskExecution
Events []TaskEvent
}
@@ -192,7 +216,45 @@ func FormatOptionalCNY(cents *int64) *string {
}
func CanCancel(status TaskStatus) bool {
return status == TaskStatusPending
return CanAdminCancelImmediately(status) || CanRequestCancel(status)
}
func IsActiveTaskStatus(status TaskStatus) bool {
switch status {
case TaskStatusClaimed,
TaskStatusRunning,
TaskStatusWaitingConfirmation:
return true
default:
return false
}
}
func CanStart(status TaskStatus) bool {
return status == TaskStatusClaimed
}
func CanHeartbeat(status TaskStatus) bool {
return status == TaskStatusRunning ||
status == TaskStatusWaitingConfirmation
}
func CanRelease(status TaskStatus) bool {
return status == TaskStatusClaimed
}
func CanAdminCancelImmediately(status TaskStatus) bool {
return status == TaskStatusPending ||
status == TaskStatusClaimed
}
func CanRequestCancel(status TaskStatus) bool {
return status == TaskStatusRunning ||
status == TaskStatusWaitingConfirmation
}
func CanAcknowledgeCancel(status TaskStatus) bool {
return CanRequestCancel(status)
}
func IsValidTaskStatus(status TaskStatus) bool {
+10 -6
View File
@@ -107,11 +107,8 @@ func TestParseOptionalCNYRejectsInvalidAndOverflow(t *testing.T) {
}
}
func TestCanCancelOnlyPending(t *testing.T) {
func TestCanCancelOnlyNonTerminalTask(t *testing.T) {
for _, status := range []TaskStatus{
TaskStatusClaimed,
TaskStatusRunning,
TaskStatusWaitingConfirmation,
TaskStatusSucceeded,
TaskStatusFailed,
TaskStatusCanceled,
@@ -120,8 +117,15 @@ func TestCanCancelOnlyPending(t *testing.T) {
t.Fatalf("CanCancel(%s) = true", status)
}
}
if !CanCancel(TaskStatusPending) {
t.Fatal("CanCancel(PENDING) = false")
for _, status := range []TaskStatus{
TaskStatusPending,
TaskStatusClaimed,
TaskStatusRunning,
TaskStatusWaitingConfirmation,
} {
if !CanCancel(status) {
t.Fatalf("CanCancel(%s) = false", status)
}
}
}
@@ -0,0 +1,544 @@
package migration
import (
"context"
"database/sql"
"path/filepath"
"strings"
"testing"
"cmroubao/backend-api/internal/platform/database"
)
const (
claimsUserID = "00000000-0000-4000-8000-000000000401"
claimsDeviceID = "00000000-0000-4000-8000-000000000402"
claimsAssetID = "00000000-0000-4000-8000-000000000403"
claimsTaskID = "00000000-0000-4000-8000-000000000404"
claimsEventID = "00000000-0000-4000-8000-000000000405"
claimsExecutionID = "00000000-0000-4000-8000-000000000406"
claimsSecondAsset = "00000000-0000-4000-8000-000000000407"
claimsSecondTask = "00000000-0000-4000-8000-000000000408"
claimsNewEventID = "00000000-0000-4000-8000-000000000409"
claimsSecondExec = "00000000-0000-4000-8000-000000000410"
claimsTimestamp = "2026-07-26T12:00:00Z"
claimsExpiry = "2026-07-26T12:10:00Z"
claimsCreator = "local-admin"
claimsRequestSHA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
claimsDeviceSecret = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
)
func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
db, runner := openClaimsMigrationDatabase(t)
ctx := context.Background()
if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("initial Up() error = %v", err)
} else if applied != 4 {
t.Fatalf("initial Up() applied = %d, want 4", applied)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("initial Down(v4) error = %v", err)
}
seedClaimsHistoricalFixture(t, db)
if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("Up(v4) over historical data error = %v", err)
} else if applied != 1 {
t.Fatalf("Up(v4) applied = %d, want 1", applied)
}
assertClaimsHistory(t, db, true)
if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v4) with compatible history error = %v", err)
}
assertClaimsHistory(t, db, false)
if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("final Up(v4) error = %v", err)
} else if applied != 1 {
t.Fatalf("final Up(v4) applied = %d, want 1", applied)
}
assertClaimsHistory(t, db, true)
}
func TestClaimsMigrationEnforcesLifecycleConstraints(t *testing.T) {
db, runner := openClaimsMigrationDatabase(t)
if _, err := runner.Up(context.Background()); err != nil {
t.Fatalf("Up() error = %v", err)
}
seedClaimsHistoricalFixture(t, db)
insertClaimsTask(
t,
db,
claimsSecondAsset,
claimsSecondTask,
"second-source",
)
assertClaimsStatementRejected(
t,
db,
`UPDATE devices SET accessibility_enabled = 2 WHERE id = ?`,
claimsDeviceID,
)
assertClaimsStatementRejected(
t,
db,
`UPDATE devices SET pdd_installed = -1 WHERE id = ?`,
claimsDeviceID,
)
assertClaimsStatementRejected(
t,
db,
`UPDATE purchase_tasks SET claim_token_hash = ? WHERE id = ?`,
strings.Repeat("A", 64),
claimsTaskID,
)
if _, err := db.Exec(
`UPDATE purchase_tasks
SET status = 'CLAIMED',
claimed_by_user_id = ?,
claimed_by_device_id = ?,
claim_generation = 1,
claim_token_hash = ?,
claim_issued_at = ?,
claim_expires_at = ?
WHERE id = ?`,
claimsUserID,
claimsDeviceID,
claimsRequestSHA,
claimsTimestamp,
claimsExpiry,
claimsTaskID,
); err != nil {
t.Fatalf("set first active claim: %v", err)
}
assertClaimsStatementRejected(
t,
db,
`UPDATE purchase_tasks
SET status = 'CLAIMED',
claimed_by_user_id = ?,
claimed_by_device_id = ?,
claim_generation = 1,
claim_token_hash = ?,
claim_issued_at = ?,
claim_expires_at = ?
WHERE id = ?`,
claimsUserID,
claimsDeviceID,
claimsRequestSHA,
claimsTimestamp,
claimsExpiry,
claimsSecondTask,
)
assertClaimsStatementRejected(
t,
db,
`INSERT INTO task_executions (
id, task_id, attempt_no, claim_generation, user_id, device_id,
current_step, last_heartbeat_at, order_submitted, started_at
) VALUES (?, ?, 1, 1, ?, ?, 'STARTED', ?, 1, ?)`,
claimsExecutionID,
claimsTaskID,
claimsUserID,
claimsDeviceID,
claimsTimestamp,
claimsTimestamp,
)
if _, err := db.Exec(
`INSERT INTO task_executions (
id, task_id, attempt_no, claim_generation, user_id, device_id,
current_step, last_heartbeat_at, order_submitted, started_at
) VALUES (?, ?, 1, 1, ?, ?, 'STARTED', ?, 0, ?)`,
claimsExecutionID,
claimsTaskID,
claimsUserID,
claimsDeviceID,
claimsTimestamp,
claimsTimestamp,
); err != nil {
t.Fatalf("insert valid execution: %v", err)
}
assertClaimsStatementRejected(
t,
db,
`INSERT INTO task_executions (
id, task_id, attempt_no, claim_generation, user_id, device_id,
current_step, last_heartbeat_at, order_submitted, started_at
) VALUES (?, ?, 1, 1, ?, ?, ?, ?, 0, ?)`,
claimsSecondExec,
claimsSecondTask,
claimsUserID,
claimsDeviceID,
strings.Repeat("A", 65),
claimsTimestamp,
claimsTimestamp,
)
if _, err := db.Exec(
`INSERT INTO lifecycle_requests (
user_id, device_id, operation, idempotency_key,
request_sha256, result_kind, created_at
) VALUES (?, ?, 'CLAIM_NEXT', 'no-task', ?, 'NO_TASK', ?)`,
claimsUserID,
claimsDeviceID,
claimsRequestSHA,
claimsTimestamp,
); err != nil {
t.Fatalf("insert valid NO_TASK replay: %v", err)
}
assertClaimsStatementRejected(
t,
db,
`INSERT INTO lifecycle_requests (
user_id, device_id, operation, idempotency_key,
request_sha256, result_kind, created_at
) VALUES (?, ?, 'START', 'invalid-no-task', ?, 'NO_TASK', ?)`,
claimsUserID,
claimsDeviceID,
claimsRequestSHA,
claimsTimestamp,
)
if _, err := db.Exec(
`INSERT INTO lifecycle_requests (
user_id, device_id, operation, idempotency_key,
request_sha256, result_kind, task_id, claim_generation,
execution_id, created_at
) VALUES (?, ?, 'START', 'valid-start', ?, 'EXECUTION', ?, 1, ?, ?)`,
claimsUserID,
claimsDeviceID,
claimsRequestSHA,
claimsTaskID,
claimsExecutionID,
claimsTimestamp,
); err != nil {
t.Fatalf("insert valid START replay: %v", err)
}
if _, err := db.Exec(
`INSERT INTO lifecycle_requests (
user_id, device_id, operation, idempotency_key,
request_sha256, result_kind, task_id, claim_generation, created_at
) VALUES (?, ?, 'RELEASE', 'valid-release', ?, 'TASK', ?, 1, ?)`,
claimsUserID,
claimsDeviceID,
claimsRequestSHA,
claimsTaskID,
claimsTimestamp,
); err != nil {
t.Fatalf("insert valid RELEASE replay: %v", err)
}
if _, err := db.Exec(
`INSERT INTO lifecycle_requests (
user_id, device_id, operation, idempotency_key,
request_sha256, result_kind, task_id, claim_generation,
execution_id, created_at
) VALUES (?, ?, 'CANCEL_ACK', 'valid-cancel-ack', ?, 'TASK', ?, 1, ?, ?)`,
claimsUserID,
claimsDeviceID,
claimsRequestSHA,
claimsTaskID,
claimsExecutionID,
claimsTimestamp,
); err != nil {
t.Fatalf("insert valid CANCEL_ACK replay: %v", err)
}
assertClaimsStatementRejected(
t,
db,
`INSERT INTO lifecycle_requests (
user_id, device_id, operation, idempotency_key,
request_sha256, result_kind, task_id, claim_generation, created_at
) VALUES (?, ?, 'CANCEL_ACK', 'cancel-ack-without-execution', ?,
'TASK', ?, 1, ?)`,
claimsUserID,
claimsDeviceID,
claimsRequestSHA,
claimsTaskID,
claimsTimestamp,
)
assertClaimsStatementRejected(
t,
db,
`INSERT INTO task_events (
id, task_id, event_type, message, occurred_at
) VALUES (?, ?, 'TASK_UNKNOWN', 'unknown', ?)`,
claimsNewEventID,
claimsTaskID,
claimsTimestamp,
)
}
func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) {
db, runner := openClaimsMigrationDatabase(t)
ctx := context.Background()
if _, err := runner.Up(ctx); err != nil {
t.Fatalf("Up() error = %v", err)
}
seedClaimsHistoricalFixture(t, db)
if _, err := db.Exec(
`INSERT INTO task_events (
id, task_id, event_type, message, occurred_at,
actor_user_id, actor_device_id
) VALUES (?, ?, 'TASK_CLAIMED', 'claimed', ?, ?, ?)`,
claimsNewEventID,
claimsTaskID,
claimsTimestamp,
claimsUserID,
claimsDeviceID,
); err != nil {
t.Fatalf("insert v4 audit event: %v", err)
}
if err := runner.Down(ctx); err == nil {
t.Fatal("Down(v4) succeeded with non-representable audit event")
}
if !claimsColumnExists(t, db, "task_events", "actor_device_id") {
t.Fatal("failed Down(v4) partially replaced task_events")
}
if !claimsTableExists(t, db, "task_executions") {
t.Fatal("failed Down(v4) partially removed task_executions")
}
var count int
if err := db.QueryRow(
"SELECT COUNT(*) FROM task_events WHERE id = ?",
claimsNewEventID,
).Scan(&count); err != nil {
t.Fatalf("query retained audit event: %v", err)
}
if count != 1 {
t.Fatalf("retained audit event count = %d, want 1", count)
}
}
func openClaimsMigrationDatabase(
t *testing.T,
) (*sql.DB, *Runner) {
t.Helper()
db, err := database.Open(
context.Background(),
filepath.Join(t.TempDir(), "claims-migration.db"),
)
if err != nil {
t.Fatalf("database.Open() error = %v", err)
}
t.Cleanup(func() { _ = db.Close() })
runner, err := New(db)
if err != nil {
t.Fatalf("New() error = %v", err)
}
return db, runner
}
func seedClaimsHistoricalFixture(t *testing.T, db *sql.DB) {
t.Helper()
statements := []struct {
query string
args []any
}{
{
`INSERT INTO users (
id, username, password_hash, role, is_active, created_at, updated_at
) VALUES (?, 'buyer', 'hash', 'BUYER', 1, ?, ?)`,
[]any{claimsUserID, claimsTimestamp, claimsTimestamp},
},
{
`INSERT INTO devices (
id, name, token_hash, bound_user_id, is_enabled, created_at, updated_at
) VALUES (?, 'historical device', ?, ?, 1, ?, ?)`,
[]any{
claimsDeviceID,
claimsDeviceSecret,
claimsUserID,
claimsTimestamp,
claimsTimestamp,
},
},
}
for _, statement := range statements {
if _, err := db.Exec(statement.query, statement.args...); err != nil {
t.Fatalf("seed historical auth data: %v", err)
}
}
insertClaimsTask(t, db, claimsAssetID, claimsTaskID, "historical-source")
if _, err := db.Exec(
`INSERT INTO task_events (
id, task_id, event_type, message, occurred_at, actor_user_id
) VALUES (?, ?, 'TASK_CREATED', 'created', ?, ?)`,
claimsEventID,
claimsTaskID,
claimsTimestamp,
claimsUserID,
); err != nil {
t.Fatalf("seed historical task event: %v", err)
}
}
func insertClaimsTask(
t *testing.T,
db *sql.DB,
assetID string,
taskID string,
sourceRef string,
) {
t.Helper()
if _, err := db.Exec(
`INSERT INTO assets (
id, creator_subject, purpose, media_type, size_bytes,
sha256, storage_key, created_at
) VALUES (?, ?, 'TASK_REFERENCE', 'image/jpeg', 10, ?, ?, ?)`,
assetID,
claimsCreator,
claimsRequestSHA,
"assets/"+assetID+".jpg",
claimsTimestamp,
); err != nil {
t.Fatalf("insert asset %s: %v", assetID, err)
}
if _, err := db.Exec(
`INSERT INTO purchase_tasks (
id, creator_subject, source_ref, title, description, sku,
image_asset_id, quantity, currency, status, version,
created_at, updated_at, created_by_user_id
) VALUES (?, ?, ?, 'title', 'description', 'sku', ?, 1, 'CNY',
'PENDING', 1, ?, ?, ?)`,
taskID,
claimsCreator,
sourceRef,
assetID,
claimsTimestamp,
claimsTimestamp,
claimsUserID,
); err != nil {
t.Fatalf("insert task %s: %v", taskID, err)
}
}
func assertClaimsHistory(t *testing.T, db *sql.DB, v4 bool) {
t.Helper()
var taskCount int
if err := db.QueryRow(
"SELECT COUNT(*) FROM purchase_tasks WHERE id = ?",
claimsTaskID,
).Scan(&taskCount); err != nil {
t.Fatalf("query historical task: %v", err)
}
if taskCount != 1 {
t.Fatalf("historical task count = %d, want 1", taskCount)
}
var actorUserID sql.NullString
if err := db.QueryRow(
"SELECT actor_user_id FROM task_events WHERE id = ?",
claimsEventID,
).Scan(&actorUserID); err != nil {
t.Fatalf("query historical event actor: %v", err)
}
if !actorUserID.Valid || actorUserID.String != claimsUserID {
t.Fatalf("historical event actor = %v, want %s", actorUserID, claimsUserID)
}
if claimsColumnExists(
t,
db,
"purchase_tasks",
"claim_generation",
) != v4 {
t.Fatalf("claim_generation presence does not match v4=%t", v4)
}
if claimsColumnExists(
t,
db,
"task_events",
"actor_device_id",
) != v4 {
t.Fatalf("actor_device_id presence does not match v4=%t", v4)
}
if claimsTableExists(t, db, "task_executions") != v4 {
t.Fatalf("task_executions presence does not match v4=%t", v4)
}
if v4 {
var generation int
var tokenHash sql.NullString
if err := db.QueryRow(
`SELECT claim_generation, claim_token_hash
FROM purchase_tasks WHERE id = ?`,
claimsTaskID,
).Scan(&generation, &tokenHash); err != nil {
t.Fatalf("query historical claim defaults: %v", err)
}
if generation != 0 || tokenHash.Valid {
t.Fatalf(
"historical claim defaults generation=%d token=%v",
generation,
tokenHash,
)
}
}
}
func assertClaimsStatementRejected(
t *testing.T,
db *sql.DB,
query string,
args ...any,
) {
t.Helper()
if _, err := db.Exec(query, args...); err == nil {
t.Fatalf("statement unexpectedly succeeded: %s", query)
}
}
func claimsTableExists(t *testing.T, db *sql.DB, name string) bool {
t.Helper()
var count int
if err := db.QueryRow(
`SELECT COUNT(*) FROM sqlite_master
WHERE type = 'table' AND name = ?`,
name,
).Scan(&count); err != nil {
t.Fatalf("query table %s: %v", name, err)
}
return count == 1
}
func claimsColumnExists(
t *testing.T,
db *sql.DB,
table string,
column string,
) bool {
t.Helper()
rows, err := db.Query("PRAGMA table_info(" + table + ")")
if err != nil {
t.Fatalf("PRAGMA table_info(%s): %v", table, err)
}
defer rows.Close()
for rows.Next() {
var cid int
var name string
var dataType string
var notNull int
var defaultValue any
var primaryKey int
if err := rows.Scan(
&cid,
&name,
&dataType,
&notNull,
&defaultValue,
&primaryKey,
); err != nil {
t.Fatalf("scan table_info(%s): %v", table, err)
}
if name == column {
return true
}
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate table_info(%s): %v", table, err)
}
return false
}
@@ -27,13 +27,14 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
if err != nil {
t.Fatalf("Up() error = %v", err)
}
if applied != 3 {
t.Fatalf("Up() applied = %d, want 3", applied)
if applied != 4 {
t.Fatalf("Up() applied = %d, want 4", applied)
}
assertStatuses(t, runner, map[int64]bool{
1: true,
2: true,
3: true,
4: true,
})
applied, err = runner.Up(context.Background())
@@ -50,7 +51,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
assertStatuses(t, runner, map[int64]bool{
1: true,
2: true,
3: false,
3: true,
4: false,
})
applied, err = runner.Up(context.Background())
@@ -64,6 +66,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
1: true,
2: true,
3: true,
4: true,
})
}
@@ -476,13 +476,16 @@ func getDeviceByID(
var androidVersion sql.NullString
var pddVersion sql.NullString
var lastSeenAt sql.NullString
var readinessAt sql.NullString
var createdAt string
var updatedAt string
err := queryer.QueryRowContext(
ctx,
`SELECT
id, name, token_hash, bound_user_id, app_version, android_version,
pdd_version, last_seen_at, is_enabled, created_at, updated_at
pdd_version, last_seen_at, readiness_reported_at,
accessibility_enabled, pdd_installed, is_enabled,
created_at, updated_at
FROM devices
WHERE id = ?`,
deviceID,
@@ -495,6 +498,9 @@ func getDeviceByID(
&androidVersion,
&pddVersion,
&lastSeenAt,
&readinessAt,
&device.AccessibilityEnabled,
&device.PDDInstalled,
&device.IsEnabled,
&createdAt,
&updatedAt,
@@ -524,6 +530,13 @@ func getDeviceByID(
}
device.LastSeenAt = &parsed
}
if readinessAt.Valid {
parsed, err := parseTimestamp(readinessAt.String)
if err != nil {
return domain.Device{}, err
}
device.ReadinessAt = &parsed
}
device.CreatedAt, err = parseTimestamp(createdAt)
if err != nil {
return domain.Device{}, err
@@ -383,6 +383,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
if err != nil {
t.Fatalf("migration.New() error = %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("Down(v4) error = %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("Down(v3) error = %v", err)
}
@@ -399,9 +402,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
t.Fatal("purchase_tasks was lost during auth migration rollback")
}
if applied, err := runner.Up(context.Background()); err != nil {
t.Fatalf("Up(v3) error = %v", err)
} else if applied != 1 {
t.Fatalf("Up(v3) applied = %d, want 1", applied)
t.Fatalf("Up(v3-v4) error = %v", err)
} else if applied != 2 {
t.Fatalf("Up(v3-v4) applied = %d, want 2", applied)
}
}
@@ -14,7 +14,7 @@ import (
sqlite3 "github.com/mattn/go-sqlite3"
)
const timestampLayout = time.RFC3339Nano
const storageTimestampLayout = "2006-01-02T15:04:05.000000000Z"
type queryRower interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
@@ -52,7 +52,14 @@ func scanTask(scanner rowScanner) (domain.PurchaseTask, error) {
var createdByUserID sql.NullString
var sourceRef sql.NullString
var maxBudget sql.NullInt64
var claimedByUserID sql.NullString
var claimedByDeviceID sql.NullString
var claimTokenHash sql.NullString
var claimIssuedAt sql.NullString
var claimExpiresAt sql.NullString
var cancelReason sql.NullString
var cancelRequestedAt sql.NullString
var cancelRequestedByUserID sql.NullString
var canceledAt sql.NullString
var createdAt string
var updatedAt string
@@ -70,7 +77,15 @@ func scanTask(scanner rowScanner) (domain.PurchaseTask, error) {
&task.Currency,
&task.Status,
&task.Version,
&claimedByUserID,
&claimedByDeviceID,
&task.ClaimGeneration,
&claimTokenHash,
&claimIssuedAt,
&claimExpiresAt,
&cancelReason,
&cancelRequestedAt,
&cancelRequestedByUserID,
&canceledAt,
&createdAt,
&updatedAt,
@@ -87,15 +102,36 @@ func scanTask(scanner rowScanner) (domain.PurchaseTask, error) {
if maxBudget.Valid {
task.MaxBudgetCents = &maxBudget.Int64
}
if cancelReason.Valid {
task.CancelReason = &cancelReason.String
if claimedByUserID.Valid {
task.ClaimedByUserID = &claimedByUserID.String
}
if canceledAt.Valid {
value, err := parseTimestamp(canceledAt.String)
if claimedByDeviceID.Valid {
task.ClaimedByDeviceID = &claimedByDeviceID.String
}
if claimTokenHash.Valid {
task.ClaimTokenHash = &claimTokenHash.String
}
task.ClaimIssuedAt, err = parseNullableTimestamp(claimIssuedAt)
if err != nil {
return domain.PurchaseTask{}, err
}
task.CanceledAt = &value
task.ClaimExpiresAt, err = parseNullableTimestamp(claimExpiresAt)
if err != nil {
return domain.PurchaseTask{}, err
}
if cancelReason.Valid {
task.CancelReason = &cancelReason.String
}
task.CancelRequestedAt, err = parseNullableTimestamp(cancelRequestedAt)
if err != nil {
return domain.PurchaseTask{}, err
}
if cancelRequestedByUserID.Valid {
task.CancelRequestedByUserID = &cancelRequestedByUserID.String
}
task.CanceledAt, err = parseNullableTimestamp(canceledAt)
if err != nil {
return domain.PurchaseTask{}, err
}
task.CreatedAt, err = parseTimestamp(createdAt)
if err != nil {
@@ -108,6 +144,42 @@ func scanTask(scanner rowScanner) (domain.PurchaseTask, error) {
return task, nil
}
func scanExecution(scanner rowScanner) (domain.TaskExecution, error) {
var execution domain.TaskExecution
var lastHeartbeatAt sql.NullString
var finishedAt sql.NullString
var startedAt string
err := scanner.Scan(
&execution.ID,
&execution.TaskID,
&execution.AttemptNo,
&execution.ClaimGeneration,
&execution.UserID,
&execution.DeviceID,
&execution.CurrentStep,
&execution.OrderSubmitted,
&startedAt,
&lastHeartbeatAt,
&finishedAt,
)
if err != nil {
return domain.TaskExecution{}, err
}
execution.StartedAt, err = parseTimestamp(startedAt)
if err != nil {
return domain.TaskExecution{}, err
}
execution.LastHeartbeatAt, err = parseNullableTimestamp(lastHeartbeatAt)
if err != nil {
return domain.TaskExecution{}, err
}
execution.FinishedAt, err = parseNullableTimestamp(finishedAt)
if err != nil {
return domain.TaskExecution{}, err
}
return execution, nil
}
func getAssetByID(
ctx context.Context,
queryer queryRower,
@@ -144,7 +216,10 @@ func getTaskByID(
`SELECT
id, creator_subject, created_by_user_id, source_ref, title, description, sku,
image_asset_id, quantity, max_budget_cents, currency, status,
version, cancel_reason, canceled_at, created_at, updated_at
version, claimed_by_user_id, claimed_by_device_id, claim_generation,
claim_token_hash, claim_issued_at, claim_expires_at, cancel_reason,
cancel_requested_at, cancel_requested_by_user_id, canceled_at,
created_at, updated_at
FROM purchase_tasks
WHERE creator_subject = ? AND id = ?`,
creatorSubject,
@@ -218,11 +293,11 @@ func insertIdempotency(
}
func formatTimestamp(value time.Time) string {
return value.UTC().Format(timestampLayout)
return value.UTC().Format(storageTimestampLayout)
}
func parseTimestamp(value string) (time.Time, error) {
parsed, err := time.Parse(timestampLayout, value)
parsed, err := time.Parse(time.RFC3339Nano, value)
if err != nil {
return time.Time{}, fmt.Errorf(
"%w: invalid stored timestamp",
@@ -232,6 +307,17 @@ func parseTimestamp(value string) (time.Time, error) {
return parsed.UTC(), nil
}
func parseNullableTimestamp(value sql.NullString) (*time.Time, error) {
if !value.Valid {
return nil, nil
}
parsed, err := parseTimestamp(value.String)
if err != nil {
return nil, err
}
return &parsed, nil
}
func nullableString(value *string) any {
if value == nil {
return nil
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -190,7 +190,7 @@ func TestStoreAssetAndTaskLifecycleIsTransactionalAndIdempotent(
t.Fatalf("detail = %+v", detail)
}
canceled, err := store.CancelPendingTask(
canceled, err := store.CancelTask(
ctx,
"local-admin",
task.ID,
@@ -199,7 +199,7 @@ func TestStoreAssetAndTaskLifecycleIsTransactionalAndIdempotent(
testEvent(3, task.ID, "TASK_CANCELED", now.Add(time.Minute)),
)
if err != nil {
t.Fatalf("CancelPendingTask() error = %v", err)
t.Fatalf("CancelTask() error = %v", err)
}
if canceled.Status != domain.TaskStatusCanceled ||
canceled.Version != 2 ||
@@ -207,7 +207,7 @@ func TestStoreAssetAndTaskLifecycleIsTransactionalAndIdempotent(
*canceled.CancelReason != "no longer needed" {
t.Fatalf("canceled task = %+v", canceled)
}
_, err = store.CancelPendingTask(
_, err = store.CancelTask(
ctx,
"local-admin",
task.ID,
@@ -3,6 +3,7 @@ package sqlite
import (
"context"
"database/sql"
"errors"
"strings"
"time"
@@ -145,7 +146,10 @@ func (s *Store) ListTasks(
query.WriteString(`SELECT
id, creator_subject, created_by_user_id, source_ref, title, description, sku,
image_asset_id, quantity, max_budget_cents, currency, status,
version, cancel_reason, canceled_at, created_at, updated_at
version, claimed_by_user_id, claimed_by_device_id, claim_generation,
claim_token_hash, claim_issued_at, claim_expires_at, cancel_reason,
cancel_requested_at, cancel_requested_by_user_id, canceled_at,
created_at, updated_at
FROM purchase_tasks
WHERE creator_subject = ?`)
arguments := []any{filter.CreatorSubject}
@@ -242,7 +246,9 @@ func (s *Store) GetTaskDetail(
}
rows, err := tx.QueryContext(
ctx,
`SELECT id, task_id, actor_user_id, event_type, message, occurred_at
`SELECT
id, task_id, actor_user_id, actor_device_id,
event_type, message, occurred_at
FROM task_events
WHERE task_id = ?
ORDER BY occurred_at ASC, id ASC`,
@@ -256,11 +262,13 @@ func (s *Store) GetTaskDetail(
for rows.Next() {
var event domain.TaskEvent
var actorUserID sql.NullString
var actorDeviceID sql.NullString
var occurredAt string
if err := rows.Scan(
&event.ID,
&event.TaskID,
&actorUserID,
&actorDeviceID,
&event.Type,
&event.Message,
&occurredAt,
@@ -270,6 +278,9 @@ func (s *Store) GetTaskDetail(
if actorUserID.Valid {
event.ActorUserID = &actorUserID.String
}
if actorDeviceID.Valid {
event.ActorDeviceID = &actorDeviceID.String
}
event.OccurredAt, err = parseTimestamp(occurredAt)
if err != nil {
return domain.TaskDetail{}, err
@@ -282,9 +293,30 @@ func (s *Store) GetTaskDetail(
if err := rows.Close(); err != nil {
return domain.TaskDetail{}, repositoryFailure(err)
}
execution, err := scanExecution(tx.QueryRowContext(
ctx,
`SELECT
id, task_id, attempt_no, claim_generation, user_id, device_id,
current_step, order_submitted, started_at, last_heartbeat_at,
finished_at
FROM task_executions
WHERE task_id = ?
ORDER BY attempt_no DESC
LIMIT 1`,
taskID,
))
var executionPointer *domain.TaskExecution
if errors.Is(err, sql.ErrNoRows) {
executionPointer = nil
} else if err != nil {
return domain.TaskDetail{}, repositoryFailure(err)
} else {
executionPointer = &execution
}
detail := domain.TaskDetail{
Task: task,
Asset: asset,
Execution: executionPointer,
Events: events,
}
if err := tx.Commit(); err != nil {
@@ -293,7 +325,7 @@ func (s *Store) GetTaskDetail(
return detail, nil
}
func (s *Store) CancelPendingTask(
func (s *Store) CancelTask(
ctx context.Context,
creatorSubject string,
taskID string,
@@ -313,17 +345,33 @@ func (s *Store) CancelPendingTask(
if !domain.CanCancel(task.Status) {
return domain.PurchaseTask{}, usecase.ErrTaskStateConflict
}
result, err := tx.ExecContext(
if domain.CanRequestCancel(task.Status) &&
task.CancelRequestedAt != nil {
if err := tx.Commit(); err != nil {
return domain.PurchaseTask{}, repositoryFailure(err)
}
return task, nil
}
var result sql.Result
if domain.CanAdminCancelImmediately(task.Status) {
result, err = tx.ExecContext(
ctx,
`UPDATE purchase_tasks
SET status = 'CANCELED',
version = version + 1,
claimed_by_user_id = NULL,
claimed_by_device_id = NULL,
claim_token_hash = NULL,
claim_issued_at = NULL,
claim_expires_at = NULL,
cancel_reason = NULLIF(?, ''),
cancel_requested_at = NULL,
cancel_requested_by_user_id = NULL,
canceled_at = ?,
updated_at = ?
WHERE id = ?
AND creator_subject = ?
AND status = 'PENDING'
AND status IN ('PENDING', 'CLAIMED')
AND version = ?`,
reason,
formatTimestamp(canceledAt),
@@ -332,6 +380,31 @@ func (s *Store) CancelPendingTask(
creatorSubject,
task.Version,
)
} else {
event.Type = "TASK_CANCEL_REQUESTED"
event.Message = "task cancellation requested"
result, err = tx.ExecContext(
ctx,
`UPDATE purchase_tasks
SET version = version + 1,
cancel_reason = NULLIF(?, ''),
cancel_requested_at = ?,
cancel_requested_by_user_id = ?,
updated_at = ?
WHERE id = ?
AND creator_subject = ?
AND status IN ('RUNNING', 'WAITING_CONFIRMATION')
AND cancel_requested_at IS NULL
AND version = ?`,
reason,
formatTimestamp(canceledAt),
nullableString(event.ActorUserID),
formatTimestamp(canceledAt),
taskID,
creatorSubject,
task.Version,
)
}
if err != nil {
return domain.PurchaseTask{}, repositoryFailure(err)
}
@@ -363,11 +436,13 @@ func insertTaskEvent(
_, err := tx.ExecContext(
ctx,
`INSERT INTO task_events (
id, task_id, actor_user_id, event_type, message, occurred_at
) VALUES (?, ?, ?, ?, ?, ?)`,
id, task_id, actor_user_id, actor_device_id,
event_type, message, occurred_at
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
event.ID,
event.TaskID,
nullableString(event.ActorUserID),
nullableString(event.ActorDeviceID),
event.Type,
event.Message,
formatTimestamp(event.OccurredAt),
@@ -160,18 +160,7 @@ func (h *adminHandlers) assetContent(ctx *gin.Context) {
return
}
defer result.Content.Close()
ctx.Header("Cache-Control", "private, no-store")
ctx.Header("Content-Type", result.Asset.MediaType)
ctx.Header("Content-Length", strconv.FormatInt(result.Asset.SizeBytes, 10))
ctx.Header("ETag", `"`+result.Asset.SHA256+`"`)
ctx.Header("X-Content-Type-Options", "nosniff")
ctx.Header(
"Content-Disposition",
`inline; filename="`+result.Asset.ID+`.jpg"`,
)
ctx.Status(http.StatusOK)
_, _ = io.Copy(ctx.Writer, result.Content)
writeAssetContent(ctx, result)
}
func (h *adminHandlers) createTask(ctx *gin.Context) {
@@ -304,11 +293,30 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) {
events = append(events, gin.H{
"id": event.ID,
"actor_user_id": event.ActorUserID,
"actor_device_id": event.ActorDeviceID,
"type": event.Type,
"message": event.Message,
"occurred_at": formatTime(event.OccurredAt),
})
}
var claim any
if detail.Task.ClaimGeneration > 0 {
claim = gin.H{
"user_id": detail.Task.ClaimedByUserID,
"device_id": detail.Task.ClaimedByDeviceID,
"generation": detail.Task.ClaimGeneration,
"issued_at": formatOptionalTime(detail.Task.ClaimIssuedAt),
"expires_at": formatOptionalTime(detail.Task.ClaimExpiresAt),
"cancel_requested_at": formatOptionalTime(
detail.Task.CancelRequestedAt,
),
"cancel_requested_by_user_id": detail.Task.CancelRequestedByUserID,
}
}
var execution any
if detail.Execution != nil {
execution = executionResponse(*detail.Execution)
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"id": detail.Task.ID,
@@ -327,8 +335,8 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) {
"currency": detail.Task.Currency,
},
"derived_requirement": nil,
"claim": nil,
"execution": nil,
"claim": claim,
"execution": execution,
"events": events,
"assets": []gin.H{
assetResponse(detail.Asset),
@@ -441,6 +449,8 @@ func writeUsecaseError(ctx *gin.Context, err error) {
}
case usecase.ErrorKindNotFound:
status = http.StatusNotFound
case usecase.ErrorKindForbidden:
status = http.StatusForbidden
case usecase.ErrorKindConflict:
status = http.StatusConflict
case usecase.ErrorKindUnavailable:
@@ -510,6 +520,10 @@ func taskSummaryResponse(task domain.PurchaseTask) gin.H {
"created_at": formatTime(task.CreatedAt),
"updated_at": formatTime(task.UpdatedAt),
"version": task.Version,
"cancel_requested": task.CancelRequestedAt != nil,
"cancel_requested_at": formatOptionalTime(
task.CancelRequestedAt,
),
}
}
@@ -522,3 +536,41 @@ func taskListItemResponse(task domain.PurchaseTask) gin.H {
func formatTime(value time.Time) string {
return value.UTC().Format(time.RFC3339Nano)
}
func formatOptionalTime(value *time.Time) any {
if value == nil {
return nil
}
return formatTime(*value)
}
func executionResponse(execution domain.TaskExecution) gin.H {
return gin.H{
"id": execution.ID,
"attempt_no": execution.AttemptNo,
"claim_generation": execution.ClaimGeneration,
"user_id": execution.UserID,
"device_id": execution.DeviceID,
"current_step": execution.CurrentStep,
"order_submitted": execution.OrderSubmitted,
"started_at": formatTime(execution.StartedAt),
"last_heartbeat_at": formatOptionalTime(
execution.LastHeartbeatAt,
),
"finished_at": formatOptionalTime(execution.FinishedAt),
}
}
func writeAssetContent(ctx *gin.Context, result usecase.AssetContent) {
ctx.Header("Cache-Control", "private, no-store")
ctx.Header("Content-Type", result.Asset.MediaType)
ctx.Header("Content-Length", strconv.FormatInt(result.Asset.SizeBytes, 10))
ctx.Header("ETag", `"`+result.Asset.SHA256+`"`)
ctx.Header("X-Content-Type-Options", "nosniff")
ctx.Header(
"Content-Disposition",
`inline; filename="`+result.Asset.ID+`.jpg"`,
)
ctx.Status(http.StatusOK)
_, _ = io.Copy(ctx.Writer, result.Content)
}
@@ -360,7 +360,9 @@ func newAdminIntegrationRouter(t *testing.T) http.Handler {
Database: db,
RegisterPublicRoutes: discardRoutes,
RegisterAdminRoutes: registrar,
RegisterDeviceRoutes: discardRoutes,
AdminSessions: allowAdminAuthenticator{},
DeviceAccess: allowAdminAuthenticator{},
LogEvent: discardEvent,
})
if err != nil {
@@ -0,0 +1,455 @@
package httpapi
import (
"errors"
"net/http"
"strconv"
"strings"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/transport/authcommon"
"cmroubao/backend-api/internal/usecase"
"github.com/gin-gonic/gin"
)
const claimTokenHeader = "X-Claim-Token"
type DeviceServices struct {
Lifecycle *usecase.LifecycleService
Assets *usecase.AssetService
}
func (services DeviceServices) validate() error {
if services.Lifecycle == nil || services.Assets == nil {
return errors.New("device services are required")
}
return nil
}
type deviceHandlers struct {
services DeviceServices
}
func NewDeviceRouteRegistrar(
services DeviceServices,
) (RouteRegistrar, error) {
if err := services.validate(); err != nil {
return nil, err
}
handler := &deviceHandlers{services: services}
return func(routes gin.IRoutes) error {
routes.POST(
"/api/v1/devices/heartbeat",
handler.heartbeatDevice,
)
routes.POST(
"/api/v1/tasks/claim-next",
handler.claimNext,
)
routes.POST(
"/api/v1/tasks/:id/start",
handler.startTask,
)
routes.POST(
"/api/v1/tasks/:id/heartbeat",
handler.heartbeatTask,
)
routes.GET(
"/api/v1/tasks/:id/reference-image",
handler.referenceImage,
)
routes.POST(
"/api/v1/tasks/:id/release",
handler.releaseTask,
)
routes.POST(
"/api/v1/tasks/:id/cancel-ack",
handler.acknowledgeCancellation,
)
return nil
}, nil
}
func (handler *deviceHandlers) referenceImage(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx)
if !ok {
return
}
generation, err := strconv.ParseInt(
strings.TrimSpace(ctx.Query("claim_generation")),
10,
64,
)
if err != nil || generation < 1 {
writePublicError(
ctx,
http.StatusUnprocessableEntity,
"TASK_REFERENCE_IMAGE_INVALID",
"task reference image request is invalid",
false,
fieldDetails("claim_generation", "must be a positive integer"),
)
return
}
task, err := handler.services.Lifecycle.AuthorizeReferenceImage(
ctx.Request.Context(),
usecase.ReferenceImageCommand{
UserID: principal.UserID,
DeviceID: principal.DeviceID,
TaskID: ctx.Param("id"),
ClaimGeneration: generation,
ClaimToken: ctx.GetHeader(claimTokenHeader),
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
result, err := handler.services.Assets.OpenTaskReference(
ctx.Request.Context(),
localAdminSubject,
task.ImageAssetID,
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
defer result.Content.Close()
writeAssetContent(ctx, result)
}
func (handler *deviceHandlers) heartbeatDevice(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx)
if !ok {
return
}
var request struct {
DeviceID string `json:"device_id"`
AppVersion string `json:"app_version"`
AndroidVersion string `json:"android_version"`
PDDVersion string `json:"pdd_version"`
Readiness struct {
AccessibilityEnabled bool `json:"accessibility_enabled"`
PDDInstalled bool `json:"pdd_installed"`
ActiveTaskID *string `json:"active_task_id"`
} `json:"readiness"`
}
if !decodeDeviceJSON(ctx, &request) ||
!deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) {
return
}
result, err := handler.services.Lifecycle.HeartbeatDevice(
ctx.Request.Context(),
usecase.DeviceHeartbeatCommand{
UserID: principal.UserID,
DeviceID: principal.DeviceID,
AppVersion: request.AppVersion,
AndroidVersion: request.AndroidVersion,
PDDVersion: request.PDDVersion,
AccessibilityEnabled: request.Readiness.AccessibilityEnabled,
PDDInstalled: request.Readiness.PDDInstalled,
ClientActiveTaskID: request.Readiness.ActiveTaskID,
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"device_id": result.Device.ID,
"readiness": gin.H{
"reported_at": formatOptionalTime(result.Device.ReadinessAt),
"accessibility_enabled": result.Device.AccessibilityEnabled,
"pdd_installed": result.Device.PDDInstalled,
},
"active_task_id": result.ActiveTaskID,
"client_state_matches": result.ClientStateMatches,
"server_time": formatTime(result.ServerTime),
})
}
func (handler *deviceHandlers) claimNext(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx)
if !ok {
return
}
var request struct {
DeviceID string `json:"device_id"`
}
if !decodeDeviceJSON(ctx, &request) ||
!deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) {
return
}
result, err := handler.services.Lifecycle.ClaimNext(
ctx.Request.Context(),
usecase.ClaimNextCommand{
UserID: principal.UserID,
DeviceID: principal.DeviceID,
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
ClaimToken: ctx.GetHeader(claimTokenHeader),
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
if result.Task == nil {
ctx.Status(http.StatusNoContent)
return
}
ctx.JSON(http.StatusOK, gin.H{
"task": deviceTaskResponse(*result.Task),
"replayed": result.Replayed,
"server_time": formatTime(result.ServerTime),
})
}
func (handler *deviceHandlers) startTask(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx)
if !ok {
return
}
var request lifecycleTransitionRequest
if !decodeDeviceJSON(ctx, &request) ||
!deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) {
return
}
result, err := handler.services.Lifecycle.StartTask(
ctx.Request.Context(),
usecase.StartTaskCommand{
UserID: principal.UserID,
DeviceID: principal.DeviceID,
TaskID: ctx.Param("id"),
ClaimGeneration: request.ClaimGeneration,
ClaimToken: ctx.GetHeader(claimTokenHeader),
ExpectedVersion: request.ExpectedVersion,
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"task": deviceTaskResponse(result.Task),
"execution": executionResponse(result.Execution),
"replayed": result.Replayed,
"server_time": formatTime(result.ServerTime),
})
}
func (handler *deviceHandlers) heartbeatTask(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx)
if !ok {
return
}
var request struct {
DeviceID string `json:"device_id"`
ExecutionID string `json:"execution_id"`
ClaimGeneration int64 `json:"claim_generation"`
Step string `json:"step"`
}
if !decodeDeviceJSON(ctx, &request) ||
!deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) {
return
}
result, err := handler.services.Lifecycle.HeartbeatTask(
ctx.Request.Context(),
usecase.TaskHeartbeatCommand{
UserID: principal.UserID,
DeviceID: principal.DeviceID,
TaskID: ctx.Param("id"),
ExecutionID: request.ExecutionID,
ClaimGeneration: request.ClaimGeneration,
ClaimToken: ctx.GetHeader(claimTokenHeader),
Step: request.Step,
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"task": deviceTaskResponse(result.Task),
"execution": executionResponse(result.Execution),
"cancel_requested": result.CancelRequested,
"server_time": formatTime(result.ServerTime),
})
}
func (handler *deviceHandlers) releaseTask(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx)
if !ok {
return
}
var request lifecycleTransitionRequest
if !decodeDeviceJSON(ctx, &request) ||
!deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) {
return
}
result, err := handler.services.Lifecycle.ReleaseTask(
ctx.Request.Context(),
usecase.ReleaseTaskCommand{
UserID: principal.UserID,
DeviceID: principal.DeviceID,
TaskID: ctx.Param("id"),
ClaimGeneration: request.ClaimGeneration,
ClaimToken: ctx.GetHeader(claimTokenHeader),
ExpectedVersion: request.ExpectedVersion,
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"task": deviceTaskResponse(result.Task),
"replayed": result.Replayed,
"server_time": formatTime(result.ServerTime),
})
}
func (handler *deviceHandlers) acknowledgeCancellation(
ctx *gin.Context,
) {
principal, ok := devicePrincipal(ctx)
if !ok {
return
}
var request struct {
lifecycleTransitionRequest
ExecutionID string `json:"execution_id"`
}
if !decodeDeviceJSON(ctx, &request) ||
!deviceIDMatches(
ctx,
request.DeviceID,
principal.DeviceID,
) {
return
}
result, err := handler.services.Lifecycle.AcknowledgeCancellation(
ctx.Request.Context(),
usecase.AcknowledgeCancellationCommand{
UserID: principal.UserID,
DeviceID: principal.DeviceID,
TaskID: ctx.Param("id"),
ExecutionID: request.ExecutionID,
ClaimGeneration: request.ClaimGeneration,
ClaimToken: ctx.GetHeader(claimTokenHeader),
ExpectedVersion: request.ExpectedVersion,
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"task": deviceTaskResponse(result.Task),
"replayed": result.Replayed,
"server_time": formatTime(result.ServerTime),
})
}
type lifecycleTransitionRequest struct {
DeviceID string `json:"device_id"`
ClaimGeneration int64 `json:"claim_generation"`
ExpectedVersion int64 `json:"expected_version"`
}
func devicePrincipal(
ctx *gin.Context,
) (domain.AuthPrincipal, bool) {
principal, ok := authcommon.Principal(ctx.Request.Context())
if ok &&
principal.Role == domain.UserRoleBuyer &&
principal.UserID != "" &&
principal.DeviceID != "" {
return principal, true
}
writePublicError(
ctx,
http.StatusUnauthorized,
"DEVICE_ACCESS_REQUIRED",
"device access token required",
false,
gin.H{},
)
return domain.AuthPrincipal{}, false
}
func decodeDeviceJSON(ctx *gin.Context, target any) bool {
if !hasMediaType(ctx, "application/json") {
writePublicError(
ctx,
http.StatusUnsupportedMediaType,
"UNSUPPORTED_MEDIA_TYPE",
"application/json is required",
false,
gin.H{},
)
return false
}
if err := decodeJSON(ctx, target); err != nil {
writePublicError(
ctx,
http.StatusBadRequest,
"INVALID_JSON",
"request body must be valid JSON",
false,
gin.H{},
)
return false
}
return true
}
func deviceIDMatches(
ctx *gin.Context,
presented string,
authoritative string,
) bool {
if presented == "" || presented == authoritative {
return true
}
writePublicError(
ctx,
http.StatusForbidden,
"DEVICE_ID_MISMATCH",
"request device does not match access token",
false,
gin.H{},
)
return false
}
func deviceTaskResponse(task domain.PurchaseTask) gin.H {
referenceImageURL := "/api/v1/tasks/" + task.ID +
"/reference-image?claim_generation=" +
strconv.FormatInt(task.ClaimGeneration, 10)
return gin.H{
"id": task.ID,
"status": task.Status,
"version": task.Version,
"claim_generation": task.ClaimGeneration,
"claim_issued_at": formatOptionalTime(task.ClaimIssuedAt),
"claim_expires_at": formatOptionalTime(task.ClaimExpiresAt),
"title": task.Title,
"description": task.Description,
"sku": task.SKU,
"image_asset_id": task.ImageAssetID,
"reference_image_url": referenceImageURL,
"quantity": task.Quantity,
"max_budget": domain.FormatOptionalCNY(task.MaxBudgetCents),
"currency": task.Currency,
}
}
File diff suppressed because it is too large Load Diff
@@ -25,7 +25,9 @@ type RouterDependencies struct {
Database DatabasePinger
RegisterPublicRoutes RouteRegistrar
RegisterAdminRoutes RouteRegistrar
RegisterDeviceRoutes RouteRegistrar
AdminSessions AdminAuthenticator
DeviceAccess DeviceAuthenticator
LogEvent EventLogger
}
@@ -62,9 +64,15 @@ func NewRouter(dependencies RouterDependencies) (http.Handler, error) {
if dependencies.RegisterPublicRoutes == nil {
return nil, errors.New("public route registrar is required")
}
if dependencies.RegisterDeviceRoutes == nil {
return nil, errors.New("device route registrar is required")
}
if dependencies.AdminSessions == nil {
return nil, errors.New("admin authenticator is required")
}
if dependencies.DeviceAccess == nil {
return nil, errors.New("device authenticator is required")
}
if dependencies.LogEvent == nil {
return nil, errors.New("event logger is required")
}
@@ -86,6 +94,11 @@ func NewRouter(dependencies RouterDependencies) (http.Handler, error) {
if err := dependencies.RegisterAdminRoutes(adminRoutes); err != nil {
return nil, err
}
deviceRoutes := router.Group("")
deviceRoutes.Use(RequireDeviceAccess(dependencies.DeviceAccess))
if err := dependencies.RegisterDeviceRoutes(deviceRoutes); err != nil {
return nil, err
}
router.NoRoute(func(ctx *gin.Context) {
ctx.JSON(http.StatusNotFound, errorResponse(
ctx,
@@ -120,7 +120,9 @@ func TestRouterRequiresDependencies(t *testing.T) {
Database: fakePinger{},
RegisterPublicRoutes: discardRoutes,
RegisterAdminRoutes: discardRoutes,
RegisterDeviceRoutes: discardRoutes,
AdminSessions: allowAdminAuthenticator{},
DeviceAccess: allowAdminAuthenticator{},
LogEvent: discardEvent,
}
missingDatabase := valid
@@ -138,11 +140,21 @@ func TestRouterRequiresDependencies(t *testing.T) {
if _, err := NewRouter(missingPublicRoutes); err == nil {
t.Fatal("NewRouter(nil public routes) error = nil")
}
missingDeviceRoutes := valid
missingDeviceRoutes.RegisterDeviceRoutes = nil
if _, err := NewRouter(missingDeviceRoutes); err == nil {
t.Fatal("NewRouter(nil device routes) error = nil")
}
missingAuth := valid
missingAuth.AdminSessions = nil
if _, err := NewRouter(missingAuth); err == nil {
t.Fatal("NewRouter(nil admin auth) error = nil")
}
missingDeviceAuth := valid
missingDeviceAuth.DeviceAccess = nil
if _, err := NewRouter(missingDeviceAuth); err == nil {
t.Fatal("NewRouter(nil device auth) error = nil")
}
missingLogger := valid
missingLogger.LogEvent = nil
if _, err := NewRouter(missingLogger); err == nil {
@@ -158,7 +170,9 @@ func newTestRouter(
Database: database,
RegisterPublicRoutes: discardRoutes,
RegisterAdminRoutes: discardRoutes,
RegisterDeviceRoutes: discardRoutes,
AdminSessions: allowAdminAuthenticator{},
DeviceAccess: allowAdminAuthenticator{},
LogEvent: logEvent,
})
}
@@ -255,6 +269,19 @@ func (allowAdminAuthenticator) AuthenticateAdmin(
}, nil
}
func (allowAdminAuthenticator) AuthenticateAccessToken(
context.Context,
string,
) (domain.AuthPrincipal, error) {
return domain.AuthPrincipal{
UserID: "00000000-0000-4000-8000-000000000098",
Username: "buyer",
Role: domain.UserRoleBuyer,
DeviceID: "00000000-0000-4000-8000-000000000097",
ExpiresAt: time.Now().Add(time.Hour),
}, nil
}
var requestIDPattern = regexp.MustCompile(
`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`,
)
@@ -299,9 +299,9 @@ func (h *Handler) CancelTask(ctx *gin.Context) {
)
return
}
_, err := h.service.CancelPending(
task, err := h.service.CancelTask(
ctx.Request.Context(),
CancelPendingInput{
CancelTaskInput{
TaskID: taskID,
IdempotencyKey: cancelKey,
},
@@ -317,9 +317,13 @@ func (h *Handler) CancelTask(ctx *gin.Context) {
h.renderServiceError(ctx, err, "取消失败,请稍后重试。")
return
}
notice := "cancel-requested"
if task.Status == "CANCELED" {
notice = "canceled"
}
ctx.Redirect(
http.StatusSeeOther,
"/tasks/"+pathEscape(taskID)+"?notice=canceled",
"/tasks/"+pathEscape(taskID)+"?notice="+notice,
)
}
@@ -644,6 +648,8 @@ func detailNotice(value string) string {
switch value {
case "canceled":
return "任务已取消,不会自动恢复。"
case "cancel-requested":
return "已请求设备安全停止;设备确认前任务仍保持当前执行状态。"
case "cancel-conflict":
return "任务状态已变化,当前不能取消。"
default:
@@ -749,6 +755,7 @@ type taskDetailView struct {
CreatedAt time.Time
UpdatedAt time.Time
CanCancel bool
CancelRequiresAck bool
}
type taskDetailPage struct {
@@ -779,7 +786,18 @@ func taskDetailViewFrom(task Task) taskDetailView {
ReferenceAssetID: task.ReferenceAssetID,
CreatedAt: task.CreatedAt,
UpdatedAt: task.UpdatedAt,
CanCancel: task.Status == "PENDING",
CanCancel: canCancelTaskStatus(task.Status),
CancelRequiresAck: task.Status == "RUNNING" ||
task.Status == "WAITING_CONFIRMATION",
}
}
func canCancelTaskStatus(status string) bool {
switch status {
case "PENDING", "CLAIMED", "RUNNING", "WAITING_CONFIRMATION":
return true
default:
return false
}
}
@@ -432,6 +432,7 @@ func TestTaskDetailPendingCancelUsesCSRFAndPRG(t *testing.T) {
CreatedAt: time.Date(2026, 7, 26, 3, 4, 5, 0, time.UTC),
UpdatedAt: time.Date(2026, 7, 26, 3, 5, 5, 0, time.UTC),
},
cancelResult: Task{Status: "CANCELED"},
}
router := newTestRouter(t, service)
detail := performRequest(
@@ -508,6 +509,81 @@ func TestTaskDetailDoesNotLeakForbiddenResource(t *testing.T) {
}
}
func TestTaskDetailCancelModeFollowsLifecycleStatus(t *testing.T) {
tests := []struct {
status string
canCancel bool
requiresAck bool
}{
{status: "PENDING", canCancel: true},
{status: "CLAIMED", canCancel: true},
{status: "RUNNING", canCancel: true, requiresAck: true},
{
status: "WAITING_CONFIRMATION",
canCancel: true,
requiresAck: true,
},
{status: "SUCCEEDED"},
{status: "FAILED"},
{status: "CANCELED"},
}
for _, test := range tests {
t.Run(test.status, func(t *testing.T) {
view := taskDetailViewFrom(Task{Status: test.status})
if view.CanCancel != test.canCancel ||
view.CancelRequiresAck != test.requiresAck {
t.Fatalf("task detail view = %+v", view)
}
})
}
if notice := detailNotice("cancel-requested"); !strings.Contains(
notice,
"安全停止",
) {
t.Fatalf("cancel requested notice = %q", notice)
}
}
func TestRunningTaskDetailExplainsCancelAcknowledgement(t *testing.T) {
service := &fakeService{
getResult: Task{
ID: testTaskID,
Title: "运行中任务",
SKU: "RUNNING-SKU",
Quantity: 1,
Status: "RUNNING",
ReferenceAssetID: "00000000-0000-4000-8000-000000000009",
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
},
}
router := newTestRouter(t, service)
response := performRequest(
t,
router,
http.MethodGet,
"/tasks/"+testTaskID,
nil,
"",
)
if response.Code != http.StatusOK {
t.Fatalf("running task detail status = %d", response.Code)
}
body := response.Body.String()
for _, expected := range []string{
"请求安全停止任务?",
"设备确认前任务仍保持当前执行状态",
"确认请求停止",
} {
if !strings.Contains(body, expected) {
t.Fatalf("running detail missing %q", expected)
}
}
if strings.Contains(body, "采购执行员将不能再领取") {
t.Fatal("running detail uses immediate cancellation copy")
}
}
func TestStaticFilesAreEmbeddedAndProtected(t *testing.T) {
router := newTestRouter(t, &fakeService{})
for _, route := range []string{"/static/admin.css", "/static/admin.js"} {
@@ -554,7 +630,7 @@ type fakeService struct {
createCalls int
cancelResult Task
cancelErr error
cancelInput CancelPendingInput
cancelInput CancelTaskInput
}
func (service *fakeService) ListTasks(
@@ -595,9 +671,9 @@ func (service *fakeService) CreateTask(
return service.createResult, service.createErr
}
func (service *fakeService) CancelPending(
func (service *fakeService) CancelTask(
_ context.Context,
input CancelPendingInput,
input CancelTaskInput,
) (Task, error) {
service.cancelInput = input
return service.cancelResult, service.cancelErr
@@ -76,26 +76,40 @@
{{if .Task.CanCancel}}
<noscript>
<section class="noscript-cancel" aria-labelledby="noscript-cancel-heading">
{{if .Task.CancelRequiresAck}}
<h2 id="noscript-cancel-heading">请求安全停止任务</h2>
<p>设备确认安全停止前,任务仍保持当前执行状态。</p>
{{else}}
<h2 id="noscript-cancel-heading">确认取消任务</h2>
<p>取消后任务不会自动恢复。</p>
{{end}}
<form method="post" action="/tasks/{{pathPart .Task.ID}}/cancel">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="cancel_key" value="{{.CancelKey}}">
<button class="button danger" type="submit">确认取消</button>
<button class="button danger" type="submit">
{{if .Task.CancelRequiresAck}}确认请求停止{{else}}确认取消{{end}}
</button>
</form>
</section>
</noscript>
<dialog class="confirm-dialog" data-cancel-dialog aria-labelledby="cancel-title">
{{if .Task.CancelRequiresAck}}
<h2 id="cancel-title">请求安全停止任务?</h2>
<p>系统将通知采购 App 在安全检查点停止;设备确认前任务仍保持当前执行状态。</p>
{{else}}
<h2 id="cancel-title">确认取消任务?</h2>
<p>任务取消后不会自动恢复,采购执行员将不能再领取。</p>
{{end}}
<div class="dialog-actions">
<button class="button" type="button" data-keep-task>保留任务</button>
<form method="post" action="/tasks/{{pathPart .Task.ID}}/cancel" data-submit-form>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="cancel_key" value="{{.CancelKey}}">
<button class="button danger" type="submit" data-submit-button
data-loading-label="正在取消…">确认取消</button>
data-loading-label="{{if .Task.CancelRequiresAck}}正在请求停止…{{else}}正在取消…{{end}}">
{{if .Task.CancelRequiresAck}}确认请求停止{{else}}确认取消{{end}}
</button>
</form>
</div>
</dialog>
@@ -23,7 +23,7 @@ type Service interface {
GetTask(context.Context, string) (Task, error)
UploadReference(context.Context, UploadReferenceInput) (UploadedAsset, error)
CreateTask(context.Context, CreateTaskInput) (Task, error)
CancelPending(context.Context, CancelPendingInput) (Task, error)
CancelTask(context.Context, CancelTaskInput) (Task, error)
}
type ListTasksInput struct {
@@ -81,7 +81,7 @@ type CreateTaskInput struct {
ImageAssetID string
}
type CancelPendingInput struct {
type CancelTaskInput struct {
TaskID string
IdempotencyKey string
}
@@ -128,9 +128,9 @@ func actorUserID(ctx context.Context) string {
return principal.UserID
}
func (adapter *UsecaseAdapter) CancelPending(
func (adapter *UsecaseAdapter) CancelTask(
ctx context.Context,
input CancelPendingInput,
input CancelTaskInput,
) (Task, error) {
task, err := adapter.tasks.Cancel(ctx, usecase.CancelTaskCommand{
CreatorSubject: localAdminSubject,
@@ -0,0 +1,69 @@
package usecase
import "errors"
var (
ErrDeviceNotReady = errors.New("device readiness is missing or stale")
ErrDeviceActiveTask = errors.New("device already has an active task")
ErrClaimInvalid = errors.New("task claim is invalid")
ErrClaimExpired = errors.New("task claim lease expired")
ErrClaimReplayExpired = errors.New("claim idempotency replay is stale")
ErrTaskVersionConflict = errors.New("task version conflict")
ErrExecutionMismatch = errors.New("task execution does not match")
)
func wrapLifecycleRepositoryError(err error) error {
switch {
case errors.Is(err, ErrDeviceNotReady):
return newError(
ErrorKindConflict,
"DEVICE_NOT_READY",
"device is not ready to claim a task",
err,
)
case errors.Is(err, ErrDeviceActiveTask):
return newError(
ErrorKindConflict,
"DEVICE_ACTIVE_TASK",
"device already has an active task",
err,
)
case errors.Is(err, ErrClaimInvalid):
return newError(
ErrorKindForbidden,
"TASK_CLAIM_INVALID",
"task claim is invalid",
err,
)
case errors.Is(err, ErrClaimExpired):
return newError(
ErrorKindConflict,
"TASK_LEASE_EXPIRED",
"task claim lease expired",
err,
)
case errors.Is(err, ErrClaimReplayExpired):
return newError(
ErrorKindConflict,
"CLAIM_REPLAY_EXPIRED",
"claim replay is no longer active",
err,
)
case errors.Is(err, ErrTaskVersionConflict):
return newError(
ErrorKindConflict,
"TASK_VERSION_CONFLICT",
"task version has changed",
err,
)
case errors.Is(err, ErrExecutionMismatch):
return newError(
ErrorKindConflict,
"TASK_EXECUTION_CONFLICT",
"task execution does not match",
err,
)
default:
return wrapRepositoryError(err)
}
}
@@ -0,0 +1,148 @@
package usecase
import (
"context"
"time"
"cmroubao/backend-api/internal/domain"
)
type DeviceHeartbeatUpdate struct {
UserID string
DeviceID string
AppVersion string
AndroidVersion string
PDDVersion string
AccessibilityEnabled bool
PDDInstalled bool
ReportedAt time.Time
}
type DeviceHeartbeatRecord struct {
Device domain.Device
ActiveTaskID *string
}
type ClaimNextRepositoryRequest struct {
CreatorSubject string
UserID string
DeviceID string
IdempotencyKey string
RequestHash string
ClaimTokenHash string
Now time.Time
ExpiresAt time.Time
ReadinessAfter time.Time
Event domain.TaskEvent
}
type ClaimNextRepositoryResult struct {
Task *domain.PurchaseTask
Replayed bool
}
type StartTaskRepositoryRequest struct {
UserID string
DeviceID string
TaskID string
ClaimGeneration int64
ClaimTokenHash string
ExpectedVersion int64
IdempotencyKey string
RequestHash string
Now time.Time
ExpiresAt time.Time
Execution domain.TaskExecution
Event domain.TaskEvent
}
type StartTaskRepositoryResult struct {
Task domain.PurchaseTask
Execution domain.TaskExecution
Replayed bool
}
type TaskHeartbeatRepositoryRequest struct {
UserID string
DeviceID string
TaskID string
ExecutionID string
ClaimGeneration int64
ClaimTokenHash string
Step string
Now time.Time
MinimumExpiry time.Time
}
type TaskClaimRepositoryRequest struct {
UserID string
DeviceID string
TaskID string
ClaimGeneration int64
ClaimTokenHash string
Now time.Time
}
type TaskHeartbeatRepositoryResult struct {
Task domain.PurchaseTask
Execution domain.TaskExecution
CancelRequested bool
}
type ReleaseTaskRepositoryRequest struct {
UserID string
DeviceID string
TaskID string
ClaimGeneration int64
ClaimTokenHash string
ExpectedVersion int64
IdempotencyKey string
RequestHash string
Now time.Time
Event domain.TaskEvent
}
type CancelAcknowledgementRepositoryRequest struct {
UserID string
DeviceID string
TaskID string
ExecutionID string
ClaimGeneration int64
ClaimTokenHash string
ExpectedVersion int64
IdempotencyKey string
RequestHash string
Now time.Time
Event domain.TaskEvent
}
type LifecycleRepository interface {
RecordDeviceHeartbeat(
context.Context,
DeviceHeartbeatUpdate,
) (DeviceHeartbeatRecord, error)
ClaimNext(
context.Context,
ClaimNextRepositoryRequest,
) (ClaimNextRepositoryResult, error)
StartTask(
context.Context,
StartTaskRepositoryRequest,
) (StartTaskRepositoryResult, error)
HeartbeatTask(
context.Context,
TaskHeartbeatRepositoryRequest,
) (TaskHeartbeatRepositoryResult, error)
GetActiveClaimTask(
context.Context,
TaskClaimRepositoryRequest,
) (domain.PurchaseTask, error)
ReleaseTask(
context.Context,
ReleaseTaskRepositoryRequest,
) (domain.PurchaseTask, bool, error)
AcknowledgeTaskCancellation(
context.Context,
CancelAcknowledgementRepositoryRequest,
) (domain.PurchaseTask, bool, error)
}
@@ -0,0 +1,783 @@
package usecase
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"time"
"unicode/utf8"
"cmroubao/backend-api/internal/domain"
)
const (
lifecycleCreatorSubject = "local-admin"
maxLifecycleStepBytes = 64
)
type LifecycleService struct {
repository LifecycleRepository
clock Clock
ids IDGenerator
claimLease time.Duration
runningLease time.Duration
readinessTTL time.Duration
}
type DeviceHeartbeatCommand struct {
UserID string
DeviceID string
AppVersion string
AndroidVersion string
PDDVersion string
AccessibilityEnabled bool
PDDInstalled bool
ClientActiveTaskID *string
}
type DeviceHeartbeatResult struct {
Device domain.Device
ActiveTaskID *string
ClientStateMatches bool
ServerTime time.Time
}
type ClaimNextCommand struct {
UserID string
DeviceID string
IdempotencyKey string
ClaimToken string
}
type ClaimNextResult struct {
Task *domain.PurchaseTask
Replayed bool
ServerTime time.Time
}
type StartTaskCommand struct {
UserID string
DeviceID string
TaskID string
ClaimGeneration int64
ClaimToken string
ExpectedVersion int64
IdempotencyKey string
}
type StartTaskResult struct {
Task domain.PurchaseTask
Execution domain.TaskExecution
Replayed bool
ServerTime time.Time
}
type TaskHeartbeatCommand struct {
UserID string
DeviceID string
TaskID string
ExecutionID string
ClaimGeneration int64
ClaimToken string
Step string
}
type TaskHeartbeatResult struct {
Task domain.PurchaseTask
Execution domain.TaskExecution
CancelRequested bool
ServerTime time.Time
}
type ReferenceImageCommand struct {
UserID string
DeviceID string
TaskID string
ClaimGeneration int64
ClaimToken string
}
type ReleaseTaskCommand struct {
UserID string
DeviceID string
TaskID string
ClaimGeneration int64
ClaimToken string
ExpectedVersion int64
IdempotencyKey string
}
type ReleaseTaskResult struct {
Task domain.PurchaseTask
Replayed bool
ServerTime time.Time
}
type AcknowledgeCancellationCommand struct {
UserID string
DeviceID string
TaskID string
ExecutionID string
ClaimGeneration int64
ClaimToken string
ExpectedVersion int64
IdempotencyKey string
}
type AcknowledgeCancellationResult struct {
Task domain.PurchaseTask
Replayed bool
ServerTime time.Time
}
func NewLifecycleService(
repository LifecycleRepository,
clock Clock,
ids IDGenerator,
claimLease time.Duration,
runningLease time.Duration,
readinessTTL time.Duration,
) (*LifecycleService, error) {
if repository == nil || clock == nil || ids == nil {
return nil, errors.New("lifecycle service dependencies are required")
}
if claimLease <= 0 || runningLease <= 0 || readinessTTL <= 0 {
return nil, errors.New("lifecycle durations must be positive")
}
return &LifecycleService{
repository: repository,
clock: clock,
ids: ids,
claimLease: claimLease,
runningLease: runningLease,
readinessTTL: readinessTTL,
}, nil
}
func (service *LifecycleService) HeartbeatDevice(
ctx context.Context,
command DeviceHeartbeatCommand,
) (DeviceHeartbeatResult, error) {
command.UserID = strings.TrimSpace(command.UserID)
command.DeviceID = strings.TrimSpace(command.DeviceID)
fields := lifecycleIdentityFields(command.UserID, command.DeviceID)
appVersion := strings.TrimSpace(command.AppVersion)
androidVersion := strings.TrimSpace(command.AndroidVersion)
pddVersion := strings.TrimSpace(command.PDDVersion)
validateVersionField(fields, "app_version", appVersion)
validateVersionField(fields, "android_version", androidVersion)
validateVersionField(fields, "pdd_version", pddVersion)
if command.ClientActiveTaskID != nil {
value := strings.TrimSpace(*command.ClientActiveTaskID)
command.ClientActiveTaskID = &value
if value != "" && !isUUID(value) {
fields["active_task_id"] = "must be a UUID or null"
}
}
if len(fields) > 0 {
return DeviceHeartbeatResult{}, invalidError(
"DEVICE_HEARTBEAT_INVALID",
"device heartbeat is invalid",
fields,
)
}
now := service.clock.Now().UTC()
record, err := service.repository.RecordDeviceHeartbeat(
ctx,
DeviceHeartbeatUpdate{
UserID: command.UserID,
DeviceID: command.DeviceID,
AppVersion: appVersion,
AndroidVersion: androidVersion,
PDDVersion: pddVersion,
AccessibilityEnabled: command.AccessibilityEnabled,
PDDInstalled: command.PDDInstalled,
ReportedAt: now,
},
)
if err != nil {
return DeviceHeartbeatResult{},
wrapLifecycleRepositoryError(err)
}
return DeviceHeartbeatResult{
Device: record.Device,
ActiveTaskID: record.ActiveTaskID,
ClientStateMatches: sameOptionalID(
command.ClientActiveTaskID,
record.ActiveTaskID,
),
ServerTime: now,
}, nil
}
func (service *LifecycleService) ClaimNext(
ctx context.Context,
command ClaimNextCommand,
) (ClaimNextResult, error) {
command.UserID = strings.TrimSpace(command.UserID)
command.DeviceID = strings.TrimSpace(command.DeviceID)
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
fields := lifecycleWriteFields(
command.UserID,
command.DeviceID,
command.IdempotencyKey,
command.ClaimToken,
)
if len(fields) > 0 {
return ClaimNextResult{}, invalidError(
"TASK_CLAIM_INVALID",
"task claim request is invalid",
fields,
)
}
requestHash, err := lifecycleRequestHash(struct {
UserID string `json:"user_id"`
DeviceID string `json:"device_id"`
ClaimTokenHash string `json:"claim_token_sha256"`
}{
UserID: command.UserID,
DeviceID: command.DeviceID,
ClaimTokenHash: hashSecret(command.ClaimToken),
})
if err != nil {
return ClaimNextResult{}, internalLifecycleFailure(err)
}
event, err := service.newLifecycleEvent(
command.UserID,
command.DeviceID,
"",
"TASK_CLAIMED",
"task claimed",
)
if err != nil {
return ClaimNextResult{}, err
}
now := service.clock.Now().UTC()
result, err := service.repository.ClaimNext(
ctx,
ClaimNextRepositoryRequest{
CreatorSubject: lifecycleCreatorSubject,
UserID: command.UserID,
DeviceID: command.DeviceID,
IdempotencyKey: command.IdempotencyKey,
RequestHash: requestHash,
ClaimTokenHash: hashSecret(command.ClaimToken),
Now: now,
ExpiresAt: now.Add(service.claimLease),
ReadinessAfter: now.Add(-service.readinessTTL),
Event: event,
},
)
if err != nil {
return ClaimNextResult{}, wrapLifecycleRepositoryError(err)
}
return ClaimNextResult{
Task: result.Task,
Replayed: result.Replayed,
ServerTime: now,
}, nil
}
func (service *LifecycleService) StartTask(
ctx context.Context,
command StartTaskCommand,
) (StartTaskResult, error) {
command = normalizeStartTaskCommand(command)
fields := lifecycleTransitionFields(
command.UserID,
command.DeviceID,
command.TaskID,
command.ClaimGeneration,
command.ClaimToken,
command.ExpectedVersion,
command.IdempotencyKey,
)
if len(fields) > 0 {
return StartTaskResult{}, invalidError(
"TASK_START_INVALID",
"task start request is invalid",
fields,
)
}
executionID, err := service.ids.NewID()
if err != nil {
return StartTaskResult{}, internalLifecycleFailure(err)
}
event, err := service.newLifecycleEvent(
command.UserID,
command.DeviceID,
command.TaskID,
"TASK_STARTED",
"task started",
)
if err != nil {
return StartTaskResult{}, err
}
now := service.clock.Now().UTC()
requestHash, err := lifecycleRequestHash(commandHashView{
TaskID: command.TaskID,
ClaimGeneration: command.ClaimGeneration,
ClaimTokenHash: hashSecret(command.ClaimToken),
ExpectedVersion: command.ExpectedVersion,
})
if err != nil {
return StartTaskResult{}, internalLifecycleFailure(err)
}
result, err := service.repository.StartTask(
ctx,
StartTaskRepositoryRequest{
UserID: command.UserID,
DeviceID: command.DeviceID,
TaskID: command.TaskID,
ClaimGeneration: command.ClaimGeneration,
ClaimTokenHash: hashSecret(command.ClaimToken),
ExpectedVersion: command.ExpectedVersion,
IdempotencyKey: command.IdempotencyKey,
RequestHash: requestHash,
Now: now,
ExpiresAt: now.Add(service.runningLease),
Execution: domain.TaskExecution{
ID: executionID,
TaskID: command.TaskID,
ClaimGeneration: command.ClaimGeneration,
UserID: command.UserID,
DeviceID: command.DeviceID,
CurrentStep: "PREFLIGHT",
OrderSubmitted: false,
StartedAt: now,
},
Event: event,
},
)
if err != nil {
return StartTaskResult{}, wrapLifecycleRepositoryError(err)
}
return StartTaskResult{
Task: result.Task,
Execution: result.Execution,
Replayed: result.Replayed,
ServerTime: now,
}, nil
}
func (service *LifecycleService) HeartbeatTask(
ctx context.Context,
command TaskHeartbeatCommand,
) (TaskHeartbeatResult, error) {
command.UserID = strings.TrimSpace(command.UserID)
command.DeviceID = strings.TrimSpace(command.DeviceID)
command.TaskID = strings.TrimSpace(command.TaskID)
command.ExecutionID = strings.TrimSpace(command.ExecutionID)
command.Step = strings.TrimSpace(command.Step)
fields := lifecycleClaimFields(
command.UserID,
command.DeviceID,
command.TaskID,
command.ClaimGeneration,
command.ClaimToken,
)
if !isUUID(command.ExecutionID) {
fields["execution_id"] = "must be a UUID"
}
if !validLifecycleStep(command.Step) {
fields["step"] = "must be 1-64 uppercase ASCII characters"
}
if len(fields) > 0 {
return TaskHeartbeatResult{}, invalidError(
"TASK_HEARTBEAT_INVALID",
"task heartbeat is invalid",
fields,
)
}
now := service.clock.Now().UTC()
result, err := service.repository.HeartbeatTask(
ctx,
TaskHeartbeatRepositoryRequest{
UserID: command.UserID,
DeviceID: command.DeviceID,
TaskID: command.TaskID,
ExecutionID: command.ExecutionID,
ClaimGeneration: command.ClaimGeneration,
ClaimTokenHash: hashSecret(command.ClaimToken),
Step: command.Step,
Now: now,
MinimumExpiry: now.Add(service.runningLease),
},
)
if err != nil {
return TaskHeartbeatResult{},
wrapLifecycleRepositoryError(err)
}
return TaskHeartbeatResult{
Task: result.Task,
Execution: result.Execution,
CancelRequested: result.CancelRequested,
ServerTime: now,
}, nil
}
func (service *LifecycleService) AuthorizeReferenceImage(
ctx context.Context,
command ReferenceImageCommand,
) (domain.PurchaseTask, error) {
command.UserID = strings.TrimSpace(command.UserID)
command.DeviceID = strings.TrimSpace(command.DeviceID)
command.TaskID = strings.TrimSpace(command.TaskID)
fields := lifecycleClaimFields(
command.UserID,
command.DeviceID,
command.TaskID,
command.ClaimGeneration,
command.ClaimToken,
)
if len(fields) > 0 {
return domain.PurchaseTask{}, invalidError(
"TASK_REFERENCE_IMAGE_INVALID",
"task reference image request is invalid",
fields,
)
}
task, err := service.repository.GetActiveClaimTask(
ctx,
TaskClaimRepositoryRequest{
UserID: command.UserID,
DeviceID: command.DeviceID,
TaskID: command.TaskID,
ClaimGeneration: command.ClaimGeneration,
ClaimTokenHash: hashSecret(command.ClaimToken),
Now: service.clock.Now().UTC(),
},
)
if err != nil {
return domain.PurchaseTask{},
wrapLifecycleRepositoryError(err)
}
return task, nil
}
func (service *LifecycleService) ReleaseTask(
ctx context.Context,
command ReleaseTaskCommand,
) (ReleaseTaskResult, error) {
command.UserID = strings.TrimSpace(command.UserID)
command.DeviceID = strings.TrimSpace(command.DeviceID)
command.TaskID = strings.TrimSpace(command.TaskID)
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
fields := lifecycleTransitionFields(
command.UserID,
command.DeviceID,
command.TaskID,
command.ClaimGeneration,
command.ClaimToken,
command.ExpectedVersion,
command.IdempotencyKey,
)
if len(fields) > 0 {
return ReleaseTaskResult{}, invalidError(
"TASK_RELEASE_INVALID",
"task release request is invalid",
fields,
)
}
event, err := service.newLifecycleEvent(
command.UserID,
command.DeviceID,
command.TaskID,
"TASK_RELEASED",
"task released",
)
if err != nil {
return ReleaseTaskResult{}, err
}
now := service.clock.Now().UTC()
requestHash, err := lifecycleRequestHash(commandHashView{
TaskID: command.TaskID,
ClaimGeneration: command.ClaimGeneration,
ClaimTokenHash: hashSecret(command.ClaimToken),
ExpectedVersion: command.ExpectedVersion,
})
if err != nil {
return ReleaseTaskResult{}, internalLifecycleFailure(err)
}
task, replayed, err := service.repository.ReleaseTask(
ctx,
ReleaseTaskRepositoryRequest{
UserID: command.UserID,
DeviceID: command.DeviceID,
TaskID: command.TaskID,
ClaimGeneration: command.ClaimGeneration,
ClaimTokenHash: hashSecret(command.ClaimToken),
ExpectedVersion: command.ExpectedVersion,
IdempotencyKey: command.IdempotencyKey,
RequestHash: requestHash,
Now: now,
Event: event,
},
)
if err != nil {
return ReleaseTaskResult{}, wrapLifecycleRepositoryError(err)
}
return ReleaseTaskResult{
Task: task,
Replayed: replayed,
ServerTime: now,
}, nil
}
func (service *LifecycleService) AcknowledgeCancellation(
ctx context.Context,
command AcknowledgeCancellationCommand,
) (AcknowledgeCancellationResult, error) {
command.UserID = strings.TrimSpace(command.UserID)
command.DeviceID = strings.TrimSpace(command.DeviceID)
command.TaskID = strings.TrimSpace(command.TaskID)
command.ExecutionID = strings.TrimSpace(command.ExecutionID)
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
fields := lifecycleTransitionFields(
command.UserID,
command.DeviceID,
command.TaskID,
command.ClaimGeneration,
command.ClaimToken,
command.ExpectedVersion,
command.IdempotencyKey,
)
if !isUUID(command.ExecutionID) {
fields["execution_id"] = "must be a UUID"
}
if len(fields) > 0 {
return AcknowledgeCancellationResult{}, invalidError(
"TASK_CANCEL_ACK_INVALID",
"task cancellation acknowledgement is invalid",
fields,
)
}
event, err := service.newLifecycleEvent(
command.UserID,
command.DeviceID,
command.TaskID,
"TASK_CANCELED",
"task cancellation acknowledged",
)
if err != nil {
return AcknowledgeCancellationResult{}, err
}
now := service.clock.Now().UTC()
requestHash, err := lifecycleRequestHash(struct {
commandHashView
ExecutionID string `json:"execution_id"`
}{
commandHashView: commandHashView{
TaskID: command.TaskID,
ClaimGeneration: command.ClaimGeneration,
ClaimTokenHash: hashSecret(command.ClaimToken),
ExpectedVersion: command.ExpectedVersion,
},
ExecutionID: command.ExecutionID,
})
if err != nil {
return AcknowledgeCancellationResult{},
internalLifecycleFailure(err)
}
task, replayed, err := service.repository.AcknowledgeTaskCancellation(
ctx,
CancelAcknowledgementRepositoryRequest{
UserID: command.UserID,
DeviceID: command.DeviceID,
TaskID: command.TaskID,
ExecutionID: command.ExecutionID,
ClaimGeneration: command.ClaimGeneration,
ClaimTokenHash: hashSecret(command.ClaimToken),
ExpectedVersion: command.ExpectedVersion,
IdempotencyKey: command.IdempotencyKey,
RequestHash: requestHash,
Now: now,
Event: event,
},
)
if err != nil {
return AcknowledgeCancellationResult{},
wrapLifecycleRepositoryError(err)
}
return AcknowledgeCancellationResult{
Task: task,
Replayed: replayed,
ServerTime: now,
}, nil
}
type commandHashView struct {
TaskID string `json:"task_id"`
ClaimGeneration int64 `json:"claim_generation"`
ClaimTokenHash string `json:"claim_token_sha256"`
ExpectedVersion int64 `json:"expected_version"`
}
func normalizeStartTaskCommand(command StartTaskCommand) StartTaskCommand {
command.UserID = strings.TrimSpace(command.UserID)
command.DeviceID = strings.TrimSpace(command.DeviceID)
command.TaskID = strings.TrimSpace(command.TaskID)
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
return command
}
func lifecycleWriteFields(
userID string,
deviceID string,
idempotencyKey string,
claimToken string,
) map[string]string {
fields := lifecycleIdentityFields(userID, deviceID)
validateIdempotencyField(fields, idempotencyKey)
if !validOpaqueToken(claimToken) {
fields["claim_token"] = "must be a 256-bit Raw URL value"
}
return fields
}
func lifecycleClaimFields(
userID string,
deviceID string,
taskID string,
claimGeneration int64,
claimToken string,
) map[string]string {
fields := lifecycleIdentityFields(userID, deviceID)
if !isUUID(taskID) {
fields["task_id"] = "must be a UUID"
}
if claimGeneration < 1 {
fields["claim_generation"] = "must be positive"
}
if !validOpaqueToken(claimToken) {
fields["claim_token"] = "must be a 256-bit Raw URL value"
}
return fields
}
func lifecycleTransitionFields(
userID string,
deviceID string,
taskID string,
claimGeneration int64,
claimToken string,
expectedVersion int64,
idempotencyKey string,
) map[string]string {
fields := lifecycleClaimFields(
userID,
deviceID,
taskID,
claimGeneration,
claimToken,
)
if expectedVersion < 1 {
fields["expected_version"] = "must be positive"
}
validateIdempotencyField(fields, idempotencyKey)
return fields
}
func lifecycleIdentityFields(
userID string,
deviceID string,
) map[string]string {
fields := make(map[string]string)
if !isUUID(userID) {
fields["user_id"] = "must be a UUID"
}
if !isUUID(deviceID) {
fields["device_id"] = "must be a UUID"
}
return fields
}
func validateIdempotencyField(
fields map[string]string,
value string,
) {
if value == "" ||
len([]byte(value)) > maxIdempotencyKeyBytes ||
!isPrintableASCII(value) {
fields["idempotency_key"] = "invalid"
}
}
func validateVersionField(
fields map[string]string,
name string,
value string,
) {
if value == "" {
fields[name] = "required"
} else if !utf8.ValidString(value) ||
len([]byte(value)) > domain.MaxVersionBytes {
fields[name] = "must be valid UTF-8 up to 128 bytes"
}
}
func validLifecycleStep(value string) bool {
if value == "" || len([]byte(value)) > maxLifecycleStepBytes {
return false
}
for _, char := range value {
if (char < 'A' || char > 'Z') &&
(char < '0' || char > '9') &&
char != '_' {
return false
}
}
return true
}
func lifecycleRequestHash(value any) (string, error) {
encoded, err := json.Marshal(value)
if err != nil {
return "", err
}
sum := sha256.Sum256(encoded)
return hex.EncodeToString(sum[:]), nil
}
func (service *LifecycleService) newLifecycleEvent(
userID string,
deviceID string,
taskID string,
eventType string,
message string,
) (domain.TaskEvent, error) {
id, err := service.ids.NewID()
if err != nil {
return domain.TaskEvent{}, internalLifecycleFailure(err)
}
actorUserID := userID
actorDeviceID := deviceID
return domain.TaskEvent{
ID: id,
TaskID: taskID,
ActorUserID: &actorUserID,
ActorDeviceID: &actorDeviceID,
Type: eventType,
Message: message,
OccurredAt: service.clock.Now().UTC(),
}, nil
}
func sameOptionalID(left, right *string) bool {
if left == nil || strings.TrimSpace(*left) == "" {
return right == nil
}
return right != nil && strings.TrimSpace(*left) == *right
}
func internalLifecycleFailure(err error) error {
return newError(
ErrorKindInternal,
"INTERNAL_ERROR",
"internal server error",
err,
)
}
@@ -0,0 +1,396 @@
package usecase
import (
"context"
"testing"
"time"
"cmroubao/backend-api/internal/domain"
)
const (
lifecycleUserID = "00000000-0000-4000-8000-000000000091"
lifecycleDeviceID = "00000000-0000-4000-8000-000000000092"
lifecycleTaskID = "00000000-0000-4000-8000-000000000093"
lifecycleExecID = "00000000-0000-4000-8000-000000000094"
)
func TestLifecycleServiceHeartbeatAndClaimUseServerIdentityAndTime(
t *testing.T,
) {
repository := &fakeLifecycleRepository{
heartbeatRecord: DeviceHeartbeatRecord{
Device: domain.Device{ID: lifecycleDeviceID},
},
claimResult: ClaimNextRepositoryResult{
Task: &domain.PurchaseTask{ID: lifecycleTaskID},
},
}
service := mustLifecycleService(t, repository)
clientActive := lifecycleTaskID
heartbeat, err := service.HeartbeatDevice(
context.Background(),
DeviceHeartbeatCommand{
UserID: lifecycleUserID,
DeviceID: lifecycleDeviceID,
AppVersion: "0.1.0",
AndroidVersion: "16",
PDDVersion: "8.17.0",
AccessibilityEnabled: true,
PDDInstalled: true,
ClientActiveTaskID: &clientActive,
},
)
if err != nil {
t.Fatalf("HeartbeatDevice() error = %v", err)
}
if heartbeat.ClientStateMatches ||
!repository.heartbeatUpdate.ReportedAt.Equal(fakeClock{}.Now()) {
t.Fatalf("heartbeat result/update = %+v / %+v", heartbeat, repository.heartbeatUpdate)
}
rawToken := validTestToken(30)
claim, err := service.ClaimNext(
context.Background(),
ClaimNextCommand{
UserID: lifecycleUserID,
DeviceID: lifecycleDeviceID,
IdempotencyKey: "claim-1",
ClaimToken: rawToken,
},
)
if err != nil {
t.Fatalf("ClaimNext() error = %v", err)
}
request := repository.claimRequest
if claim.Task == nil || claim.Task.ID != lifecycleTaskID ||
request.ClaimTokenHash == rawToken ||
request.ClaimTokenHash != hashSecret(rawToken) ||
len(request.RequestHash) != 64 ||
request.ExpiresAt.Sub(request.Now) != 10*time.Minute ||
request.Now.Sub(request.ReadinessAfter) != 2*time.Minute {
t.Fatalf("claim/request = %+v / %+v", claim, request)
}
if request.Event.ActorUserID == nil ||
*request.Event.ActorUserID != lifecycleUserID ||
request.Event.ActorDeviceID == nil ||
*request.Event.ActorDeviceID != lifecycleDeviceID {
t.Fatalf("claim event = %+v", request.Event)
}
}
func TestLifecycleServiceTransitionsHashClaimAndCreateExecution(
t *testing.T,
) {
repository := &fakeLifecycleRepository{
startResult: StartTaskRepositoryResult{
Task: domain.PurchaseTask{
ID: lifecycleTaskID,
Status: domain.TaskStatusRunning,
Version: 3,
ClaimGeneration: 1,
},
Execution: domain.TaskExecution{ID: lifecycleExecID},
},
heartbeatTaskResult: TaskHeartbeatRepositoryResult{
Task: domain.PurchaseTask{
ID: lifecycleTaskID,
Status: domain.TaskStatusRunning,
Version: 4,
ClaimGeneration: 1,
},
Execution: domain.TaskExecution{ID: lifecycleExecID},
CancelRequested: true,
},
releaseTask: domain.PurchaseTask{
ID: lifecycleTaskID,
Status: domain.TaskStatusPending,
},
cancelTask: domain.PurchaseTask{
ID: lifecycleTaskID,
Status: domain.TaskStatusCanceled,
},
}
service := mustLifecycleService(t, repository)
token := validTestToken(40)
start, err := service.StartTask(
context.Background(),
StartTaskCommand{
UserID: lifecycleUserID,
DeviceID: lifecycleDeviceID,
TaskID: lifecycleTaskID,
ClaimGeneration: 1,
ClaimToken: token,
ExpectedVersion: 2,
IdempotencyKey: "start-1",
},
)
if err != nil {
t.Fatalf("StartTask() error = %v", err)
}
startRequest := repository.startRequest
if start.Execution.ID != lifecycleExecID ||
startRequest.ClaimTokenHash != hashSecret(token) ||
startRequest.Execution.ID == "" ||
startRequest.Execution.CurrentStep != "PREFLIGHT" ||
startRequest.ExpiresAt.Sub(startRequest.Now) != 90*time.Second ||
startRequest.Event.Type != "TASK_STARTED" {
t.Fatalf("start/request = %+v / %+v", start, startRequest)
}
heartbeat, err := service.HeartbeatTask(
context.Background(),
TaskHeartbeatCommand{
UserID: lifecycleUserID,
DeviceID: lifecycleDeviceID,
TaskID: lifecycleTaskID,
ExecutionID: lifecycleExecID,
ClaimGeneration: 1,
ClaimToken: token,
Step: "SCAN_RESULTS",
},
)
if err != nil {
t.Fatalf("HeartbeatTask() error = %v", err)
}
if !heartbeat.CancelRequested ||
repository.taskHeartbeatRequest.MinimumExpiry.Sub(
repository.taskHeartbeatRequest.Now,
) != 90*time.Second {
t.Fatalf("heartbeat/request = %+v / %+v", heartbeat, repository.taskHeartbeatRequest)
}
release, err := service.ReleaseTask(
context.Background(),
ReleaseTaskCommand{
UserID: lifecycleUserID,
DeviceID: lifecycleDeviceID,
TaskID: lifecycleTaskID,
ClaimGeneration: 1,
ClaimToken: token,
ExpectedVersion: 2,
IdempotencyKey: "release-1",
},
)
if err != nil || release.Task.Status != domain.TaskStatusPending {
t.Fatalf("ReleaseTask() = %+v, error = %v", release, err)
}
if repository.releaseRequest.Event.Type != "TASK_RELEASED" {
t.Fatalf("release event = %+v", repository.releaseRequest.Event)
}
acknowledged, err := service.AcknowledgeCancellation(
context.Background(),
AcknowledgeCancellationCommand{
UserID: lifecycleUserID,
DeviceID: lifecycleDeviceID,
TaskID: lifecycleTaskID,
ExecutionID: lifecycleExecID,
ClaimGeneration: 1,
ClaimToken: token,
ExpectedVersion: 4,
IdempotencyKey: "cancel-ack-1",
},
)
if err != nil ||
acknowledged.Task.Status != domain.TaskStatusCanceled {
t.Fatalf(
"AcknowledgeCancellation() = %+v, error = %v",
acknowledged,
err,
)
}
if repository.cancelRequest.Event.Type != "TASK_CANCELED" {
t.Fatalf("cancel event = %+v", repository.cancelRequest.Event)
}
}
func TestLifecycleServiceRejectsMalformedClaimsAndMapsConflicts(
t *testing.T,
) {
repository := &fakeLifecycleRepository{
claimErr: ErrDeviceNotReady,
}
service := mustLifecycleService(t, repository)
_, err := service.ClaimNext(
context.Background(),
ClaimNextCommand{
UserID: lifecycleUserID,
DeviceID: lifecycleDeviceID,
IdempotencyKey: "claim-1",
ClaimToken: validTestToken(50),
},
)
assertUsecaseError(t, err, ErrorKindConflict, "DEVICE_NOT_READY")
_, err = service.ClaimNext(
context.Background(),
ClaimNextCommand{
UserID: lifecycleUserID,
DeviceID: lifecycleDeviceID,
IdempotencyKey: "claim-2",
ClaimToken: "not-a-token",
},
)
assertUsecaseError(t, err, ErrorKindInvalid, "TASK_CLAIM_INVALID")
_, err = service.HeartbeatTask(
context.Background(),
TaskHeartbeatCommand{
UserID: lifecycleUserID,
DeviceID: lifecycleDeviceID,
TaskID: lifecycleTaskID,
ExecutionID: lifecycleExecID,
ClaimGeneration: 1,
ClaimToken: validTestToken(51),
Step: "not valid",
},
)
assertUsecaseError(t, err, ErrorKindInvalid, "TASK_HEARTBEAT_INVALID")
}
func TestLifecycleServiceAuthorizesReferenceImageWithHashedClaim(
t *testing.T,
) {
task := domain.PurchaseTask{
ID: lifecycleTaskID,
ImageAssetID: "00000000-0000-4000-8000-000000000095",
}
repository := &fakeLifecycleRepository{claimedTask: task}
service := mustLifecycleService(t, repository)
rawToken := validTestToken(36)
result, err := service.AuthorizeReferenceImage(
context.Background(),
ReferenceImageCommand{
UserID: lifecycleUserID,
DeviceID: lifecycleDeviceID,
TaskID: lifecycleTaskID,
ClaimGeneration: 2,
ClaimToken: rawToken,
},
)
if err != nil || result.ImageAssetID != task.ImageAssetID {
t.Fatalf(
"AuthorizeReferenceImage() = %+v, error = %v",
result,
err,
)
}
if repository.claimedTaskRequest.ClaimTokenHash !=
hashSecret(rawToken) ||
repository.claimedTaskRequest.ClaimGeneration != 2 ||
!repository.claimedTaskRequest.Now.Equal(fakeClock{}.Now()) {
t.Fatalf(
"claimed task request = %+v",
repository.claimedTaskRequest,
)
}
}
type fakeLifecycleRepository struct {
heartbeatUpdate DeviceHeartbeatUpdate
heartbeatRecord DeviceHeartbeatRecord
heartbeatErr error
claimRequest ClaimNextRepositoryRequest
claimResult ClaimNextRepositoryResult
claimErr error
startRequest StartTaskRepositoryRequest
startResult StartTaskRepositoryResult
startErr error
taskHeartbeatRequest TaskHeartbeatRepositoryRequest
heartbeatTaskResult TaskHeartbeatRepositoryResult
taskHeartbeatErr error
claimedTaskRequest TaskClaimRepositoryRequest
claimedTask domain.PurchaseTask
claimedTaskErr error
releaseRequest ReleaseTaskRepositoryRequest
releaseTask domain.PurchaseTask
releaseReplayed bool
releaseErr error
cancelRequest CancelAcknowledgementRepositoryRequest
cancelTask domain.PurchaseTask
cancelReplayed bool
cancelErr error
}
func (repository *fakeLifecycleRepository) RecordDeviceHeartbeat(
_ context.Context,
update DeviceHeartbeatUpdate,
) (DeviceHeartbeatRecord, error) {
repository.heartbeatUpdate = update
return repository.heartbeatRecord, repository.heartbeatErr
}
func (repository *fakeLifecycleRepository) ClaimNext(
_ context.Context,
request ClaimNextRepositoryRequest,
) (ClaimNextRepositoryResult, error) {
repository.claimRequest = request
return repository.claimResult, repository.claimErr
}
func (repository *fakeLifecycleRepository) StartTask(
_ context.Context,
request StartTaskRepositoryRequest,
) (StartTaskRepositoryResult, error) {
repository.startRequest = request
return repository.startResult, repository.startErr
}
func (repository *fakeLifecycleRepository) HeartbeatTask(
_ context.Context,
request TaskHeartbeatRepositoryRequest,
) (TaskHeartbeatRepositoryResult, error) {
repository.taskHeartbeatRequest = request
return repository.heartbeatTaskResult, repository.taskHeartbeatErr
}
func (repository *fakeLifecycleRepository) GetActiveClaimTask(
_ context.Context,
request TaskClaimRepositoryRequest,
) (domain.PurchaseTask, error) {
repository.claimedTaskRequest = request
return repository.claimedTask, repository.claimedTaskErr
}
func (repository *fakeLifecycleRepository) ReleaseTask(
_ context.Context,
request ReleaseTaskRepositoryRequest,
) (domain.PurchaseTask, bool, error) {
repository.releaseRequest = request
return repository.releaseTask,
repository.releaseReplayed,
repository.releaseErr
}
func (repository *fakeLifecycleRepository) AcknowledgeTaskCancellation(
_ context.Context,
request CancelAcknowledgementRepositoryRequest,
) (domain.PurchaseTask, bool, error) {
repository.cancelRequest = request
return repository.cancelTask,
repository.cancelReplayed,
repository.cancelErr
}
func mustLifecycleService(
t *testing.T,
repository LifecycleRepository,
) *LifecycleService {
t.Helper()
service, err := NewLifecycleService(
repository,
fakeClock{},
&sequenceIDs{},
10*time.Minute,
90*time.Second,
2*time.Minute,
)
if err != nil {
t.Fatalf("NewLifecycleService() error = %v", err)
}
return service
}
var _ LifecycleRepository = (*fakeLifecycleRepository)(nil)
+1 -1
View File
@@ -82,7 +82,7 @@ type TaskRepository interface {
string,
string,
) (domain.TaskDetail, error)
CancelPendingTask(
CancelTask(
context.Context,
string,
string,
+1 -1
View File
@@ -378,7 +378,7 @@ func (s *TaskService) Cancel(
Message: "task canceled",
OccurredAt: now,
}
task, err := s.repository.CancelPendingTask(
task, err := s.repository.CancelTask(
ctx,
command.CreatorSubject,
command.TaskID,
@@ -203,7 +203,7 @@ func (repository *fakeTaskRepository) GetTaskDetail(
return domain.TaskDetail{}, errors.New("not implemented")
}
func (repository *fakeTaskRepository) CancelPendingTask(
func (repository *fakeTaskRepository) CancelTask(
_ context.Context,
_ string,
_ string,
@@ -0,0 +1,289 @@
-- +goose Up
ALTER TABLE devices
ADD COLUMN accessibility_enabled INTEGER NOT NULL DEFAULT 0
CHECK (accessibility_enabled IN (0, 1));
ALTER TABLE devices
ADD COLUMN pdd_installed INTEGER NOT NULL DEFAULT 0
CHECK (pdd_installed IN (0, 1));
ALTER TABLE devices
ADD COLUMN readiness_reported_at TEXT;
ALTER TABLE purchase_tasks
ADD COLUMN claimed_by_user_id TEXT
REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT;
ALTER TABLE purchase_tasks
ADD COLUMN claimed_by_device_id TEXT
REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT;
ALTER TABLE purchase_tasks
ADD COLUMN claim_generation INTEGER NOT NULL DEFAULT 0
CHECK (claim_generation >= 0);
ALTER TABLE purchase_tasks
ADD COLUMN claim_token_hash TEXT
CHECK (
claim_token_hash IS NULL
OR (
length(claim_token_hash) = 64
AND claim_token_hash NOT GLOB '*[^0-9a-f]*'
)
);
ALTER TABLE purchase_tasks
ADD COLUMN claim_issued_at TEXT;
ALTER TABLE purchase_tasks
ADD COLUMN claim_expires_at TEXT;
ALTER TABLE purchase_tasks
ADD COLUMN cancel_requested_at TEXT;
ALTER TABLE purchase_tasks
ADD COLUMN cancel_requested_by_user_id TEXT
REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT;
CREATE UNIQUE INDEX purchase_tasks_active_device_idx
ON purchase_tasks (claimed_by_device_id)
WHERE claimed_by_device_id IS NOT NULL
AND status IN ('CLAIMED', 'RUNNING', 'WAITING_CONFIRMATION');
CREATE TABLE task_executions (
id TEXT PRIMARY KEY NOT NULL
CHECK (length(id) = 36),
task_id TEXT NOT NULL
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
attempt_no INTEGER NOT NULL
CHECK (attempt_no > 0),
claim_generation INTEGER NOT NULL
CHECK (claim_generation > 0),
user_id TEXT NOT NULL
REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
device_id TEXT NOT NULL
REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
current_step TEXT NOT NULL
CHECK (
length(trim(current_step)) > 0
AND length(CAST(current_step AS BLOB)) <= 64
),
last_heartbeat_at TEXT NOT NULL,
order_submitted INTEGER NOT NULL DEFAULT 0
CHECK (order_submitted = 0),
started_at TEXT NOT NULL,
finished_at TEXT,
UNIQUE (task_id, attempt_no),
UNIQUE (task_id, claim_generation)
);
CREATE UNIQUE INDEX task_executions_active_task_idx
ON task_executions (task_id)
WHERE finished_at IS NULL;
CREATE INDEX task_executions_device_heartbeat_idx
ON task_executions (device_id, last_heartbeat_at DESC);
CREATE TABLE lifecycle_requests (
user_id TEXT NOT NULL
REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
device_id TEXT NOT NULL
REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
operation TEXT NOT NULL
CHECK (
operation IN ('CLAIM_NEXT', 'START', 'RELEASE', 'CANCEL_ACK')
),
idempotency_key TEXT NOT NULL
CHECK (
length(trim(idempotency_key)) > 0
AND length(CAST(idempotency_key AS BLOB)) <= 128
),
request_sha256 TEXT NOT NULL
CHECK (
length(request_sha256) = 64
AND request_sha256 NOT GLOB '*[^0-9a-f]*'
),
result_kind TEXT NOT NULL
CHECK (result_kind IN ('TASK', 'NO_TASK', 'EXECUTION')),
task_id TEXT
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
claim_generation INTEGER
CHECK (claim_generation IS NULL OR claim_generation > 0),
execution_id TEXT
REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
created_at TEXT NOT NULL,
PRIMARY KEY (user_id, device_id, operation, idempotency_key),
CHECK (
(
operation = 'CLAIM_NEXT'
AND result_kind = 'NO_TASK'
AND task_id IS NULL
AND claim_generation IS NULL
AND execution_id IS NULL
)
OR (
operation = 'CLAIM_NEXT'
AND result_kind = 'TASK'
AND task_id IS NOT NULL
AND claim_generation IS NOT NULL
AND execution_id IS NULL
)
OR (
operation = 'START'
AND result_kind = 'EXECUTION'
AND task_id IS NOT NULL
AND claim_generation IS NOT NULL
AND execution_id IS NOT NULL
)
OR (
operation = 'RELEASE'
AND result_kind = 'TASK'
AND task_id IS NOT NULL
AND claim_generation IS NOT NULL
AND execution_id IS NULL
)
OR (
operation = 'CANCEL_ACK'
AND result_kind = 'TASK'
AND task_id IS NOT NULL
AND claim_generation IS NOT NULL
AND execution_id IS NOT NULL
)
)
);
ALTER TABLE task_events RENAME TO task_events_v3;
CREATE TABLE task_events (
id TEXT PRIMARY KEY NOT NULL
CHECK (length(id) = 36),
task_id TEXT NOT NULL
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
event_type TEXT NOT NULL
CHECK (
event_type IN (
'TASK_CREATED',
'TASK_CLAIMED',
'TASK_RECLAIMED',
'TASK_RELEASED',
'TASK_STARTED',
'TASK_CANCEL_REQUESTED',
'TASK_CANCELED'
)
),
message TEXT NOT NULL,
occurred_at TEXT NOT NULL,
actor_user_id TEXT
REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
actor_device_id TEXT
REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT
);
INSERT INTO task_events (
id,
task_id,
event_type,
message,
occurred_at,
actor_user_id,
actor_device_id
)
SELECT
id,
task_id,
event_type,
message,
occurred_at,
actor_user_id,
NULL
FROM task_events_v3;
DROP TABLE task_events_v3;
CREATE INDEX task_events_task_occurred_idx
ON task_events (task_id, occurred_at ASC, id ASC);
CREATE INDEX task_events_actor_user_idx
ON task_events (actor_user_id, occurred_at DESC, id DESC);
CREATE INDEX task_events_actor_device_idx
ON task_events (actor_device_id, occurred_at DESC, id DESC);
-- +goose Down
CREATE TEMP TABLE task_events_v4_down_guard (
allowed INTEGER NOT NULL
CHECK (allowed = 1)
);
INSERT INTO task_events_v4_down_guard (allowed)
SELECT CASE
WHEN EXISTS (
SELECT 1
FROM task_events
WHERE event_type NOT IN ('TASK_CREATED', 'TASK_CANCELED')
OR actor_device_id IS NOT NULL
)
THEN 0
ELSE 1
END;
DROP TABLE task_events_v4_down_guard;
ALTER TABLE task_events RENAME TO task_events_v4;
CREATE TABLE task_events (
id TEXT PRIMARY KEY NOT NULL
CHECK (length(id) = 36),
task_id TEXT NOT NULL
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
event_type TEXT NOT NULL
CHECK (event_type IN ('TASK_CREATED', 'TASK_CANCELED')),
message TEXT NOT NULL,
occurred_at TEXT NOT NULL,
actor_user_id TEXT
REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT
);
INSERT INTO task_events (
id,
task_id,
event_type,
message,
occurred_at,
actor_user_id
)
SELECT
id,
task_id,
event_type,
message,
occurred_at,
actor_user_id
FROM task_events_v4;
DROP TABLE task_events_v4;
CREATE INDEX task_events_task_occurred_idx
ON task_events (task_id, occurred_at ASC, id ASC);
CREATE INDEX task_events_actor_user_idx
ON task_events (actor_user_id, occurred_at DESC, id DESC);
DROP TABLE lifecycle_requests;
DROP INDEX task_executions_device_heartbeat_idx;
DROP INDEX task_executions_active_task_idx;
DROP TABLE task_executions;
DROP INDEX purchase_tasks_active_device_idx;
ALTER TABLE purchase_tasks DROP COLUMN cancel_requested_by_user_id;
ALTER TABLE purchase_tasks DROP COLUMN cancel_requested_at;
ALTER TABLE purchase_tasks DROP COLUMN claim_expires_at;
ALTER TABLE purchase_tasks DROP COLUMN claim_issued_at;
ALTER TABLE purchase_tasks DROP COLUMN claim_token_hash;
ALTER TABLE purchase_tasks DROP COLUMN claim_generation;
ALTER TABLE purchase_tasks DROP COLUMN claimed_by_device_id;
ALTER TABLE purchase_tasks DROP COLUMN claimed_by_user_id;
ALTER TABLE devices DROP COLUMN readiness_reported_at;
ALTER TABLE devices DROP COLUMN pdd_installed;
ALTER TABLE devices DROP COLUMN accessibility_enabled;
+3 -2
View File
@@ -53,8 +53,9 @@
当前已完成 Phase 0 和 Phase 1:Android 可运行、设备就绪、workflow、私有样本导入、
动态词搜索、最多 5 个候选截图采集、结构化需求提取、候选评估和人工确认停止点均已
验证。T-201 后端骨架、T-202 P0 原型、T-203 任务 API/管理 Web 和 T-204 最小鉴权
均已完成。下一步按编号开始 T-205,实现 App 原子领取、租约和任务状态机。
验证。T-201 后端骨架、T-202 P0 原型、T-203 任务 API/管理 Web、T-204 最小鉴权
和 T-205 原子领取/租约状态机均已完成。下一步按编号开始 T-206,把 Android
`HttpTaskSource`、前台服务和已确认的任务页面接到设备 API。
严格按以下顺序推进:
+16 -7
View File
@@ -15,7 +15,7 @@
## 二、用户角色
- **采购管理员**:创建、查看、取消尚未执行的任务并查看执行结果。
- **采购管理员**:创建、查看、取消未执行任务,或请求执行中任务安全停止,并查看结果。
- **采购执行员**:在 App 上检查设备状态、手动领取、执行和人工确认任务。
- **设备身份**:代表一台获得授权的 Android 设备领取任务和上报状态。
- **系统管理员/审核员**:目标架构角色,MVP 不提供完整管理界面。
@@ -38,7 +38,7 @@
| 功能 | 说明 | 阶段 |
| --- | --- | --- |
| 完整 RBAC | 采购管理员、执行员、审核员和系统管理员的细粒度权限。 | V2 |
| 多设备调度 | 设备心跳、任务队列、租约回收和容量调度。 | V2 |
| 多设备容量调度 | 在当前原子领取/租约基础上增加优先级、容量、运营监控和跨实例调度。 | V2 |
| 后台通知 | WebSocket/厂商推送只通知有任务,App 仍通过 claim 领取。 | V2 |
| 订单提交审批 | 在金额、店铺和品类规则内,经人工审批后允许提交订单。 | V2,需单独安全评审 |
| 多平台比价 | 淘宝、1688、京东等平台。 | V3 |
@@ -50,10 +50,11 @@
2. 数量必须是正整数;最高商品总预算如填写,必须大于零。总预算表示当前任务全部
数量的商品金额上限,不包含尚无法可靠确认的运费、优惠或支付金额。
3. SKU、数量和最高商品总预算以管理员输入为准,模型不得更改。
4. 一台设备同一时间最多有一条 `CLAIMED` 或 `RUNNING` 任务。
4. 一台设备同一时间最多有一条未过期 `CLAIMED` 或任意
`RUNNING/WAITING_CONFIRMATION` 任务。
5. 一条任务同一时间只能被一台设备持有;重复点击不能产生重复领取。
6. App 未就绪时不能开始:无障碍未授权、拼多多未安装、设备离线或已有运行任务都
必须说明原因。
6. App 未就绪时不能领取:无障碍未连接、拼多多未安装、设备 heartbeat 过期或已有
活跃任务都必须说明原因。
7. 遇到验证码、登录失效、风控提示、页面未知、预算不满足或模型低置信度时停止,
不猜测点击。
8. MVP 的“成功”表示完成验证闭环并得到人工确认的候选结果,
@@ -67,7 +68,14 @@
普通日志;禁用用户或设备后,已有凭证必须立即失效。
13. 管理登录和 App token 登录必须在高成本密码校验前按服务端观察到的来源地址限流;
超限返回通用错误和重试时间,不得泄露账号、角色或设备是否存在。
14. 管理员创建和取消任务时,任务事件必须记录真实 ADMIN actor;历史事件允许为空。
14. 管理员创建/取消和 App 状态迁移必须记录真实 user actor;设备操作还必须记录
device actor。heartbeat 不写高频事件;历史事件允许 actor 为空。
15. claim token 由 App 在请求前生成并安全保存,必须与幂等 key 独立;数据库只保存
SHA-256,API 和日志不得回显原值或 hash。
16. `CLAIMED` 租约过期后可以被原子回收;`RUNNING/WAITING_CONFIRMATION` 租约
过期后不得自动回到队列,避免失联设备与新设备重复执行。
17. 管理取消 `PENDING/CLAIMED` 可立即终态;执行中只设置取消请求,必须等 App 在
安全检查点确认停止后才能进入 `CANCELED`。
## 六、第一层本地样本约定
@@ -110,7 +118,8 @@ T-004 已固定首版规则:推荐私有目录为被 Git 忽略的 `private-fi
- F-002/US-002/IX-003:状态变化后管理页面能看到最新阶段、时间、设备、候选摘要
或结构化错误;无权限用户不可访问。
- F-003/US-003/IX-005:App 点击“获取任务”后原子领取一条任务;重复点击或多请求
不得领取第二条或把同一任务分配两次。
不得领取第二条或把同一任务分配两次。T-205 已完成后端 heartbeat、原子 claim、
client-generated token 安全重放、参考图授权和租约;Android 接入属于 T-206。
- F-004/US-004/IX-006:解析结果包含搜索词、识别属性、预算、数量、置信度和警告;
原始输入保留,硬约束与输入一致。T-103 已实现版本化 schema、0.75 置信阈值、
冲突转人工以及 SKU/数量的本地确定性回填;当前样本未提供预算,因此预算保持空。
+5 -2
View File
@@ -27,7 +27,7 @@
| App 鉴权 | BUYER 密码 + 预授权设备 secret + 1 小时 opaque token | T-204 已验证 | 首次原子绑定空闲设备;数据库只存 token SHA-256,不提供自助登记/refresh。 |
| VLM 接入 | 应用内统一适配器,优先兼容 OpenAI 风格多模态接口 | 需求提取与候选评估已实现,供应商待定 | T-103/T-104 使用严格 JSON Schema、单候选单次调用和 2048 px 图片上限;GUI-Owl/MAI-UI 动作模型不具备需求提取能力。 |
| 通知 | MVP 不使用推送 | 已定 | 点击“获取任务”调用原子 claim API;V2 再评估厂商推送/WebSocket。 |
| 后端测试 | 标准库 `testing` + `httptest` | T-204 已验证 | 当前 142 个测试覆盖配置、迁移、图片限制、任务事务、bcrypt、会话/令牌、设备原子绑定、登录限流、角色/CSRF 隔离、HTTP 生命周期和安全响应。 |
| 后端测试 | 标准库 `testing` + `httptest` | T-205 已验证 | 当前 192 个测试覆盖配置、迁移、图片限制、任务事务、鉴权隔离、设备就绪、原子领取、幂等重放、租约状态机、取消确认、跨连接与真实 TCP 并发。 |
| Android 测试 | Gradle `test` + `kotlinx-coroutines-test` 1.7.3 + 真实设备 smoke | Phase 1 探针已验证 | 166 次测试覆盖 runner、动态页面分类、受控证据、VLM schema、人工确认策略与隐私;OnePlus PKG110 上完成私有 fixture + 本机 mock 的需求提取和 5 候选评估 smoke。 |
| 部署 | 单机局域网 Go 服务;容器化后置 | MVP 已定 | Android 测试机必须能通过 HTTPS 或受控测试网络访问。 |
@@ -127,6 +127,9 @@ docs/
T-203 已建立 `domain/usecase/repository/sqlite` 和 `transport/webui`,handler 不直接
执行 SQL,Web 与 JSON API 复用同一 usecase。
T-205 已在该分层上增加独立 `LifecycleService` 与 SQLite immediate transaction,
固定 client-generated claim token、设备 readiness、CLAIMED/运行租约、execution、
安全取消和 task-scoped 参考图授权;Android HTTP TaskSource 仍由 T-206 接入。
## 构建与运行命令
@@ -137,7 +140,7 @@ T-203 已建立 `domain/usecase/repository/sqlite` 和 `transport/webui`,handl
| Android 测试任务 | `android-buyer\gradlew.bat test --no-daemon` | 已验证;当前全工程 166 次测试通过 |
| Android 安装/启动 | `$env:RUN_START_COMMAND="1"; .\init.ps1` | 已在 Android 16 真机验证 |
| 后端依赖 | `$env:GOTOOLCHAIN="local"; go mod download`(在 `backend-api/`) | 已验证 |
| 后端测试 | `$env:GOTOOLCHAIN="local"; go test ./...; go vet ./...` | 已验证;99 个测试 |
| 后端测试 | `$env:GOTOOLCHAIN="local"; go test ./...; go test -race ./...; go vet ./...` | 已验证;192 个测试,全包 race 通过 |
| 后端构建 | `go build -o bin/cmroubao-api.exe ./cmd/api` 与 `./cmd/migrate` | 已验证 |
| 后端启动 | `go run ./cmd/api` | 已完成本机 HTTP smoke |
| 数据迁移 | `go run ./cmd/migrate up` | 已完成 `status/up/up/down/up` smoke |
+50 -13
View File
@@ -292,10 +292,16 @@ CLAIMED/RUNNING/WAITING_CONFIRMATION
规则:
- `SUCCEEDED` 是验证结果成功,不代表已下单;`order_submitted=false`。
- `CLAIMED` 的默认租约计划为 10 分钟,运行时每 30 秒心跳续租;数值进入配置。
- `CLAIMED` 默认租约为 10 分钟;`RUNNING/WAITING_CONFIRMATION` 默认租约为
90 秒,App 计划每 30 秒 heartbeat 续租。三个时长均由有上下界的环境变量配置。
- 只有当前设备和有效 claim token 能启动、续租、上报或结束任务。
- 终态不可回退;重试创建新的 execution attempt,不篡改历史证据。
- 取消正在自动化的任务时,App 应在下一个安全检查点停止。
- App 在 claim 前生成并安全保存 256 bit Raw URL token;后端只存 SHA-256,
token 与 `Idempotency-Key` 相互独立。
- 过期 `CLAIMED` 可以原子回收;过期 `RUNNING/WAITING_CONFIRMATION` 保持原状态,
不自动回队列。
- 管理取消 `PENDING/CLAIMED` 立即终态;执行中只设置停止请求,App 在下一个安全
检查点停止并调用 `cancel-ack` 后才进入 `CANCELED`。
### 4.2 Android 工作流状态
@@ -348,6 +354,9 @@ IDLE
| `android_version` | nullable | Android 系统版本 |
| `pdd_version` | nullable | 已验证拼多多版本 |
| `last_seen_at` | nullable | 最近心跳 |
| `readiness_reported_at` | nullable | 最近一次就绪上报的服务端时间 |
| `accessibility_enabled` | NOT NULL | 无障碍连接就绪位 |
| `pdd_installed` | NOT NULL | 拼多多安装就绪位 |
| `is_enabled` | NOT NULL | 后端开关 |
设备必须先由本地管理命令预授权。首次 BUYER 联合登录可以把 `bound_user_id` 为空的
@@ -378,17 +387,28 @@ IDLE
| `quantity` | `> 0` | 权威数量 |
| `max_budget` | `> 0`, nullable | 全部数量的权威最高商品总预算,币种为 CNY |
| `status` | NOT NULL | 任务状态枚举 |
| `created_by` | FK | 创建人 |
| `claimed_by_user_id` | FK, nullable | 当前采购员 |
| `claimed_by_device_id` | FK, nullable | 当前设备 |
| `claim_generation` | `>= 0` | 每次领取递增,释放后保留用于审计 |
| `claim_token_hash` | nullable | 64 位小写 SHA-256;永不保存原 token |
| `claim_issued_at` | nullable | 当前 claim 签发时间 |
| `claim_expires_at` | nullable | 租约到期时间 |
| `cancel_reason` | nullable | 管理取消原因 |
| `cancel_requested_at` | nullable | 执行中停止请求时间 |
| `cancel_requested_by_user_id` | FK, nullable | 请求停止的 ADMIN |
| `canceled_at` | nullable | 取消终态时间 |
| `version` | NOT NULL | 乐观锁/状态并发控制 |
| `created_at/updated_at` | NOT NULL | 审计时间 |
### `task_events`
- 事件只追加;T-204 已实现 `TASK_CREATED`、`TASK_CANCELED`。
- 事件只追加;T-205 已实现 `TASK_CREATED`、`TASK_CLAIMED`、
`TASK_RECLAIMED`、`TASK_RELEASED`、`TASK_STARTED`、
`TASK_CANCEL_REQUESTED`、`TASK_CANCELED`。
- `actor_user_id` 是指向 `users` 的可空外键。新管理操作必须写入真实 ADMIN,
T-203 历史事件保持为空。
- `actor_device_id` 是指向 `devices` 的可空外键;App 状态迁移同时记录用户和设备。
- 设备/任务 heartbeat 不写事件,避免高频审计膨胀。
- 管理任务详情 API 可返回 actor;密码、session、设备 secret 和 access token 永不
进入事件。
@@ -399,17 +419,28 @@ IDLE
| `id` | PK | 一次执行尝试 |
| `task_id` | FK | 所属任务 |
| `attempt_no` | UNIQUE(task, no) | 尝试序号 |
| `claim_generation` | UNIQUE(task, generation) | 对应的 claim 代次 |
| `device_id/user_id` | FK | 执行设备和人员 |
| `extracted_requirements` | JSON | 已校验的模型结果 |
| `candidate_result` | JSON, nullable | 候选及匹配理由 |
| `outcome` | nullable | `CANDIDATE_ACCEPTED`、`CANDIDATE_REJECTED`、`NO_MATCH` 或 `MANUAL_REQUIRED` |
| `current_step` | 1-64 bytes | 最近 heartbeat 的执行步骤 |
| `last_heartbeat_at` | NOT NULL | 最近运行 heartbeat |
| `order_submitted` | NOT NULL, false | MVP 数据库约束必须为 false |
| `error_code/error_message` | nullable | 结构化失败 |
| `started_at/finished_at` | nullable | 执行耗时 |
| `started_at/finished_at` | started 必填 | 执行耗时和安全结束 |
### `execution_events` 与 `assets`
候选、模型派生结果、outcome、结构化失败和 execution evidence 属于 T-207,不在
T-205 的最小 execution 表中提前伪造。
- `execution_events` 只追加,记录 step、事件类型、可读消息和时间;不保存密码或 token。
### `lifecycle_requests`
- 主键为 `user_id + device_id + operation + idempotency_key`。
- operation 为 `CLAIM_NEXT`、`START`、`RELEASE`、`CANCEL_ACK`;保存请求 SHA-256
与 task/generation/execution 结果引用。
- `NO_TASK` 也是稳定结果;同 key 重放不会因后来新增任务而改变。
- claim 原 token 不进入该表,请求 hash 只包含 token hash。
### `assets` 与后续 `execution_events`
- T-207 的 `execution_events` 只追加,记录 step、事件类型、可读消息和时间;不保存
密码或 token。
- `assets` 保存文件相对路径、媒体类型、大小、哈希、创建者和保留时间。
- 原图和截图必须通过鉴权接口读取,文件名不能直接作为公开 URL。
- 任务参考图上传可接受 JPEG/PNG/WebP,但后端必须先真实解码、限制字节与像素,再
@@ -419,10 +450,16 @@ IDLE
- 合约以 [`api.md`](api.md) 为准。
- `claim-next` 必须由 usecase 调用 repository,在一个数据库事务内完成
“选取 + 校验设备空闲 + 更新状态”。
- 创建、领取和完成接口支持 `Idempotency-Key`。
“幂等检查 + 就绪/单活校验 + FIFO 选取/过期回收 + 更新 + 事件”。
- 当前设备若有自己的过期 `CLAIMED`,优先原子回收该任务;没有时才在全局候选中
按 `created_at ASC, id ASC` 选择。该例外保持设备唯一归属并避免静默清理审计状态。
- SQLite DSN 使用 `_txlock=immediate`、WAL 和 busy timeout;跨两个独立数据库连接
的并发测试必须精确得到一个领取者。
- claim/start/release/cancel-ack 支持 `Idempotency-Key`;heartbeat 不需要幂等表。
- App 对网络超时不能假定失败;必须用任务详情确认服务端最终状态。
- 后端拒绝非法状态迁移,即使客户端 UI 隐藏了对应按钮。
- App 参考图通过 task-scoped URL 读取;每次校验当前 user/device/generation/token/
租约,不允许用可猜测 asset ID 越权读取其他任务。
- SQLite MVP 使用单后端进程;切换多实例前先迁移 PostgreSQL 并验证领取竞争。
- Gin handler 只做鉴权上下文、binding、调用 usecase 和响应映射。
+8 -2
View File
@@ -79,7 +79,12 @@
- 请求在边界完成类型、大小、媒体类型和业务规则校验。
- 领取任务必须使用事务;不得先 GET 再由客户端自行标记占用。
- 状态变化校验当前状态、设备归属、claim token、版本和租约。
- 创建、领取、完成和证据上传的重试路径必须幂等。
- App 必须在 claim 请求前生成并安全保存独立的 256 bit claim token 与幂等 key;
后端只存 token SHA-256,响应、事件和日志不得返回原值或 hash。
- claim/start/release/cancel-ack 和后续完成/证据上传的重试路径必须幂等;`NO_TASK`
也必须是稳定幂等结果。
- 设备/任务 heartbeat 使用服务端时钟且不写高频事件;过期 RUNNING 不得自动回到
队列。管理取消执行中任务必须等 App 安全确认,不能提前伪装为终态。
- 不向客户端返回 `password_hash`、`token_hash`、存储绝对路径或供应商密钥。
- 管理 Cookie 与 BUYER 设备 Bearer token 使用独立 middleware,不得互相替代;
每次鉴权都重新检查用户/设备启用、绑定、撤销和过期状态。
@@ -92,7 +97,8 @@
- 创建和取消任务必须从认证上下文传入真实 ADMIN actor 并写入只追加事件;共享
`local-admin` 只用于 MVP 资源可见范围,不能冒充操作者。
- 通用错误使用稳定 code 和可读 message;内部堆栈只进受控日志。
- 文件访问通过鉴权接口,防止路径遍历和猜测 URL。
- 文件访问通过鉴权接口,防止路径遍历和猜测 URL;App 参考图读取必须重新校验当前
task/user/device/generation/token/租约,不能只凭 asset UUID 授权。
- Go 命令固定 `GOTOOLCHAIN=local`;`go.mod` 不得出现更高 Go 版本或未固定的
`@latest` 依赖。
- 后端不得自动加载 `.env`、默认开启 CORS、使用包级数据库单例,或在库、handler、
+5 -2
View File
@@ -72,7 +72,9 @@
1. 设备就绪且有待处理任务时,点击后只出现一条已领取任务。
2. 没有任务时显示空状态,不把空队列当作错误。
3. 无障碍缺失、拼多多不可用、设备禁用或已有活跃任务时,说明原因并禁止领取。
4. 网络超时或重复点击后,刷新服务端状态不会产生重复领取。
4. 网络超时或重复点击后,复用请求前已保存的 claim token 和幂等 key 可确认同一
领取结果,不产生重复领取。
5. 只有持有当前 claim 的用户和设备能读取该任务的参考图、开始或续租。
## US-004 自动检索并得到候选
@@ -131,7 +133,8 @@
1. 验证码、风控、登录失效、未知页面和安全边界分别产生可区分错误。
2. 网络中断时保留本地待上传证据,恢复后用幂等方式补报。
3. 用户取消时工作流在安全检查点停止,任务不会继续在后台点击。
3. 用户或管理员取消时,执行中任务先收到停止请求;App 在安全检查点停止并确认后
才进入取消终态,不会继续在后台点击。
4. 重新打开终态任务可以看到相同结果和证据。
## US-007 建立受控会话和设备身份
+2 -1
View File
@@ -150,7 +150,8 @@
- 无任务:显示安静的空状态和手动刷新,不循环弹错。
- 权限缺失:提供打开对应系统设置的命令。
- 已有任务:显示“继续任务”,不再领取。
- 请求超时:先查询当前设备活跃任务,再允许重试。
- 请求超时:保留请求前已安全保存的 claim token/幂等 key,先用同一请求重放并通过
heartbeat 对比服务端活跃任务,再允许生成新请求。
- 租约到期:预览页提示任务已释放,返回任务页。
**可访问性**
+87 -18
View File
@@ -254,8 +254,10 @@ T-204 已实现的 `TASK_CREATED`、`TASK_CANCELED` 事件包含可空
}
```
T-203 只实现 `PENDING -> CANCELED`;其他状态返回 `409`。执行中设置取消请求及 App
安全检查点响应属于 T-205。
`PENDING/CLAIMED` 立即进入 `CANCELED`。`RUNNING/WAITING_CONFIRMATION` 只记录
取消请求并保持原状态;重复请求不重复写事件。App 的任务 heartbeat 会返回
`cancel_requested=true`,只有 App 在安全检查点调用 `cancel-ack` 后才进入
`CANCELED` 并结束 execution。
## 设备与领取
@@ -265,8 +267,8 @@ App 空闲或运行时上报设备状态;运行时任务续租使用任务专
```json
{
"device_id": "f0c3d438-1c44-4f80-b898-c645afe7eaa8",
"app_version": "0.1.0",
"android_version": "16",
"pdd_version": "device-observed-value",
"readiness": {
"accessibility_enabled": true,
@@ -276,14 +278,20 @@ App 空闲或运行时上报设备状态;运行时任务续租使用任务专
}
```
`device_id` 可以省略;若提供,必须与 Bearer token 中的设备一致。服务端时间是唯一
租约时钟。响应返回服务端认定的 `active_task_id`、`client_state_matches`、
readiness 上报时间和 `server_time`。heartbeat 超过配置 TTL 或任一就绪位为 false
时,claim 返回 `409 DEVICE_NOT_READY`。
### `POST /api/v1/tasks/claim-next`
由用户点击触发,在单个数据库事务中领取下一条任务。必须带 `Idempotency-Key`。
由用户点击触发,在单个 SQLite immediate transaction 中领取下一条任务。App 必须
在请求前生成并安全保存相互独立的 256 bit Raw URL `X-Claim-Token` 和
`Idempotency-Key`;网络结果不确定时复用同一组值。服务端只保存 token 的
SHA-256,响应和日志都不回显原 token 或 hash。
```json
{
"device_id": "f0c3d438-1c44-4f80-b898-c645afe7eaa8"
}
{}
```
有任务时:
@@ -297,22 +305,50 @@ App 空闲或运行时上报设备状态;运行时任务续租使用任务专
"sku": "BLACK-20L",
"description": "容量约20L,外观接近参考图",
"image_asset_id": "2bbc1bf2-30f7-497e-a52b-bcb4c61d57c3",
"reference_image_url": "/api/v1/tasks/37c9c715-9b51-4ed5-984e-66dad2710c71/reference-image?claim_generation=1",
"quantity": 2,
"max_budget": "200.00"
},
"claim_token": "returned-once",
"max_budget": "200.00",
"currency": "CNY",
"version": 2,
"claim_generation": 1,
"claim_issued_at": "2026-07-25T08:30:00Z",
"claim_expires_at": "2026-07-25T08:40:00Z"
},
"replayed": false,
"server_time": "2026-07-25T08:30:00Z"
}
```
没有任务返回 `204`。设备未就绪或已有活跃任务返回 `409`。幂等重放返回同一领取
结果;claim token 的安全重放形式在实现前通过测试固定。
当前设备自己有已过期 `CLAIMED` 时优先回收该任务,避免设备唯一归属冲突;否则按
`created_at ASC, id ASC` 选择 `PENDING` 或已过期 `CLAIMED`。没有任务返回 `204`;
同 key 的无任务重放始终保持 `204`。同 key、同 token 的活跃 claim 重放返回同一
任务,即使状态已进入 `RUNNING/WAITING_CONFIRMATION`;请求不同返回
`409 IDEMPOTENCY_CONFLICT`;原 claim 已释放、取消或被其他领取回收时返回
`409 CLAIM_REPLAY_EXPIRED`。同一设备不能再领取第二条活跃任务。
### `GET /api/v1/tasks/{task_id}/reference-image`
claim 响应给出的受保护参考图地址。请求使用 BUYER Bearer token、
`X-Claim-Token` 和 URL 中的 `claim_generation`;只有当前用户、当前设备、匹配
generation/token 且租约未过期的活跃任务可以读取。成功返回匿名规范化 JPEG,
设置 `private, no-store`、`nosniff`、长度和 ETag,不返回存储路径。
### `POST /api/v1/tasks/{task_id}/start`
把当前设备持有的 `CLAIMED` 任务改为 `RUNNING` 并创建 execution。请求带
`X-Claim-Token` 和 `Idempotency-Key`。
```json
{
"claim_generation": 1,
"expected_version": 2
}
```
同 key、同请求重放返回同一 execution;错误用户/设备/token 返回 `403`,过期租约、
状态或版本冲突返回 `409`。成功响应包含更新后的 `task`、`execution`、`replayed`
和 `server_time`,运行租约使用配置的 running lease。
### `POST /api/v1/tasks/{task_id}/heartbeat`
运行时续租并返回是否请求取消:
@@ -320,21 +356,56 @@ App 空闲或运行时上报设备状态;运行时任务续租使用任务专
```json
{
"execution_id": "e3190742-a24b-441e-b5c1-c7ed10ed342f",
"step": "SCAN_RESULTS",
"client_time": "2026-07-25T08:33:30Z"
"claim_generation": 1,
"step": "SCAN_RESULTS"
}
```
```json
{
"claim_expires_at": "2026-07-25T08:43:30Z",
"cancel_requested": false
"task": {
"id": "37c9c715-9b51-4ed5-984e-66dad2710c71",
"status": "RUNNING",
"version": 4,
"claim_generation": 1,
"claim_expires_at": "2026-07-25T08:35:00Z"
},
"execution": {
"id": "e3190742-a24b-441e-b5c1-c7ed10ed342f",
"current_step": "SCAN_RESULTS",
"order_submitted": false
},
"cancel_requested": false,
"server_time": "2026-07-25T08:33:30Z"
}
```
只接受 1 至 64 字节的大写 ASCII step。heartbeat 使用服务端 UTC 更新 execution、
设备最近在线时间、任务 version 和租约,不写高频任务事件;过期运行租约拒绝续租,
任务保持原非终态且绝不回到领取队列。
### `POST /api/v1/tasks/{task_id}/release`
只允许尚未开始的 `CLAIMED` 任务释放回 `PENDING`。运行中使用取消/失败流程。
请求带 `X-Claim-Token`、`Idempotency-Key`,body 与 start 相同。成功后清除当前
用户、设备、token hash 和租约,但保留递增过的 `claim_generation` 供审计。
### `POST /api/v1/tasks/{task_id}/cancel-ack`
App 收到取消请求并在安全检查点停止后调用:
```json
{
"execution_id": "e3190742-a24b-441e-b5c1-c7ed10ed342f",
"claim_generation": 1,
"expected_version": 5
}
```
请求带 `X-Claim-Token` 和 `Idempotency-Key`。只有匹配的未结束 execution、有效
运行租约和已存在的管理取消请求可以确认;成功把任务置为 `CANCELED`、结束
execution、清除 claim 秘密并追加带用户/设备 actor 的事件。同 key 重放不产生第二
个事件。
## AI 合约
@@ -488,7 +559,5 @@ App 空闲或运行时上报设备状态;运行时任务续租使用任务专
## 待实现前固定
- 上传大小、像素和保留期限的具体数值。
- access token、管理会话和 claim lease 的最终时长。
- claim token 幂等重放与轮换细节。
- VLM `confidence` 阈值、模型和提示词版本记录格式。
- 外部管理后台的服务账号认证方式和调用频率。
+24 -8
View File
@@ -5,30 +5,36 @@
## 当前快照
- 日期:2026-07-26
- 阶段:T-204 用户与设备最小鉴权完成,下一步 T-205
- 阶段:T-205 原子领取、租约和状态机完成,下一步 T-206
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-203
均已纳入 Git 历史;T-204 与本文同次提交
均已纳入 Git 历史;T-204 已提交,T-205 与本文同次提交
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
- Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留
- 后端:Go 1.23.0 + Gin 1.11.0 + SQLite + Goose 3.26.0;已实现图片/任务业务、
SSR 管理 Web、ADMIN 会话、BUYER/预授权设备联合认证和 `authctl`
SSR 管理 Web、ADMIN/BUYER 联合认证、设备 readiness、原子 claim、租约状态机、
task-scoped 参考图和 `authctl`
- 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
- 测试:`lintDebug test assembleDebug` 成功;App 两个变体、task contract 和导入器
共 26 份报告、166 次测试,0 failure、0 error、0 skipped
- 后端测试:`GOTOOLCHAIN=local go test -count=1 ./...` 共 142 个测试通过;
- 后端测试:`GOTOOLCHAIN=local go test -count=1 ./...` 共 192 个测试通过;
全包 race、`go vet ./...`、API/migration/authctl Windows 构建和根 `init.ps1`
均通过
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、
脚本错误或外部请求,Android 可见交互控件均不小于 44px
- 管理 Web:真实 Gin/SQLite 流程已完成图片上传、任务创建、列表、详情参考图和
待领取取消;ADMIN 登录/退出和安全返回路径已接入,同三种视口无横向溢出,
可见操作控件不小于 44px
非终态取消;执行中只显示“请求安全停止”,ADMIN 登录/退出和安全返回路径已接入,
同三种视口无横向溢出,可见任务操作控件不小于 44px
- 鉴权:bcrypt 密码、8 小时管理 session、1 小时 App access token 和设备 secret
均不明文落库;设备首次绑定原子化,禁用/过期/撤销每次请求重新检查;管理/App
登录各自按来源地址执行内存有界限流,账号和设备支持 `authctl` 启停
- 生命周期:设备 heartbeat 记录三个版本、两个就绪位和服务端活跃任务;claim 使用
client-generated 256 bit token + 独立幂等 key、SQLite immediate transaction、
10 分钟 CLAIMED/90 秒运行租约和 2 分钟 readiness TTL。start/heartbeat/release/
cancel-ack、过期回收、跨连接并发唯一领取、管理安全停止与 task-scoped 参考图均
已实现;原 claim token/hash 不进入响应或日志
- Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、
用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
@@ -65,6 +71,7 @@
| `docs/tasks/T-202.md` | DONE | 生成并确认 P0 Web/App 低保真原型 |
| `docs/tasks/T-203.md` | DONE | 图片/任务 API、SQLite 业务层和 SSR 管理 Web |
| `docs/tasks/T-204.md` | DONE | 用户、管理会话和预授权设备联合身份 |
| `docs/tasks/T-205.md` | DONE | 原子 claim、租约、execution 和取消安全确认 |
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
@@ -75,9 +82,9 @@
## 任务摘要
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-204。
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-205。
- 正在进行:无。
- 下一个可领取任务:T-205 原子领取、租约和状态机。
- 下一个可领取任务:T-206 App 接入手动领取和执行进度。
## 当前可运行内容
@@ -132,6 +139,15 @@ TLS。真实 SQLite/Gin smoke 验证 migration up/down/up、账号/设备预置
验证无横向溢出,发现并修复退出路由漏装配及 T-203 旧 CSRF Cookie 路径兼容问题。
142 个 Go 测试、全包 race/vet、Windows 三入口构建与根 `init.ps1` 均通过。
2026-07-26 完成 T-205:新增设备 readiness、claim/execution/lifecycle migration,
实现 client-generated claim token 安全重放、跨连接原子领取、CLAIMED/运行租约、
start/heartbeat/release、管理取消请求、App cancel-ack 和当前 claim 参考图授权。
真实 TCP 并发测试验证同设备同时 claim 只有一个成功;迁移 CLI 完成
`status/up/up/down/up`。Playwright 连接真实服务验证管理任务详情在 1440×900、
390×844、360×800 无横向溢出、破图或取消弹窗重叠;执行中安全停止文案由 SSR
测试覆盖。192 个 Go 测试、全包 race/vet、Windows 三入口构建和根 `init.ps1`
全部通过。
## 维护规则
发生以下变化时覆盖更新本文:
+17
View File
@@ -30,6 +30,23 @@ Android 导航名称是逻辑目的地,具体 Compose/Fragment 形式待接入
| `task/{id}/result` | 任务结果 | 查看终态和同步状态 | US-002、US-006 | IX-007、IX-008 |
| `settings` | 设备设置 | 查看权限、版本、后端和模型连接状态 | US-003、US-007 | IX-004、IX-005 |
## App 后端路由
以下路由只接受有效 BUYER Bearer token,认证 principal 中的用户和设备是权威身份:
| 路由 | 职责 |
| --- | --- |
| `POST /api/v1/devices/heartbeat` | 上报版本、就绪位并核对服务端活跃任务 |
| `POST /api/v1/tasks/claim-next` | 用户点击后原子领取或重放领取结果 |
| `GET /api/v1/tasks/{id}/reference-image` | 用当前 claim 读取该任务匿名参考图 |
| `POST /api/v1/tasks/{id}/start` | `CLAIMED -> RUNNING` 并创建 execution |
| `POST /api/v1/tasks/{id}/heartbeat` | 更新 step、设备在线时间和运行租约 |
| `POST /api/v1/tasks/{id}/release` | 未开始时 `CLAIMED -> PENDING` |
| `POST /api/v1/tasks/{id}/cancel-ack` | 安全停止后确认 `CANCELED` 并结束 execution |
claim/start/release/cancel-ack 使用相应幂等规则;参考图、start、task heartbeat、
release 和 cancel-ack 都必须匹配当前 `X-Claim-Token` 与 `claim_generation`。
## 导航规则
- App 有活跃任务时启动后优先恢复该任务,不允许直接领取下一条。
+151
View File
@@ -0,0 +1,151 @@
---
id: T-205
title: 实现原子 claim、租约和状态机
phase: 2
deps:
- T-203
- T-204
status: DONE
created: 2026-07-26
context_ref: 49db5b8
work_branch: main
write_paths:
- README.md
- backend-api/**
- docs/00-ai-start-here.md
- docs/02-requirements.md
- docs/03-tech-stack.md
- docs/04-architecture.md
- docs/05-coding-rules.md
- docs/07-user-stories.md
- docs/08-interaction-checklist.md
- docs/api.md
- docs/current-state.md
- docs/routes.md
- docs/tasks/T-205.md
- progress.md
---
## 问题 / 背景
T-203 已能创建 `PENDING` 任务,T-204 已建立 BUYER + 预授权设备联合身份,但 App
还不能安全领取任务。现有正式文档只给出接口草图,未解决 claim token 只返回一次时
如何在响应丢失后安全重放,也没有固定设备就绪新鲜度、运行中租约过期、取消确认和
并发领取的精确语义。T-206 在这些合约固定前不能接入 HTTP TaskSource。
## 关联需求与交互
- 功能:F-003、F-007。
- 用户故事:US-003、US-006。
- 交互:IX-005、IX-008;本任务只实现后端,Android 页面接入属于 T-206。
- 架构/API:任务状态机、`devices/heartbeat`、`claim-next`、`start`、任务 heartbeat、
`reference-image`、`release` 和取消安全停止。
## 已定合约
1. 所有设备接口只接受有效 BUYER Bearer token;认证上下文中的 `user_id/device_id`
是权威身份。请求中的 `device_id` 必须与上下文一致,不能代表其他设备。
2. App 在发起 claim 前生成独立的 256 bit Raw URL `X-Claim-Token` 并先安全保存,
同时生成独立 `Idempotency-Key`。服务只保存 claim token 的 SHA-256,响应不回显
原值;网络结果不确定时必须复用同一组值。
3. claim 幂等记录按用户、设备、操作和 key 隔离,请求 hash 包含 claim token hash。
每次成功领取递增 `claim_generation`。同 key 同请求在同一 generation 的租约仍
属于该设备时返回同一任务;同 key 不同请求返回 `409`;原领取已释放、取消或被
过期回收时返回稳定 `409 CLAIM_REPLAY_EXPIRED`。无任务的首次结果记录为
`NO_TASK`,同 key 重放仍返回 `204`。
4. 设备 heartbeat 记录服务端时间、App/Android/拼多多版本和两个确定性就绪位:
无障碍已连接、拼多多已安装。客户端上报的活跃任务只用于一致性检查,服务端任务
归属是权威。heartbeat 超过 2 分钟或任一就绪位为 false 时禁止领取。
5. 领取在单个 SQLite immediate transaction 内完成。若当前设备自己仍有一条已过期
`CLAIMED`,先回收该任务,避免设备唯一归属冲突;否则在 `PENDING` 或已过期
`CLAIMED` 中按创建时间最早、ID 最小选择。一个任务只能归属一台设备;同一设备
只允许一个未过期 `CLAIMED` 或任意 `RUNNING/WAITING_CONFIRMATION` 任务。
6. 初始 claim 租约默认 10 分钟。只有当前用户、当前设备、匹配 token hash 且租约
未过期时可以 start、release、heartbeat 或确认取消。所有到期计算只使用服务端
UTC 时钟,不信任 `client_time`。
7. `start` 只允许 `CLAIMED -> RUNNING`,在同一事务创建唯一 execution attempt,
并把运行租约改为默认 90 秒;请求携带 claim 响应的 `expected_version`,
`Idempotency-Key` 同请求重放返回同一 execution。
8. 运行 heartbeat 每 30 秒计划调用,只有 `RUNNING/WAITING_CONFIRMATION` 且
execution/generation 匹配时才更新 step、`last_seen_at` 和 90 秒运行租约。
过期运行租约拒绝续租和后续自动操作,任务保持当前非终态且绝不自动回到队列,
防止失联设备与新设备重复执行;恢复/失败归档后置。
9. `release` 只允许未过期 `CLAIMED -> PENDING`,请求必须匹配 `expected_version`,
并清除用户、设备、token 和租约。已开始任务不能 release。过期 `CLAIMED` 可由
下一次 claim 原子回收。
10. 管理取消 `PENDING/CLAIMED` 时立即进入 `CANCELED`;`RUNNING` 或
`WAITING_CONFIRMATION` 只设置取消请求,不伪装为已停止。任务 heartbeat 返回
`cancel_requested=true`,App 在安全检查点以 `expected_version` 调用
`cancel-ack` 后才进入 `CANCELED` 并结束 execution。
11. 每次状态迁移递增 `version` 并追加带 actor 的任务事件;heartbeat 只更新租约和
当前 step,不追加高频事件。claim/start/heartbeat/transition 响应都返回当前
`version`、`claim_generation`、服务端时间和租约到期时间。终态不可回退,非法
状态、错误设备、错误 token、execution 不匹配和过期租约使用彼此稳定但不泄露
其他任务内容的错误码。
12. 三个时长通过配置提供安全默认值和上下界:claim lease 10 分钟、运行 lease
90 秒、设备 heartbeat TTL 2 分钟。T-205 不启动后台定时清理器。
13. 参考图不暴露资产存储路径。App 只能通过 claim 响应中的 task-scoped URL,
携带当前 BUYER token、设备身份、`X-Claim-Token` 和 `claim_generation` 读取;
服务端同时校验当前状态和未过期租约,并返回 `private, no-store` JPEG。
## 方案
1. 新增 `00004_claims_and_lifecycle.sql`,扩展任务 claim/cancel 字段、设备就绪字段,
增加 execution 与生命周期幂等表,并扩展任务事件枚举;迁移必须可空兼容历史数据。
2. 在 domain 增加 claim、execution、状态迁移和就绪实体;在独立
`LifecycleService` 中完成输入校验、token hash、服务端时钟、错误映射和响应模型。
3. SQLite repository 使用显式事务完成 claim/reclaim、start、release、heartbeat、
取消请求/确认和幂等重放;所有条件更新同时校验状态、归属、token、租约和版本。
4. Gin 增加独立 BUYER 设备路由组,复用 T-204 Bearer middleware;handler 只解析
header/body、读取认证 principal、调用 usecase 和映射稳定响应。
5. 管理取消从仅 PENDING 扩展为上述安全语义;管理详情返回 claim、execution 和
取消请求的非秘密摘要,永不返回 claim token/hash。
6. claim 响应只给出 task-scoped 参考图 URL;图片 handler 在读取本地资产前重新
授权当前 claim,避免长期资产 URL 或跨任务枚举。
## 验收要点
- [x] migration 可 up/down/up,历史任务保持可读,数据库不存在原始 claim token。
- [x] heartbeat 身份、版本长度、就绪位和新鲜度校验正确,客户端不能冒充设备。
- [x] 多 goroutine/多连接并发 claim 同一任务时精确一个成功,不重复分配。
- [x] 同一设备不能领取第二条活跃任务;不同设备可以领取不同任务。
- [x] claim 首次响应丢失后用同 key/token 重放得到同一任务;冲突/过期重放被拒绝。
- [x] 无任务的幂等重放保持 204,不因稍后新增任务改变旧请求结果。
- [x] start 精确创建一个 execution;重复 start 同 key 返回同一 execution。
- [x] 错误设备、错误 token、过期 CLAIMED 租约、非法状态均不能 start/release。
- [x] 当前 claim 才能读取参考图,错误 token/generation/设备或过期租约均被拒绝。
- [x] RUNNING heartbeat 续租并返回取消标志;过期运行租约不会回队列或被其他设备领取。
- [x] 管理取消活跃任务只请求停止;App 安全确认后才进入 CANCELED。
- [x] 事件 actor、version、execution attempt 和状态迁移可审计,响应/日志不泄露秘密。
- [x] Go test/race/vet/gofmt、迁移/HTTP 并发 smoke、根脚本全部通过。
## 边界
- 不实现 Android HTTP TaskSource、token 安全存储、前台服务或页面;属于 T-206。
- 不实现候选、执行证据、批量事件、完成/失败结果归档;属于 T-207。
- 不实现后台通知、WebSocket、跨实例锁、自动重试或多设备调度运营页面。
- 不把过期 `RUNNING` 自动回到 `PENDING`,也不允许任何路径提交订单或支付。
- 不提交运行数据库、claim/access token、真实设备心跳或私有订单样本。
## 执行记录
### 2026-07-26:任务开始
- 基于提交 `49db5b8` 开始,工作区干净;T-203/T-204 依赖均已完成。
- codebase-memory MCP 本轮仍未暴露 graph 工具,按项目规则回退到 `rg` 和定点读取。
- 先固定 client-generated claim token、安全幂等重放、就绪 TTL、租约过期与取消确认
语义,避免在迁移和 handler 写完后再改变跨端协议。
### 2026-07-26:实现与验证完成
- 新增 migration 00004、生命周期领域/用例/SQLite 仓储和 BUYER Gin 路由;管理端
取消扩展为 `PENDING/CLAIMED` 立即取消、`RUNNING/WAITING_CONFIRMATION`
请求安全停止,并在任务详情展示非秘密 claim/execution 摘要。
- `go test -count=1 ./...` 共 192 个测试通过;全包 `go test -race -count=1 ./...`、
`go vet ./...`、`gofmt -d .`、三入口 Windows 构建和根 `init.ps1` 均通过。
- migration CLI 完成 `status -> up -> up -> down -> up`,第二次 up 无重复应用;
两个独立数据库连接和真实 TCP 并发 claim 都验证同设备精确一个成功。
- Playwright 连接真实 Gin/SQLite 流程验证任务详情在 1440x900、390x844、360x800
无横向溢出、破图或弹窗重叠;运行中“请求安全停止”分支由 SSR 测试覆盖。
- 独立审查发现并修复 start 后 claim 幂等重放被误判过期的问题;同设备优先恢复
自己过期 CLAIMED 的亲和策略已补充文档和测试。未发现 High 严重度问题。
+9
View File
@@ -137,3 +137,12 @@
- 影响:匿名与跨端凭证不能访问任务,新任务保留共享 `local-admin` scope 并单独记录
真实 ADMIN actor,创建/取消事件均可审计;T-205 可在稳定 BUYER + device 身份上
实现原子领取和租约。
## 2026-07-26 原子领取、租约和状态机
- 类型:阶段完成
- 内容:完成 T-205;实现设备 readiness、client-generated claim token、SQLite
原子领取、CLAIMED/运行租约、execution、task-scoped 参考图、管理安全停止和
App 取消确认。
- 影响:后端已具备 Android 手动领取和执行进度所需的稳定协议;T-206 可接入
HTTP TaskSource、凭证/claim token 安全存储和前台执行界面。