feat(tasks): implement atomic claims and leases
This commit is contained in:
@@ -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
@@ -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)。
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
¬Null,
|
||||
&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)
|
||||
@@ -82,7 +82,7 @@ type TaskRepository interface {
|
||||
string,
|
||||
string,
|
||||
) (domain.TaskDetail, error)
|
||||
CancelPendingTask(
|
||||
CancelTask(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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/数量的本地确定性回填;当前样本未提供预算,因此预算保持空。
|
||||
|
||||
@@ -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
@@ -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 和响应映射。
|
||||
|
||||
|
||||
@@ -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、
|
||||
|
||||
@@ -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 建立受控会话和设备身份
|
||||
|
||||
@@ -150,7 +150,8 @@
|
||||
- 无任务:显示安静的空状态和手动刷新,不循环弹错。
|
||||
- 权限缺失:提供打开对应系统设置的命令。
|
||||
- 已有任务:显示“继续任务”,不再领取。
|
||||
- 请求超时:先查询当前设备活跃任务,再允许重试。
|
||||
- 请求超时:保留请求前已安全保存的 claim token/幂等 key,先用同一请求重放并通过
|
||||
heartbeat 对比服务端活跃任务,再允许生成新请求。
|
||||
- 租约到期:预览页提示任务已释放,返回任务页。
|
||||
|
||||
**可访问性**
|
||||
|
||||
+87
-18
@@ -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
@@ -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`
|
||||
全部通过。
|
||||
|
||||
## 维护规则
|
||||
|
||||
发生以下变化时覆盖更新本文:
|
||||
|
||||
@@ -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 有活跃任务时启动后优先恢复该任务,不允许直接领取下一条。
|
||||
|
||||
@@ -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 严重度问题。
|
||||
@@ -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 安全存储和前台执行界面。
|
||||
|
||||
Reference in New Issue
Block a user