feat(t206): connect Android procurement tasks

This commit is contained in:
QiuSW
2026-07-27 11:11:16 +08:00
parent 9e698c1b6c
commit 45b66eeea7
35 changed files with 2634 additions and 81 deletions
+9 -1
View File
@@ -22,7 +22,7 @@ start/运行续租/release 和取消安全确认。
| `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_RUNNING_LEASE` | `30m` | RUNNING/等待确认的离线执行授权;允许 `5m` 至 `120m` |
| `CMROUBAO_READINESS_TTL` | `2m` | 设备就绪 heartbeat 新鲜度;允许 `30s` 至 `10m` |
不会自动读取 `.env`。本地配置和 `var/` 运行数据不得提交。
@@ -43,6 +43,9 @@ $env:CMROUBAO_AUTH_PASSWORD = "至少 12 个 UTF-8 字节"
go run ./cmd/authctl create-user ADMIN admin
Remove-Item Env:CMROUBAO_AUTH_PASSWORD
$env:CMROUBAO_AUTH_PASSWORD = "采购员独立强密码,至少 12 个 UTF-8 字节"
go run ./cmd/authctl create-user BUYER buyer01
Remove-Item Env:CMROUBAO_AUTH_PASSWORD
go run ./cmd/authctl create-device buyer-phone-01
# 发生人员离岗或设备风险时,现有凭证会随禁用立即失效
go run ./cmd/authctl disable-user buyer01
@@ -78,6 +81,11 @@ Cookie 与 BUYER Bearer token 不能互换。两类登录在凭证校验前按
和迁移都受当前用户、设备、claim generation、`X-Claim-Token` 与服务端租约约束;
原 claim token 只由 App 生成和保存,服务端数据库只存 SHA-256。
start 和任务 heartbeat 显式返回 `execution_expires_at`。默认运行授权为 30 分钟,
App 可每 30 秒 best-effort 滑动续期;过期后原设备 heartbeat 只同步状态且不延长
旧授权,并只接受 `SAFE_STOPPED` step;`RUNNING/WAITING_CONFIRMATION` 不自动
回队列。匹配原 execution/claim 的设备仍可确认已存在的管理取消请求。
默认 loopback 可使用 HTTP 开发。局域网监听必须同时设置 certificate/private key,
服务直接使用 TLS 启动,不会降级为明文。完整 API 合约见
[`../docs/api.md`](../docs/api.md)。
+1 -1
View File
@@ -138,7 +138,7 @@ 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,
RunningLease: 30 * time.Minute,
ReadinessTTL: 2 * time.Minute,
}, db)
if err != nil {
+3 -3
View File
@@ -23,7 +23,7 @@ const (
defaultDatabasePath = "var/cmroubao.db"
defaultAssetDirectory = "var/assets"
defaultClaimLease = 10 * time.Minute
defaultRunningLease = 90 * time.Second
defaultRunningLease = 30 * time.Minute
defaultReadinessTTL = 2 * time.Minute
)
@@ -121,8 +121,8 @@ func Load(lookup LookupEnvironment) (Config, error) {
lookup,
RunningLeaseEnvironment,
defaultRunningLease,
30*time.Second,
10*time.Minute,
5*time.Minute,
120*time.Minute,
)
if err != nil {
return Config{}, err
+10 -4
View File
@@ -30,7 +30,7 @@ func TestLoadUsesSafeDefaults(t *testing.T) {
t.Fatal("server safety limits must all be positive")
}
if cfg.ClaimLease != 10*time.Minute ||
cfg.RunningLease != 90*time.Second ||
cfg.RunningLease != 30*time.Minute ||
cfg.ReadinessTTL != 2*time.Minute {
t.Fatalf(
"lifecycle durations = %s / %s / %s",
@@ -52,7 +52,7 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
TLSCertificateEnvironment: "tmp/server.crt",
TLSPrivateKeyEnvironment: "tmp/server.key",
ClaimLeaseEnvironment: "15m",
RunningLeaseEnvironment: "2m",
RunningLeaseEnvironment: "45m",
ReadinessTTLEnvironment: "3m",
}
@@ -79,7 +79,7 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
)
}
if cfg.ClaimLease != 15*time.Minute ||
cfg.RunningLease != 2*time.Minute ||
cfg.RunningLease != 45*time.Minute ||
cfg.ReadinessTTL != 3*time.Minute {
t.Fatalf(
"lifecycle durations = %s / %s / %s",
@@ -174,10 +174,16 @@ func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) {
ClaimLeaseEnvironment: "59s",
},
},
{
name: "running lease below minimum",
values: map[string]string{
RunningLeaseEnvironment: "4m59s",
},
},
{
name: "running lease above maximum",
values: map[string]string{
RunningLeaseEnvironment: "11m",
RunningLeaseEnvironment: "121m",
},
},
{
@@ -498,13 +498,12 @@ func (s *Store) HeartbeatTask(
if err != nil {
return usecase.TaskHeartbeatRepositoryResult{}, err
}
if err := validateClaim(
if err := validateClaimOwner(
task,
request.UserID,
request.DeviceID,
request.ClaimGeneration,
request.ClaimTokenHash,
request.Now,
); err != nil {
return usecase.TaskHeartbeatRepositoryResult{}, err
}
@@ -512,6 +511,11 @@ func (s *Store) HeartbeatTask(
return usecase.TaskHeartbeatRepositoryResult{},
usecase.ErrTaskStateConflict
}
expired := !task.ClaimExpiresAt.After(request.Now)
if expired && request.Step != "SAFE_STOPPED" {
return usecase.TaskHeartbeatRepositoryResult{},
usecase.ErrClaimExpired
}
execution, err := getExecutionByID(ctx, tx, request.ExecutionID)
if err != nil {
return usecase.TaskHeartbeatRepositoryResult{}, err
@@ -525,7 +529,8 @@ func (s *Store) HeartbeatTask(
usecase.ErrExecutionMismatch
}
expiresAt := *task.ClaimExpiresAt
if request.MinimumExpiry.After(expiresAt) {
if !expired &&
request.MinimumExpiry.After(expiresAt) {
expiresAt = request.MinimumExpiry
}
result, err := tx.ExecContext(
@@ -561,8 +566,7 @@ func (s *Store) HeartbeatTask(
AND claimed_by_user_id = ?
AND claimed_by_device_id = ?
AND claim_generation = ?
AND claim_token_hash = ?
AND claim_expires_at > ?`,
AND claim_token_hash = ?`,
formatTimestamp(expiresAt),
formatTimestamp(request.Now),
request.TaskID,
@@ -570,7 +574,6 @@ func (s *Store) HeartbeatTask(
request.DeviceID,
request.ClaimGeneration,
request.ClaimTokenHash,
formatTimestamp(request.Now),
)
if err != nil {
return usecase.TaskHeartbeatRepositoryResult{}, repositoryFailure(err)
@@ -581,7 +584,7 @@ func (s *Store) HeartbeatTask(
}
if affected != 1 {
return usecase.TaskHeartbeatRepositoryResult{},
usecase.ErrClaimExpired
usecase.ErrTaskVersionConflict
}
_, err = tx.ExecContext(
ctx,
@@ -797,13 +800,12 @@ func (s *Store) AcknowledgeTaskCancellation(
if err != nil {
return domain.PurchaseTask{}, false, err
}
if err := validateClaim(
if err := validateClaimOwner(
task,
request.UserID,
request.DeviceID,
request.ClaimGeneration,
request.ClaimTokenHash,
request.Now,
); err != nil {
return domain.PurchaseTask{}, false, err
}
@@ -846,8 +848,7 @@ func (s *Store) AcknowledgeTaskCancellation(
AND claimed_by_user_id = ?
AND claimed_by_device_id = ?
AND claim_generation = ?
AND claim_token_hash = ?
AND claim_expires_at > ?`,
AND claim_token_hash = ?`,
formatTimestamp(request.Now),
formatTimestamp(request.Now),
request.TaskID,
@@ -856,7 +857,6 @@ func (s *Store) AcknowledgeTaskCancellation(
request.DeviceID,
request.ClaimGeneration,
request.ClaimTokenHash,
formatTimestamp(request.Now),
)
if err != nil {
return domain.PurchaseTask{}, false, repositoryFailure(err)
@@ -1041,6 +1041,28 @@ func validateClaim(
generation int64,
tokenHash string,
now time.Time,
) error {
if err := validateClaimOwner(
task,
userID,
deviceID,
generation,
tokenHash,
); err != nil {
return err
}
if task.ClaimExpiresAt == nil || !task.ClaimExpiresAt.After(now) {
return usecase.ErrClaimExpired
}
return nil
}
func validateClaimOwner(
task domain.PurchaseTask,
userID string,
deviceID string,
generation int64,
tokenHash string,
) error {
if task.ClaimedByUserID == nil ||
*task.ClaimedByUserID != userID ||
@@ -1051,7 +1073,7 @@ func validateClaim(
*task.ClaimTokenHash != tokenHash {
return usecase.ErrClaimInvalid
}
if task.ClaimExpiresAt == nil || !task.ClaimExpiresAt.After(now) {
if task.ClaimExpiresAt == nil {
return usecase.ErrClaimExpired
}
return nil
@@ -748,6 +748,29 @@ func TestLifecycleRepositoryHeartbeatExtendsLeaseWithoutEvent(
},
)
assertLifecycleError(t, err, usecase.ErrClaimExpired)
expiredHeartbeat, err := fixture.store.HeartbeatTask(
ctx,
usecase.TaskHeartbeatRepositoryRequest{
UserID: fixture.buyerOneID,
DeviceID: fixture.deviceOneID,
TaskID: claimed.ID,
ExecutionID: started.Execution.ID,
ClaimGeneration: claimed.ClaimGeneration,
ClaimTokenHash: tokenHash,
Step: "SAFE_STOPPED",
Now: expiredAt,
MinimumExpiry: expiredAt.Add(90 * time.Second),
},
)
if err != nil {
t.Fatalf("expired HeartbeatTask() error = %v", err)
}
if expiredHeartbeat.Task.ClaimExpiresAt == nil ||
!expiredHeartbeat.Task.ClaimExpiresAt.Equal(expiredAt) ||
expiredHeartbeat.Execution.CurrentStep != "SAFE_STOPPED" {
t.Fatalf("expired heartbeat = %+v", expiredHeartbeat)
}
}
func TestLifecycleRepositoryReleaseIsIdempotentAndInvalidatesClaim(
@@ -870,6 +893,7 @@ func TestLifecycleRepositoryCancelAcknowledgementEndsExecution(
if err != nil {
t.Fatalf("StartTask() error = %v", err)
}
expiredAt := *started.Task.ClaimExpiresAt
cancelAt := startRequest.Now.Add(10 * time.Second)
adminID := fixture.adminUserID
cancelRequested, err := fixture.store.CancelTask(
@@ -940,16 +964,16 @@ func TestLifecycleRepositoryCancelAcknowledgementEndsExecution(
ExecutionID: started.Execution.ID,
ClaimGeneration: claimed.ClaimGeneration,
ClaimTokenHash: tokenHash,
Step: "STOPPING",
Now: cancelAt.Add(time.Second),
MinimumExpiry: cancelAt.Add(91 * time.Second),
Step: "SAFE_STOPPED",
Now: expiredAt,
MinimumExpiry: expiredAt.Add(90 * time.Second),
},
)
if err != nil || !heartbeat.CancelRequested {
t.Fatalf("cancel heartbeat = %+v, error = %v", heartbeat, err)
}
ackAt := cancelAt.Add(2 * time.Second)
ackAt := expiredAt.Add(time.Second)
request := usecase.CancelAcknowledgementRepositoryRequest{
UserID: fixture.buyerOneID,
DeviceID: fixture.deviceOneID,
@@ -5,6 +5,7 @@ import (
"net/http"
"strconv"
"strings"
"time"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/transport/authcommon"
@@ -235,8 +236,11 @@ func (handler *deviceHandlers) startTask(ctx *gin.Context) {
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"task": deviceTaskResponse(result.Task),
"execution": executionResponse(result.Execution),
"task": deviceTaskResponse(result.Task),
"execution": deviceExecutionResponse(
result.Execution,
result.Task.ClaimExpiresAt,
),
"replayed": result.Replayed,
"server_time": formatTime(result.ServerTime),
})
@@ -275,8 +279,11 @@ func (handler *deviceHandlers) heartbeatTask(ctx *gin.Context) {
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"task": deviceTaskResponse(result.Task),
"execution": executionResponse(result.Execution),
"task": deviceTaskResponse(result.Task),
"execution": deviceExecutionResponse(
result.Execution,
result.Task.ClaimExpiresAt,
),
"cancel_requested": result.CancelRequested,
"server_time": formatTime(result.ServerTime),
})
@@ -453,3 +460,12 @@ func deviceTaskResponse(task domain.PurchaseTask) gin.H {
"currency": task.Currency,
}
}
func deviceExecutionResponse(
execution domain.TaskExecution,
expiresAt *time.Time,
) gin.H {
response := executionResponse(execution)
response["execution_expires_at"] = formatOptionalTime(expiresAt)
return response
}
@@ -432,9 +432,14 @@ func TestDeviceStartAndTaskHeartbeatUseClaimContract(t *testing.T) {
if started.Task.Status != string(domain.TaskStatusRunning) ||
started.Execution.ID == "" ||
started.Execution.CurrentStep != "PREFLIGHT" ||
started.Execution.ExpiresAt.IsZero() ||
started.Execution.OrderSubmitted {
t.Fatalf("start response = %+v", started)
}
if remaining := time.Until(started.Execution.ExpiresAt); remaining < 9*time.Minute ||
remaining > 11*time.Minute {
t.Fatalf("start execution expiry remaining = %s", remaining)
}
assertNoClaimSecret(t, startedResponse, testOpaqueToken)
heartbeatBody := fmt.Sprintf(
@@ -464,6 +469,8 @@ func TestDeviceStartAndTaskHeartbeatUseClaimContract(t *testing.T) {
if heartbeat.Task.Status != string(domain.TaskStatusRunning) ||
heartbeat.Execution.ID != started.Execution.ID ||
heartbeat.Execution.CurrentStep != "SEARCH_RESULTS" ||
heartbeat.Execution.ExpiresAt.IsZero() ||
heartbeat.Execution.ExpiresAt.Before(started.Execution.ExpiresAt) ||
heartbeat.Execution.OrderSubmitted ||
heartbeat.CancelRequested {
t.Fatalf("task heartbeat response = %+v", heartbeat)
@@ -918,9 +925,10 @@ type deviceLifecycleResponse struct {
ReferenceImageURL string `json:"reference_image_url"`
} `json:"task"`
Execution struct {
ID string `json:"id"`
CurrentStep string `json:"current_step"`
OrderSubmitted bool `json:"order_submitted"`
ID string `json:"id"`
CurrentStep string `json:"current_step"`
OrderSubmitted bool `json:"order_submitted"`
ExpiresAt time.Time `json:"execution_expires_at"`
} `json:"execution"`
Replayed bool `json:"replayed"`
CancelRequested bool `json:"cancel_requested"`